mirror of https://github.com/buster-so/buster.git
update streaming to use
This commit is contained in:
parent
845e9a73fa
commit
fa21c4246e
|
@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/nextjs';
|
|||
import type { ReportElementsWithIds, ReportElementWithId } from '@buster/server-shared/reports';
|
||||
import { ReportEditor } from './ReportEditor';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '../buttons/Button';
|
||||
|
||||
// StreamingContentExample component that demonstrates streaming capabilities
|
||||
const StreamingContentExample: React.FC<{
|
||||
|
@ -9,7 +10,16 @@ const StreamingContentExample: React.FC<{
|
|||
initialContent?: ReportElementsWithIds;
|
||||
streamingDelay?: number;
|
||||
className?: string;
|
||||
}> = ({ isStreaming = false, initialContent = [], streamingDelay = 1000, className }) => {
|
||||
showStreamingButton?: boolean;
|
||||
batchSize?: number;
|
||||
}> = ({
|
||||
isStreaming = false,
|
||||
initialContent = [],
|
||||
streamingDelay = 1000,
|
||||
className,
|
||||
showStreamingButton = false,
|
||||
batchSize = 1
|
||||
}) => {
|
||||
const [content, setContent] = useState<ReportElementsWithIds>(initialContent);
|
||||
const [isStreamingActive, setIsStreamingActive] = useState(isStreaming);
|
||||
|
||||
|
@ -19,39 +29,56 @@ const StreamingContentExample: React.FC<{
|
|||
|
||||
const streamingContent: ReportElementsWithIds = [
|
||||
{
|
||||
id: '1',
|
||||
id: 'id-1',
|
||||
type: 'p',
|
||||
children: [{ text: 'This is a streaming report that updates in real-time.' }]
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
id: 'id-2',
|
||||
type: 'p',
|
||||
children: [{ text: 'The content is being generated dynamically as you watch.' }]
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
id: 'id-3',
|
||||
type: 'p',
|
||||
children: [
|
||||
{ text: 'Each paragraph appears with a slight delay to simulate AI generation.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
id: 'id-4',
|
||||
type: 'p',
|
||||
children: [{ text: 'This demonstrates the streaming capabilities of the report editor.' }]
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
id: 'id-5',
|
||||
type: 'p',
|
||||
children: [{ text: 'The editor remains responsive and updates smoothly.' }]
|
||||
},
|
||||
{
|
||||
id: 'id-6',
|
||||
type: 'p',
|
||||
children: [{ text: 'Batch streaming allows multiple items to be added at once.' }]
|
||||
},
|
||||
{
|
||||
id: 'id-7',
|
||||
type: 'p',
|
||||
children: [{ text: 'This creates a more efficient streaming experience.' }]
|
||||
},
|
||||
{
|
||||
id: 'id-8',
|
||||
type: 'p',
|
||||
children: [{ text: 'Perfect for scenarios where content comes in chunks.' }]
|
||||
}
|
||||
];
|
||||
|
||||
let currentIndex = 0;
|
||||
const interval = setInterval(() => {
|
||||
if (currentIndex < streamingContent.length) {
|
||||
setContent((prev) => [...prev, streamingContent[currentIndex]]);
|
||||
currentIndex++;
|
||||
// Add batchSize number of items at once
|
||||
const itemsToAdd = streamingContent.slice(currentIndex, currentIndex + batchSize);
|
||||
setContent((prev) => [...prev, ...itemsToAdd]);
|
||||
currentIndex += batchSize;
|
||||
} else {
|
||||
setIsStreamingActive(false);
|
||||
clearInterval(interval);
|
||||
|
@ -59,7 +86,7 @@ const StreamingContentExample: React.FC<{
|
|||
}, streamingDelay);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isStreamingActive, streamingDelay]);
|
||||
}, [isStreamingActive, streamingDelay, batchSize]);
|
||||
|
||||
const handleValueChange = (newContent: ReportElementsWithIds) => {
|
||||
if (!isStreamingActive) {
|
||||
|
@ -67,8 +94,27 @@ const StreamingContentExample: React.FC<{
|
|||
}
|
||||
};
|
||||
|
||||
const handleStartStreaming = () => {
|
||||
setIsStreamingActive(true);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setIsStreamingActive(false);
|
||||
setContent(initialContent);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className={'report-editor h-full w-full rounded border p-3'}>
|
||||
{showStreamingButton && (
|
||||
<div className="mb-4 flex gap-2">
|
||||
<Button onClick={handleStartStreaming} disabled={isStreamingActive} variant="default">
|
||||
Start Streaming
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="outlined">
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ReportEditor
|
||||
value={content}
|
||||
placeholder="Start typing or watch streaming content..."
|
||||
|
@ -85,7 +131,7 @@ const meta: Meta<typeof StreamingContentExample> = {
|
|||
title: 'UI/Report/StreamingReportEditor',
|
||||
component: StreamingContentExample,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
layout: 'fullscreen',
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
|
@ -102,6 +148,14 @@ const meta: Meta<typeof StreamingContentExample> = {
|
|||
streamingDelay: {
|
||||
control: { type: 'number', min: 100, max: 3000, step: 100 },
|
||||
description: 'Delay between streaming content updates (ms)'
|
||||
},
|
||||
showStreamingButton: {
|
||||
control: 'boolean',
|
||||
description: 'Whether to show the streaming control buttons'
|
||||
},
|
||||
batchSize: {
|
||||
control: { type: 'number', min: 1, max: 5, step: 1 },
|
||||
description: 'Number of items to add at once during streaming'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -113,106 +167,276 @@ type Story = StoryObj<typeof meta>;
|
|||
export const Default: Story = {
|
||||
args: {
|
||||
isStreaming: false,
|
||||
streamingDelay: 1000
|
||||
streamingDelay: 1000,
|
||||
showStreamingButton: true
|
||||
}
|
||||
};
|
||||
|
||||
// Story for intelligent content streaming
|
||||
export const IntelligentContentStreaming: Story = {
|
||||
args: {
|
||||
isStreaming: true,
|
||||
streamingDelay: 800
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Demonstrates streaming content with ID-based replacement for dynamic updates.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Story for custom value streaming
|
||||
export const CustomValueStreaming: Story = {
|
||||
args: {
|
||||
isStreaming: true,
|
||||
streamingDelay: 500,
|
||||
initialContent: [
|
||||
{
|
||||
id: 'pre-existing',
|
||||
type: 'p',
|
||||
children: [{ text: 'This content was here before streaming began.' }]
|
||||
}
|
||||
]
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Shows streaming of pre-formatted Value content with intelligent appending.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Story for simple intelligent test
|
||||
export const SimpleIntelligentTest: Story = {
|
||||
args: {
|
||||
isStreaming: true,
|
||||
streamingDelay: 300
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Basic test of intelligent replacement with multiple updates to the same content.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Story for realistic full test
|
||||
export const RealisticFullTest: Story = {
|
||||
args: {
|
||||
isStreaming: true,
|
||||
streamingDelay: 1200,
|
||||
initialContent: [
|
||||
{ id: 'intro', type: 'p', children: [{ text: 'Welcome to this comprehensive report.' }] }
|
||||
]
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Simulates realistic streaming scenarios with multiple paragraphs and updates.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Story for stream full test
|
||||
export const StreamFullTest: Story = {
|
||||
args: {
|
||||
isStreaming: true,
|
||||
streamingDelay: 600
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Demonstrates efficient length-based updates for complete document state management.'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Story showing the editor in a cleared state
|
||||
export const ClearedEditor: Story = {
|
||||
// New story that appends 2 items at a time
|
||||
export const BatchStreaming: Story = {
|
||||
args: {
|
||||
isStreaming: false,
|
||||
initialContent: []
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
story: 'Shows the editor in its initial cleared state ready for new content.'
|
||||
}
|
||||
}
|
||||
streamingDelay: 1500,
|
||||
showStreamingButton: true,
|
||||
batchSize: 2
|
||||
}
|
||||
};
|
||||
|
||||
// Story with lots of content to demonstrate scrolling
|
||||
export const LongContentScrolling: Story = {
|
||||
args: {
|
||||
isStreaming: false,
|
||||
streamingDelay: 300,
|
||||
showStreamingButton: true,
|
||||
batchSize: 1
|
||||
},
|
||||
render: (args) => {
|
||||
const [content, setContent] = useState<ReportElementsWithIds>([]);
|
||||
const [isStreamingActive, setIsStreamingActive] = useState(false);
|
||||
const [iterationCount, setIterationCount] = useState(0);
|
||||
|
||||
// Generate a large amount of content for scrolling demonstration
|
||||
const generateLongContent = (): ReportElementsWithIds => {
|
||||
const longContent: ReportElementsWithIds = [];
|
||||
|
||||
// Generate 50 paragraphs with varied content
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
const paragraphContent = [
|
||||
`This is paragraph ${i} of a very long document designed to test scrolling functionality. `,
|
||||
`The content is being streamed in real-time to demonstrate how the editor handles large amounts of text. `,
|
||||
`Each paragraph contains multiple sentences to create substantial content that will require scrolling. `,
|
||||
`As more content is added, you should see the editor automatically scroll to show the latest additions. `,
|
||||
`This helps ensure that users can always see the most recent content as it's being generated.`
|
||||
].join('');
|
||||
|
||||
longContent.push({
|
||||
id: `long-content-${i}`,
|
||||
type: 'p',
|
||||
children: [{ text: paragraphContent }]
|
||||
});
|
||||
}
|
||||
|
||||
return longContent;
|
||||
};
|
||||
|
||||
const longContent = generateLongContent();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreamingActive) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setIterationCount((prev) => {
|
||||
const newCount = prev + 1;
|
||||
|
||||
if (newCount <= longContent.length) {
|
||||
setContent((prevContent) => {
|
||||
// Add the next paragraph from our long content
|
||||
return [...prevContent, longContent[newCount - 1]];
|
||||
});
|
||||
} else {
|
||||
setIsStreamingActive(false);
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
return newCount;
|
||||
});
|
||||
}, args.streamingDelay);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isStreamingActive, args.streamingDelay, longContent]);
|
||||
|
||||
const handleValueChange = (newContent: ReportElementsWithIds) => {
|
||||
if (!isStreamingActive) {
|
||||
setContent(newContent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartStreaming = () => {
|
||||
setIsStreamingActive(true);
|
||||
setIterationCount(0);
|
||||
setContent([]);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setIsStreamingActive(false);
|
||||
setIterationCount(0);
|
||||
setContent([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'report-editor h-full w-full rounded border p-3'}>
|
||||
<div className="mb-4 flex gap-2">
|
||||
<Button onClick={handleStartStreaming} disabled={isStreamingActive} variant="default">
|
||||
Start Long Content Streaming
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="outlined">
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-2 text-sm text-gray-600">
|
||||
Paragraphs added: {iterationCount} / {longContent.length}
|
||||
</div>
|
||||
<div className="mb-2 text-sm text-gray-600">Total content: {content.length} paragraphs</div>
|
||||
<ReportEditor
|
||||
value={content}
|
||||
placeholder="Watch as 50 paragraphs are streamed in real-time..."
|
||||
readOnly={isStreamingActive}
|
||||
isStreaming={isStreamingActive}
|
||||
onValueChange={handleValueChange}
|
||||
className="min-h-[600px] w-full max-w-4xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// New story that appends text to existing nodes
|
||||
export const AppendToExisting: Story = {
|
||||
args: {
|
||||
isStreaming: false,
|
||||
streamingDelay: 500,
|
||||
showStreamingButton: true,
|
||||
batchSize: 1
|
||||
},
|
||||
render: (args) => {
|
||||
const [content, setContent] = useState<ReportElementsWithIds>([
|
||||
{
|
||||
id: 'id-1',
|
||||
type: 'p',
|
||||
children: [{ text: '' }]
|
||||
}
|
||||
]);
|
||||
const [isStreamingActive, setIsStreamingActive] = useState(false);
|
||||
const [iterationCount, setIterationCount] = useState(0);
|
||||
|
||||
// Text chunks to append
|
||||
// A paragraph about the author of Red Rising, split into small chunks (4-8 words each)
|
||||
const textChunks = [
|
||||
'Pierce Brown is the author',
|
||||
'of the Red Rising series.',
|
||||
'He grew up in Colorado,',
|
||||
'fascinated by science fiction worlds.',
|
||||
'After college, he worked many jobs,',
|
||||
'before turning to writing full-time.',
|
||||
'Red Rising debuted in 2014, ',
|
||||
'earning praise for its storytelling.',
|
||||
'Brown’s books explore rebellion and identity,',
|
||||
'with vivid prose and complex characters.',
|
||||
'He continues to expand the universe,',
|
||||
'captivating readers with each new book.'
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!isStreamingActive) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setIterationCount((prev) => {
|
||||
const newCount = prev + 1;
|
||||
|
||||
if (newCount <= textChunks.length) {
|
||||
setContent((prevContent) => {
|
||||
const newContent = [...prevContent];
|
||||
|
||||
if (newCount <= 6) {
|
||||
// First 6 iterations: append to the first paragraph
|
||||
const firstParagraph = newContent[0];
|
||||
if (
|
||||
firstParagraph &&
|
||||
firstParagraph.children[0] &&
|
||||
'text' in firstParagraph.children[0]
|
||||
) {
|
||||
const currentText = (firstParagraph.children[0] as { text: string }).text || '';
|
||||
firstParagraph.children[0] = {
|
||||
...firstParagraph.children[0],
|
||||
text: currentText + textChunks[newCount - 1] + ' '
|
||||
} as { text: string };
|
||||
}
|
||||
} else {
|
||||
// After 6 iterations: start appending to the second paragraph
|
||||
if (newContent.length === 1) {
|
||||
// Create second paragraph
|
||||
newContent.push({
|
||||
id: 'id-2',
|
||||
type: 'p',
|
||||
children: [{ text: textChunks[newCount - 1] }]
|
||||
});
|
||||
} else {
|
||||
// Append to second paragraph
|
||||
const secondParagraph = newContent[1];
|
||||
if (
|
||||
secondParagraph &&
|
||||
secondParagraph.children[0] &&
|
||||
'text' in secondParagraph.children[0]
|
||||
) {
|
||||
const currentText =
|
||||
(secondParagraph.children[0] as { text: string }).text || '';
|
||||
secondParagraph.children[0] = {
|
||||
...secondParagraph.children[0],
|
||||
text: currentText + textChunks[newCount - 1] + ' '
|
||||
} as { text: string };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newContent;
|
||||
});
|
||||
} else {
|
||||
setIsStreamingActive(false);
|
||||
clearInterval(interval);
|
||||
}
|
||||
|
||||
return newCount;
|
||||
});
|
||||
}, args.streamingDelay);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isStreamingActive, args.streamingDelay]);
|
||||
|
||||
const handleValueChange = (newContent: ReportElementsWithIds) => {
|
||||
if (!isStreamingActive) {
|
||||
setContent(newContent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartStreaming = () => {
|
||||
setIsStreamingActive(true);
|
||||
setIterationCount(0);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setIsStreamingActive(false);
|
||||
setIterationCount(0);
|
||||
setContent([
|
||||
{
|
||||
id: 'id-1',
|
||||
type: 'p',
|
||||
children: [{ text: '' }]
|
||||
}
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={'report-editor h-full w-full rounded border p-3'}>
|
||||
<div className="mb-4 flex gap-2">
|
||||
<Button onClick={handleStartStreaming} disabled={isStreamingActive} variant="default">
|
||||
Start Streaming
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="outlined">
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-2 text-sm text-gray-600">
|
||||
Iteration: {iterationCount} / {textChunks.length}
|
||||
</div>
|
||||
<ReportEditor
|
||||
value={content}
|
||||
placeholder="Start typing or watch streaming content..."
|
||||
readOnly={isStreamingActive}
|
||||
isStreaming={isStreamingActive}
|
||||
onValueChange={handleValueChange}
|
||||
className="min-h-[400px] w-full max-w-4xl"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
import { BaseAlignKit } from './plugins/align-base-kit';
|
||||
import { BaseBasicBlocksKit } from './plugins/basic-blocks-base-kit';
|
||||
import { BaseBasicMarksKit } from './plugins/basic-marks-base-kit';
|
||||
import { BusterStreamKit } from './plugins/buster-stream-kit';
|
||||
import { BaseCalloutKit } from './plugins/callout-base-kit';
|
||||
import { BaseCodeBlockKit } from './plugins/code-block-base-kit';
|
||||
import { BaseColumnKit } from './plugins/column-base-kit';
|
||||
|
@ -22,6 +21,7 @@ import { BaseSuggestionKit } from './plugins/suggestion-base-kit';
|
|||
import { BaseTableKit } from './plugins/table-base-kit';
|
||||
import { BaseTocKit } from './plugins/toc-base-kit';
|
||||
import { BaseToggleKit } from './plugins/toggle-base-kit';
|
||||
import { MetricKit } from './plugins/metric-kit';
|
||||
|
||||
//THIS EXCLUDES ALL "INTERACTIVE" PLUGINS LIKE DND, TOOLTIPS, ETC.
|
||||
export const BaseEditorKit = [
|
||||
|
@ -45,6 +45,5 @@ export const BaseEditorKit = [
|
|||
...BaseCommentKit,
|
||||
...BaseSuggestionKit,
|
||||
...MarkdownKit,
|
||||
...BusterStreamKit,
|
||||
...MetricBaseKit
|
||||
];
|
||||
|
|
|
@ -36,8 +36,9 @@ import { SuggestionKit } from './plugins/suggestion-kit';
|
|||
import { TableKit } from './plugins/table-kit';
|
||||
import { TocKit } from './plugins/toc-kit';
|
||||
import { ToggleKit } from './plugins/toggle-kit';
|
||||
import { BusterStreamKit } from './plugins/buster-stream-kit';
|
||||
import { CaptionKit } from './plugins/caption-kit';
|
||||
import { StreamContentKit } from './plugins/stream-content-kit';
|
||||
import { MetricKit } from './plugins/metric-kit';
|
||||
|
||||
export const EditorKit = [
|
||||
// Editing
|
||||
|
@ -82,7 +83,8 @@ export const EditorKit = [
|
|||
...SuggestionKit,
|
||||
|
||||
// Custom
|
||||
...BusterStreamKit,
|
||||
...StreamContentKit,
|
||||
...MetricKit,
|
||||
|
||||
// Parsers
|
||||
...DocxKit,
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import type { PlateTextProps } from 'platejs/react';
|
||||
import { PlateText, usePluginOption } from 'platejs/react';
|
||||
|
||||
import { StreamContentPlugin } from '../plugins/stream-content-plugin';
|
||||
|
||||
// Simple utility function for conditional class names
|
||||
function cn(...classes: (string | string[] | boolean | undefined | null)[]): string {
|
||||
return classes.filter(Boolean).flat().join(' ');
|
||||
}
|
||||
|
||||
export function StreamingText(props: PlateTextProps) {
|
||||
const isStreaming = usePluginOption(StreamContentPlugin, 'isStreaming');
|
||||
// const streamingNode = props.editor
|
||||
// .getApi(StreamContentPlugin)
|
||||
// .streamContent.getCurrentStreamingNode();
|
||||
|
||||
// const lastStreamingTextNode = props.editor
|
||||
// .getApi(StreamContentPlugin)
|
||||
// .streamContent.getLastStreamingTextNode();
|
||||
|
||||
// Check if this text node is part of the currently streaming content
|
||||
const isStreamingText = false;
|
||||
|
||||
// Check if this is the last text node in the streaming content (for the animated dot)
|
||||
const isLastStreamingText = isStreaming && false;
|
||||
|
||||
return (
|
||||
<PlateText
|
||||
className={cn(
|
||||
isStreaming &&
|
||||
isStreamingText && [
|
||||
'border-b-2 border-b-purple-100 bg-purple-50 text-purple-800',
|
||||
'transition-all duration-200 ease-in-out'
|
||||
],
|
||||
// Only show the animated dot on the last streaming text node
|
||||
isLastStreamingText && [
|
||||
'after:ml-1.5 after:inline-block after:h-3 after:w-3 after:animate-pulse after:rounded-full after:bg-purple-500 after:align-middle after:content-[""]'
|
||||
]
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { BannerPlugin } from './banner-plugin';
|
||||
import { CharacterCounterPlugin } from './character-counter-kit';
|
||||
import { MetricKit } from './metric-kit';
|
||||
|
||||
export const BusterStreamKit = [
|
||||
//BannerPlugin,
|
||||
CharacterCounterPlugin,
|
||||
...MetricKit
|
||||
];
|
|
@ -1,9 +0,0 @@
|
|||
import {
|
||||
createTSlatePlugin,
|
||||
type PluginConfig,
|
||||
type TElement,
|
||||
Node as PlateNode,
|
||||
TNode
|
||||
} from 'platejs';
|
||||
import { createPlatePlugin, type PlateElementProps, useEditorRef } from 'platejs/react';
|
||||
import { useMemo } from 'react';
|
|
@ -0,0 +1,3 @@
|
|||
import { StreamContentPlugin } from './stream-content-plugin';
|
||||
|
||||
export const StreamContentKit = [StreamContentPlugin];
|
|
@ -1,5 +1,5 @@
|
|||
import type { PlateEditor } from 'platejs/react';
|
||||
import type { Value, Element, Text } from 'platejs';
|
||||
import type { Element, Text } from 'platejs';
|
||||
import { createPlatePlugin } from 'platejs/react';
|
||||
import type { ReportElementWithId } from '@buster/server-shared/reports';
|
||||
|
||||
|
@ -34,103 +34,95 @@ export const StreamContentPlugin = createPlatePlugin({
|
|||
return ctx.getOption('isStreaming') as boolean;
|
||||
},
|
||||
|
||||
/**
|
||||
* Stream a single chunk with intelligent replacement logic
|
||||
*/
|
||||
streamChunk: (chunk: ReportElementWithId, options?: { moveCursor?: boolean }) => {
|
||||
const editor = ctx.editor as PlateEditor;
|
||||
const previousChunkId = ctx.getOption('previousChunkId') as string | null;
|
||||
const moveCursor = options?.moveCursor ?? false;
|
||||
|
||||
// Prevent undo/redo for streaming operations
|
||||
editor.tf.withoutSaving(() => {
|
||||
if (previousChunkId === chunk.id) {
|
||||
// Replace the last node with new content
|
||||
replaceLastNode(editor, chunk.children);
|
||||
} else {
|
||||
// Append new chunk to the end
|
||||
insertContentAtEnd(editor, chunk, moveCursor);
|
||||
}
|
||||
});
|
||||
|
||||
// Update the previous chunk ID
|
||||
ctx.setOption('previousChunkId', chunk.id);
|
||||
},
|
||||
|
||||
/**
|
||||
* Stream complete array of chunks with efficient length-based updates
|
||||
*/
|
||||
streamFull: (chunks: ReportElementWithId[], options?: { debug?: boolean }) => {
|
||||
streamFull: (chunks: ReportElementWithId[]) => {
|
||||
const editor = ctx.editor as PlateEditor;
|
||||
const debug = options?.debug ?? false;
|
||||
|
||||
if (!chunks || chunks.length === 0) {
|
||||
if (debug) console.warn('streamFull: No chunks provided');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent undo/redo and defer normalization for performance
|
||||
editor.tf.withoutSaving(() => {
|
||||
editor.tf.withoutNormalizing(() => {
|
||||
// Get all current nodes in the editor
|
||||
const currentNodes = editor.children;
|
||||
const currentLength = currentNodes.length;
|
||||
const incomingLength = chunks.length;
|
||||
editor.tf.withScrolling(() => {
|
||||
editor.tf.withoutSaving(() => {
|
||||
editor.tf.withoutNormalizing(() => {
|
||||
// Get all current nodes in the editor
|
||||
const currentNodes = editor.children;
|
||||
const currentLength = currentNodes.length;
|
||||
const incomingLength = chunks.length;
|
||||
|
||||
// Batch operations for better performance
|
||||
const operations: Array<() => void> = [];
|
||||
// Batch operations for better performance
|
||||
const operations: Array<() => void> = [];
|
||||
|
||||
// First, identify all operations we need to perform
|
||||
for (let i = 0; i < incomingLength; i++) {
|
||||
const chunk = chunks[i];
|
||||
// First, identify all operations we need to perform
|
||||
for (let i = 0; i < incomingLength; i++) {
|
||||
const chunk = chunks[i];
|
||||
|
||||
if (i < currentLength) {
|
||||
const existingNode = currentNodes[i];
|
||||
if (i < currentLength) {
|
||||
const existingNode = currentNodes[i];
|
||||
|
||||
// Quick ID check first (fast)
|
||||
if (existingNode.id !== chunk.id) {
|
||||
// Different ID, needs replacement
|
||||
operations.push(() => {
|
||||
// Remove the old node and insert the new one
|
||||
editor.tf.removeNodes({ at: [i] });
|
||||
editor.tf.insertNodes(chunk, { at: [i], select: false });
|
||||
});
|
||||
} else {
|
||||
// Same ID, check if content changed (only if necessary)
|
||||
const existingText = extractTextFromNode(existingNode);
|
||||
const incomingText = extractTextFromValue(chunk.children);
|
||||
|
||||
if (existingText !== incomingText) {
|
||||
// Quick ID check first (fast)
|
||||
if (existingNode.id !== chunk.id) {
|
||||
// Different ID, needs replacement
|
||||
operations.push(() => {
|
||||
// Remove the old node and insert the new one
|
||||
editor.tf.removeNodes({ at: [i] });
|
||||
editor.tf.insertNodes(chunk, { at: [i], select: false });
|
||||
// Insert the chunk as a properly formatted node
|
||||
const nodeToInsert = {
|
||||
...chunk,
|
||||
id: chunk.id // Ensure ID is preserved
|
||||
};
|
||||
editor.tf.insertNodes(nodeToInsert, { at: [i], select: false });
|
||||
});
|
||||
} else {
|
||||
// Same ID, check if content changed (only if necessary)
|
||||
const existingText = extractTextFromNode(existingNode);
|
||||
const incomingText = extractTextFromChildren(chunk.children);
|
||||
|
||||
if (existingText !== incomingText) {
|
||||
operations.push(() => {
|
||||
// Remove the old node and insert the new one
|
||||
editor.tf.removeNodes({ at: [i] });
|
||||
// Insert the chunk as a properly formatted node
|
||||
const nodeToInsert = {
|
||||
...chunk,
|
||||
id: chunk.id // Ensure ID is preserved
|
||||
};
|
||||
editor.tf.insertNodes(nodeToInsert, { at: [i], select: false });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// New node to append
|
||||
operations.push(() => {
|
||||
editor.tf.insertNodes(chunks, {
|
||||
at: [editor.children.length],
|
||||
select: false
|
||||
} else {
|
||||
// New node to append
|
||||
operations.push(() => {
|
||||
// Insert the chunk as a properly formatted node
|
||||
const nodeToInsert = {
|
||||
...chunk,
|
||||
id: chunk.id // Ensure ID is preserved
|
||||
};
|
||||
editor.tf.insertNodes(nodeToInsert, {
|
||||
at: [editor.children.length],
|
||||
select: false
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle removals if incoming is shorter
|
||||
if (currentLength > incomingLength) {
|
||||
operations.push(() => {
|
||||
// Remove extra nodes one by one (Slate doesn't support batch removal with array of paths)
|
||||
for (let i = currentLength - 1; i >= incomingLength; i--) {
|
||||
editor.tf.removeNodes({ at: [i] });
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Handle removals if incoming is shorter
|
||||
if (currentLength > incomingLength) {
|
||||
operations.push(() => {
|
||||
// Remove extra nodes one by one (Slate doesn't support batch removal with array of paths)
|
||||
for (let i = currentLength - 1; i >= incomingLength; i--) {
|
||||
editor.tf.removeNodes({ at: [i] });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Execute all operations
|
||||
operations.forEach((op) => op());
|
||||
// Execute all operations
|
||||
operations.forEach((op) => op());
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
|
@ -162,70 +154,6 @@ const findNodeWithId = (editor: PlateEditor, id: string): number[] | null => {
|
|||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert content at the end of the document with ID
|
||||
*/
|
||||
const insertContentAtEnd = (
|
||||
editor: PlateEditor,
|
||||
content: ReportElementWithId,
|
||||
moveCursor = true
|
||||
) => {
|
||||
const children = content.children;
|
||||
if (!content || children.length === 0) return;
|
||||
|
||||
// Add ID to the content nodes
|
||||
|
||||
// Get the last path in the document
|
||||
const lastPath = editor.api.end([]);
|
||||
|
||||
// Insert content at the end
|
||||
editor.tf.insertNodes(content, {
|
||||
at: lastPath,
|
||||
select: false
|
||||
});
|
||||
|
||||
// Only move cursor if requested (for performance during streaming)
|
||||
if (moveCursor) {
|
||||
const newEndPath = editor.api.end([]);
|
||||
editor.tf.select(newEndPath);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Replace the last node in the document with new content
|
||||
*/
|
||||
const replaceLastNode = (editor: PlateEditor, newContent: ReportElementWithId['children']) => {
|
||||
if (!newContent || newContent.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the last path in the document
|
||||
const lastPoint = editor.api.end([]);
|
||||
|
||||
// Extract the path from the point
|
||||
const lastPath = lastPoint?.path;
|
||||
|
||||
// Remove the last node
|
||||
if (lastPath && Array.isArray(lastPath) && lastPath.length > 0) {
|
||||
const pathToRemove = [...lastPath];
|
||||
pathToRemove[pathToRemove.length - 1] -= 1; // Go back one node
|
||||
|
||||
if (pathToRemove[pathToRemove.length - 1] >= 0) {
|
||||
editor.tf.removeNodes({ at: pathToRemove });
|
||||
}
|
||||
}
|
||||
|
||||
// Insert the new content at the end (not at the last path, but at the end)
|
||||
const endPoint = editor.api.end([]);
|
||||
const endPath = endPoint?.path;
|
||||
if (endPath && Array.isArray(endPath)) {
|
||||
editor.tf.insertNodes(newContent as Value, {
|
||||
at: endPath,
|
||||
select: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract text content from a node (optimized for performance)
|
||||
*/
|
||||
|
@ -245,12 +173,12 @@ const extractTextFromNode = (node: Element | Text): string => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Extract text content from a Value (optimized for performance)
|
||||
* Extract text content from children array (optimized for performance)
|
||||
*/
|
||||
const extractTextFromValue = (value: ReportElementWithId['children']): string => {
|
||||
const extractTextFromChildren = (children: ReportElementWithId['children']): string => {
|
||||
// Use string concatenation instead of map/join for better performance
|
||||
let result = '';
|
||||
for (const node of value) {
|
||||
for (const node of children) {
|
||||
result += extractTextFromNode(node as Element | Text);
|
||||
}
|
||||
return result;
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { StreamContentPlugin as BaseStreamContentPlugin } from './stream-content-plugin';
|
||||
import { StreamingText } from '../elements/StreamingText';
|
||||
|
||||
export const StreamContentPlugin = BaseStreamContentPlugin.withComponent(StreamingText);
|
|
@ -6,7 +6,6 @@ import { useEffect, useMemo } from 'react';
|
|||
import { EditorKit } from './editor-kit';
|
||||
import { FIXED_TOOLBAR_KIT_KEY } from './plugins/fixed-toolbar-kit';
|
||||
import { GlobalVariablePlugin } from './plugins/global-variable-kit';
|
||||
import { useMount } from '@/hooks';
|
||||
import { StreamContentPlugin } from './plugins/stream-content-plugin';
|
||||
import type { ReportElementsWithIds } from '@buster/server-shared/reports';
|
||||
|
||||
|
@ -41,6 +40,7 @@ export const useReportEditor = ({
|
|||
if (editor && isStreaming) {
|
||||
const streamContentPlugin = editor.getPlugin(StreamContentPlugin);
|
||||
streamContentPlugin.api.streamContent.start();
|
||||
console.log('streaming', value);
|
||||
streamContentPlugin.api.streamContent.streamFull(value);
|
||||
} else {
|
||||
editor?.getPlugin(StreamContentPlugin)?.api.streamContent.stop();
|
||||
|
@ -49,7 +49,7 @@ export const useReportEditor = ({
|
|||
|
||||
const editor = usePlateEditor({
|
||||
plugins,
|
||||
value,
|
||||
value: [],
|
||||
readOnly: disabled || isStreaming
|
||||
});
|
||||
|
||||
|
|
Loading…
Reference in New Issue