markdown smoother stream

This commit is contained in:
Nate Kelley 2025-07-21 14:43:06 -06:00
parent a569460d17
commit 9d6d613334
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
9 changed files with 399 additions and 44 deletions

View File

@ -496,3 +496,249 @@ export const ParagraphToListTransition: Story = {
); );
} }
}; };
// Test case for broken up inline code streaming
const brokenInlineCodeTokens = [
{
token: 'This paragraph contains several inline code examples. Here is a complete ',
delayMs: 300
},
{
token: '`complete_code_block`',
delayMs: 200
},
{
token: ' HOLD ',
delayMs: 1000
},
{
token: " that arrives in one token. Now let's test broken inline code: ",
delayMs: 300
},
{
token: '`br',
delayMs: 400
},
{
token: 'oken',
delayMs: 2000
},
{
token: '_code',
delayMs: 250
},
{
token: '_block',
delayMs: 260
},
{
token: '`',
delayMs: 650
},
{
token: " where the backticks and content are split. Here's another example with ",
delayMs: 300
},
{
token: '`',
delayMs: 500
},
{
token: 'useState',
delayMs: 200
},
{
token: '`',
delayMs: 400
},
{
token: ' and ',
delayMs: 150
},
{
token: '`',
delayMs: 300
},
{
token: 'useEffect',
delayMs: 200
},
{
token: '`',
delayMs: 300
},
{
token: ' hooks. Sometimes the content itself is split like ',
delayMs: 300
},
{
token: '`',
delayMs: 200
},
{
token: 'customer',
delayMs: 150
},
{
token: '_all',
delayMs: 150
},
{
token: '_time',
delayMs: 150
},
{
token: '_clv',
delayMs: 150
},
{
token: '`',
delayMs: 200
},
{
token: ' and we need to wait for all parts to complete the inline code block.\n\n',
delayMs: 400
},
{
token: 'Another paragraph with more examples: ',
delayMs: 200
},
{
token: '`',
delayMs: 300
},
{
token: 'const',
delayMs: 150
},
{
token: ' result',
delayMs: 150
},
{
token: ' =',
delayMs: 100
},
{
token: ' await',
delayMs: 150
},
{
token: ' fetch',
delayMs: 150
},
{
token: '()',
delayMs: 100
},
{
token: '`',
delayMs: 300
},
{
token: ' shows how complex code can be streamed. Mixed with normal ',
delayMs: 300
},
{
token: '`simple`',
delayMs: 150
},
{
token: ' inline code that arrives complete.\n\n',
delayMs: 200
},
{
token: 'Final test case: ',
delayMs: 200
},
{
token: '`',
delayMs: 400
},
{
token: 'database',
delayMs: 200
},
{
token: '.query',
delayMs: 150
},
{
token: '("SELECT',
delayMs: 150
},
{
token: ' * FROM',
delayMs: 150
},
{
token: ' customers',
delayMs: 150
},
{
token: ' WHERE',
delayMs: 150
},
{
token: ' active',
delayMs: 150
},
{
token: ' = true")',
delayMs: 200
},
{
token: '`',
delayMs: 300
},
{
token: ' demonstrates SQL code streaming.',
delayMs: 200
},
{
token: 'HOLD',
delayMs: 50000
}
];
export const InlineCodeStreaming: Story = {
render: () => {
const { isStreamFinished, output } = useStreamTokenArray(brokenInlineCodeTokens);
return (
<div className="flex flex-col space-y-4">
<div className="text-lg font-bold">Testing Broken Inline Code Streaming</div>
<div className="mb-4 text-sm text-gray-600">
Watch how inline code renders when backticks and content are split across multiple stream
tokens
</div>
<div className="flex w-full space-x-4">
<div className="w-1/2">
<h3 className="mb-2 font-semibold">Animated Output</h3>
<div className="rounded border border-gray-300 p-4">
<AppMarkdownStreaming
content={output}
isStreamFinished={isStreamFinished}
animation="fadeIn"
animationDuration={500}
animationTimingFunction="ease-in-out"
/>
</div>
</div>
<div className="w-1/2">
<h3 className="mb-2 font-semibold">Raw Streaming Content</h3>
<div className="max-h-[400px] overflow-y-auto rounded border border-gray-300 p-4">
<pre className="font-mono text-sm whitespace-pre-wrap">{output}</pre>
</div>
<div className="mt-2 text-sm text-gray-600">
Stream Status: {isStreamFinished ? '✅ Complete' : '⏳ Streaming...'}
</div>
<div className="mt-1 text-xs text-gray-500">
Current length: {output.length} characters
</div>
</div>
</div>
</div>
);
}
};

View File

