update streaming to use

This commit is contained in:
Nate Kelley 2025-08-19 22:33:53 -06:00
parent 845e9a73fa
commit fa21c4246e
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
10 changed files with 463 additions and 275 deletions

View File

@ -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.',
'Browns 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>
);
}
};

View File

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

View File

@ -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,

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
import { StreamContentPlugin } from './stream-content-plugin';
export const StreamContentKit = [StreamContentPlugin];

View File

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

View File

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

View File

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