mirror of https://github.com/buster-so/buster.git
markdown smoother stream
This commit is contained in:
parent
a569460d17
commit
9d6d613334
|
@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,32 +1,51 @@
|
|||
import type { BlockMatch } from '@llm-ui/react';
|
||||
import AnimatedMarkdown from '../../typography/AnimatedMarkdown/AnimatedMarkdown';
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAppMarkdownStreaming } from './AppMarkdownStreaming';
|
||||
import { findPartialInlineCode } from './inlineCodeHelpers';
|
||||
|
||||
type LLMAnimatedMarkdownProps = {
|
||||
blockMatch: BlockMatch;
|
||||
};
|
||||
|
||||
export const LLMAnimatedMarkdown: React.FC<LLMAnimatedMarkdownProps> = ({ blockMatch }) => {
|
||||
const markdown = blockMatch.output;
|
||||
const {
|
||||
animation,
|
||||
stripFormatting,
|
||||
isThrottleStreamingFinished,
|
||||
animationDuration,
|
||||
animationTimingFunction
|
||||
} = useAppMarkdownStreaming();
|
||||
export const LLMAnimatedMarkdown: React.FC<LLMAnimatedMarkdownProps> = React.memo(
|
||||
({ blockMatch }) => {
|
||||
const markdown = blockMatch.llmOutput;
|
||||
const {
|
||||
animation,
|
||||
stripFormatting,
|
||||
|
||||
return (
|
||||
<AnimatedMarkdown
|
||||
content={markdown}
|
||||
isStreamFinished={blockMatch.isComplete || isThrottleStreamingFinished}
|
||||
animation={animation}
|
||||
animationDuration={animationDuration}
|
||||
animationTimingFunction={animationTimingFunction}
|
||||
stripFormatting={stripFormatting}
|
||||
/>
|
||||
);
|
||||
};
|
||||
animationDuration,
|
||||
isStreamFinished,
|
||||
animationTimingFunction
|
||||
} = useAppMarkdownStreaming();
|
||||
|
||||
const optimizedContent = useMemo(() => {
|
||||
const partialMatch = findPartialInlineCode(markdown);
|
||||
|
||||
// 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;
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
}
|
|
@ -7,14 +7,29 @@ import { markdownLookBack } from '@llm-ui/markdown';
|
|||
const throttle = throttleBasic({
|
||||
// show output as soon as it arrives
|
||||
readAheadChars: 0,
|
||||
// stay literally at the LLM’s pace
|
||||
// stay literally at the LLM's pace
|
||||
targetBufferChars: 10,
|
||||
adjustPercentage: 0.4,
|
||||
frameLookBackMs: 10000,
|
||||
// split that into 250 ms windows for smoothing
|
||||
// split that into 250 ms windows for smoothing
|
||||
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 = ({
|
||||
content,
|
||||
isStreamFinished
|
||||
|
@ -24,18 +39,8 @@ export const useMarkdownStreaming = ({
|
|||
}) => {
|
||||
return useLLMOutput({
|
||||
llmOutput: content,
|
||||
fallbackBlock: {
|
||||
component: LLMAnimatedMarkdown,
|
||||
lookBack: markdownLookBack()
|
||||
},
|
||||
blocks: [
|
||||
{
|
||||
component: CodeComponentStreaming,
|
||||
findCompleteMatch: findCompleteCodeBlock(),
|
||||
findPartialMatch: findPartialCodeBlock(),
|
||||
lookBack: codeBlockLookBack()
|
||||
}
|
||||
],
|
||||
fallbackBlock,
|
||||
blocks,
|
||||
isStreamFinished,
|
||||
throttle
|
||||
});
|
||||
|
|
|
@ -8,6 +8,7 @@ import { MarkdownAnimation } from './animation-helpers';
|
|||
import styles from './AnimatedMarkdown.module.css';
|
||||
import { cn } from '@/lib/classMerge';
|
||||
import './animations.css';
|
||||
import { useMount } from '../../../../hooks';
|
||||
|
||||
export interface AnimatedMarkdownProps {
|
||||
className?: string;
|
||||
|
@ -28,13 +29,8 @@ const AnimatedMarkdown: React.FC<AnimatedMarkdownProps> = ({
|
|||
animationTimingFunction = 'ease-in-out',
|
||||
isStreamFinished = true,
|
||||
stripFormatting = false,
|
||||
|
||||
className
|
||||
}) => {
|
||||
const optimizedContent = useMemo(() => {
|
||||
return content;
|
||||
}, [content]);
|
||||
|
||||
const { components } = useMarkdownComponents({
|
||||
animation,
|
||||
animationDuration,
|
||||
|
@ -50,7 +46,7 @@ const AnimatedMarkdown: React.FC<AnimatedMarkdownProps> = ({
|
|||
// remarkPlugins are used to extend or modify the Markdown parsing behavior.
|
||||
// Here, remarkGfm enables GitHub Flavored Markdown features (like tables, strikethrough, task lists).
|
||||
remarkPlugins={remarkPlugins}>
|
||||
{optimizedContent}
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -5,6 +5,7 @@ import { cva } from 'class-variance-authority';
|
|||
import { StreamingMessageCode } from '../../streaming/StreamingMessageCode';
|
||||
import { cn } from '@/lib/classMerge';
|
||||
import { useAnimationContext, AnimationProvider } from './AnimationContext';
|
||||
import { useMount } from '../../../../hooks';
|
||||
|
||||
type MarkdownComponentProps = {
|
||||
children: React.ReactNode;
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
export { default as AnimatedMarkdown } from './AnimatedMarkdown';
|
||||
export { default as LLMAnimatedMarkdown } from '../AppMarkdownStreaming/LLMAnimatedMarkdown';
|
||||
|
|
|
@ -23,6 +23,7 @@ import {
|
|||
BreakComponent,
|
||||
CodeComponent
|
||||
} from './MarkdownComponent';
|
||||
import { useMount, useWhyDidYouUpdate } from '../../../../hooks';
|
||||
|
||||
interface UseMarkdownComponentsProps {
|
||||
stripFormatting?: boolean;
|
||||
|
@ -139,8 +140,6 @@ export const useMarkdownComponents = ({
|
|||
</TableRowComponent>
|
||||
),
|
||||
|
||||
// Components WITHOUT animations
|
||||
// Components WITH animations (updated)
|
||||
ul: ({ children, className, style }) => (
|
||||
<UnorderedListComponent {...commonProps} className={className} style={style}>
|
||||
{children}
|
||||
|
|
Loading…
Reference in New Issue