@ -1,32 +1,51 @@
import type { BlockMatch } from '@llm-ui/react'; import type { BlockMatch } from '@llm-ui/react';
import AnimatedMarkdown from '../../typography/AnimatedMarkdown/AnimatedMarkdown'; import AnimatedMarkdown from '../../typography/AnimatedMarkdown/AnimatedMarkdown';
import React from 'react'; import React, { useMemo } from 'react';
import { useAppMarkdownStreaming } from './AppMarkdownStreaming'; import { useAppMarkdownStreaming } from './AppMarkdownStreaming';
import { findPartialInlineCode } from './inlineCodeHelpers';
type LLMAnimatedMarkdownProps = { type LLMAnimatedMarkdownProps = {
blockMatch: BlockMatch; blockMatch: BlockMatch;
}; };
export const LLMAnimatedMarkdown: React.FC<LLMAnimatedMarkdownProps> = ({ blockMatch }) => { export const LLMAnimatedMarkdown: React.FC<LLMAnimatedMarkdownProps> = React.memo(
const markdown = blockMatch.output; ({ blockMatch }) => {
const { const markdown = blockMatch.llmOutput;
animation, const {
stripFormatting, animation,
isThrottleStreamingFinished, stripFormatting,
animationDuration,
animationTimingFunction
} = useAppMarkdownStreaming();
return ( animationDuration,
<AnimatedMarkdown isStreamFinished,
content={markdown} animationTimingFunction
isStreamFinished={blockMatch.isComplete || isThrottleStreamingFinished} } = useAppMarkdownStreaming();
animation={animation}
animationDuration={animationDuration} const optimizedContent = useMemo(() => {
animationTimingFunction={animationTimingFunction} const partialMatch = findPartialInlineCode(markdown);
stripFormatting={stripFormatting}
/> // If there's a partial inline code match, only show content up to the start of the partial match
); // This naturally excludes the offending backtick without affecting complete inline code blocks
}; if (partialMatch) {
return markdown.slice(0, partialMatch.startIndex);
}
// No partial match, show all content as-is
return markdown;
}, [markdown]);
return (
<AnimatedMarkdown
content={optimizedContent}
isStreamFinished={isStreamFinished && blockMatch.isComplete}
animation={animation}
animationDuration={animationDuration}
animationTimingFunction={animationTimingFunction}
stripFormatting={stripFormatting}
/>
);
}
);
LLMAnimatedMarkdown.displayName = 'LLMAnimatedMarkdown';
export default LLMAnimatedMarkdown; export default LLMAnimatedMarkdown;

View File

@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { findPartialInlineCode } from './inlineCodeHelpers';
describe('findPartialInlineCode', () => {
it('should find partial inline code (unclosed backtick)', () => {
// Test case: Text with an unclosed backtick at the end
const text = 'This is some text with `unclosed code';
const result = findPartialInlineCode(text);
expect(result).not.toBeNull();
expect(result).toEqual({
startIndex: 23, // Position of the backtick
endIndex: 37, // End of the partial code
outputRaw: '`unclosed code'
});
});
it('should return null for complete inline code', () => {
// Test case: Text with properly closed inline code
const text = 'This is some text with `complete code` here';
const result = findPartialInlineCode(text);
// Should return null because the code block is complete (properly closed)
expect(result).toBeNull();
});
it('should return null when no inline code is present', () => {
// Test case: Text with no backticks at all
const text = 'This is just plain text without any code blocks';
const result = findPartialInlineCode(text);
// Should return null because there are no backticks
expect(result).toBeNull();
});
});

View File

@ -0,0 +1,55 @@
// Helper functions for inline code detection in streaming markdown
import type { LLMOutputMatcher } from '@llm-ui/react';
/**
* Finds complete inline code blocks (text between single backticks)
* @returns A function that finds complete inline code in the input
*/
export const findCompleteInlineCode = (): LLMOutputMatcher => {
return (input: string) => {
// Match inline code that is NOT part of a code block (```).
// Look for single backticks that are not preceded or followed by additional backticks
const regex = /(?<!`)`(?!``)[^`\n]+`(?!`)/;
const match = regex.exec(input);
if (match && match.index !== undefined) {
return {
startIndex: match.index,
endIndex: match.index + 1,
outputRaw: match[0]
};
}
return undefined;
};
};
/**
* Finds partial inline code blocks (unclosed inline code)
* @returns A function that finds partial inline code in the input
*/
export function findPartialInlineCode(
text: string
): { startIndex: number; endIndex: number; outputRaw: string } | null {
// Find all single backticks (not part of code blocks)
const backticks: number[] = [];
for (let i = 0; i < text.length; i++) {
if (text[i] === '`' && text[i - 1] !== '`' && text[i + 1] !== '`') {
backticks.push(i);
}
}
// If odd number of backticks, the last one is unclosed
if (backticks.length % 2 === 1) {
const startIndex = backticks[backticks.length - 1];
return {
startIndex,
endIndex: text.length,
outputRaw: text.substring(startIndex)
};
}
return null;
}

View File

@ -7,14 +7,29 @@ import { markdownLookBack } from '@llm-ui/markdown';
const throttle = throttleBasic({ const throttle = throttleBasic({
// show output as soon as it arrives // show output as soon as it arrives
readAheadChars: 0, readAheadChars: 0,
// stay literally at the LLMs pace // stay literally at the LLM's pace
targetBufferChars: 10, targetBufferChars: 10,
adjustPercentage: 0.4, adjustPercentage: 0.4,
frameLookBackMs: 10000, frameLookBackMs: 10000,
// split that into 250ms windows for smoothing // split that into 250 ms windows for smoothing
windowLookBackMs: 250 windowLookBackMs: 250
}); });
const blocks = [
// Handle code blocks (triple backticks)
{
component: CodeComponentStreaming,
findCompleteMatch: findCompleteCodeBlock(),
findPartialMatch: findPartialCodeBlock(),
lookBack: codeBlockLookBack()
}
];
const fallbackBlock = {
component: LLMAnimatedMarkdown,
lookBack: markdownLookBack()
};
export const useMarkdownStreaming = ({ export const useMarkdownStreaming = ({
content, content,
isStreamFinished isStreamFinished
@ -24,18 +39,8 @@ export const useMarkdownStreaming = ({
}) => { }) => {
return useLLMOutput({ return useLLMOutput({
llmOutput: content, llmOutput: content,
fallbackBlock: { fallbackBlock,
component: LLMAnimatedMarkdown, blocks,
lookBack: markdownLookBack()
},
blocks: [
{
component: CodeComponentStreaming,
findCompleteMatch: findCompleteCodeBlock(),
findPartialMatch: findPartialCodeBlock(),
lookBack: codeBlockLookBack()
}
],
isStreamFinished, isStreamFinished,
throttle throttle
}); });

View File

@ -8,6 +8,7 @@ import { MarkdownAnimation } from './animation-helpers';
import styles from './AnimatedMarkdown.module.css'; import styles from './AnimatedMarkdown.module.css';
import { cn } from '@/lib/classMerge'; import { cn } from '@/lib/classMerge';
import './animations.css'; import './animations.css';
import { useMount } from '../../../../hooks';
export interface AnimatedMarkdownProps { export interface AnimatedMarkdownProps {
className?: string; className?: string;
@ -28,13 +29,8 @@ const AnimatedMarkdown: React.FC<AnimatedMarkdownProps> = ({
animationTimingFunction = 'ease-in-out', animationTimingFunction = 'ease-in-out',
isStreamFinished = true, isStreamFinished = true,
stripFormatting = false, stripFormatting = false,
className className
}) => { }) => {
const optimizedContent = useMemo(() => {
return content;
}, [content]);
const { components } = useMarkdownComponents({ const { components } = useMarkdownComponents({
animation, animation,
animationDuration, animationDuration,
@ -50,7 +46,7 @@ const AnimatedMarkdown: React.FC<AnimatedMarkdownProps> = ({
// remarkPlugins are used to extend or modify the Markdown parsing behavior. // remarkPlugins are used to extend or modify the Markdown parsing behavior.
// Here, remarkGfm enables GitHub Flavored Markdown features (like tables, strikethrough, task lists). // Here, remarkGfm enables GitHub Flavored Markdown features (like tables, strikethrough, task lists).
remarkPlugins={remarkPlugins}> remarkPlugins={remarkPlugins}>
{optimizedContent} {content}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
); );

View File

@ -5,6 +5,7 @@ import { cva } from 'class-variance-authority';
import { StreamingMessageCode } from '../../streaming/StreamingMessageCode'; import { StreamingMessageCode } from '../../streaming/StreamingMessageCode';
import { cn } from '@/lib/classMerge'; import { cn } from '@/lib/classMerge';
import { useAnimationContext, AnimationProvider } from './AnimationContext'; import { useAnimationContext, AnimationProvider } from './AnimationContext';
import { useMount } from '../../../../hooks';
type MarkdownComponentProps = { type MarkdownComponentProps = {
children: React.ReactNode; children: React.ReactNode;

View File

@ -1,2 +1 @@
export { default as AnimatedMarkdown } from './AnimatedMarkdown'; export { default as AnimatedMarkdown } from './AnimatedMarkdown';
export { default as LLMAnimatedMarkdown } from '../AppMarkdownStreaming/LLMAnimatedMarkdown';

View File

@ -23,6 +23,7 @@ import {
BreakComponent, BreakComponent,
CodeComponent CodeComponent
} from './MarkdownComponent'; } from './MarkdownComponent';
import { useMount, useWhyDidYouUpdate } from '../../../../hooks';
interface UseMarkdownComponentsProps { interface UseMarkdownComponentsProps {
stripFormatting?: boolean; stripFormatting?: boolean;
@ -139,8 +140,6 @@ export const useMarkdownComponents = ({
</TableRowComponent> </TableRowComponent>
), ),
// Components WITHOUT animations
// Components WITH animations (updated)
ul: ({ children, className, style }) => ( ul: ({ children, className, style }) => (
<UnorderedListComponent {...commonProps} className={className} style={style}> <UnorderedListComponent {...commonProps} className={className} style={style}>
{children} {children}