Make a streaming plugin

This commit is contained in:
Nate Kelley 2025-08-19 18:02:53 -06:00
parent 6424ea00c6
commit f68d0835b3
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
4 changed files with 763 additions and 0 deletions

View File

@ -0,0 +1,11 @@
'use client';
import { StreamingContentExample } from '@/components/ui/report/StreamingContentExample';
export default function StreamingContentPage() {
return (
<div className="bg-background h-full w-full rounded border p-5">
<StreamingContentExample />
</div>
);
}

View File

@ -0,0 +1,456 @@
import React, { useCallback, useState } from 'react';
import type { Value } from 'platejs';
import { Plate, PlateContent } from 'platejs/react';
import { createPlateEditor } from 'platejs/react';
// Minimal required plugins for a working editor
import { BasicBlocksPlugin } from '@platejs/basic-nodes/react';
import { BoldPlugin, ItalicPlugin } from '@platejs/basic-nodes/react';
import { StreamContentPlugin, type StreamChunk } from './plugins/stream-content-plugin';
// Create an editor with minimal but functional plugins
const editor = createPlateEditor({
plugins: [
// Basic blocks (paragraphs, headings)
BasicBlocksPlugin,
// Text formatting
BoldPlugin,
ItalicPlugin,
// Our streaming plugin
StreamContentPlugin
],
// Initial editor value
value: [
{
type: 'p',
children: [{ text: 'Start typing or use the streaming buttons below...' }]
}
]
});
export function StreamingContentExample() {
const [isStreaming, setIsStreaming] = useState(false);
// Get the streaming plugin context
const getStreamingPlugin = () => editor.getPlugin(StreamContentPlugin);
// Start streaming content
const handleStartStreaming = useCallback(() => {
getStreamingPlugin().api.streamContent.start();
setIsStreaming(true);
}, []);
// Stop streaming content
const handleStopStreaming = useCallback(() => {
getStreamingPlugin().api.streamContent.stop();
setIsStreaming(false);
}, []);
// Stream intelligent content with ID-based replacement
const handleStreamIntelligentContent = useCallback(async () => {
if (!isStreaming) {
handleStartStreaming();
}
// Simulate streaming content that gets updated/replaced
const intelligentChunks: StreamChunk[] = [
// Create initial heading
createHeadingChunk('dynamic-heading', 'Loading...', 1),
// Replace the heading with final content (same ID)
createHeadingChunk('dynamic-heading', 'Streaming Content Example', 1),
// Create initial paragraph
createParagraphChunk('dynamic-paragraph', 'Initial content...'),
// Replace paragraph with more content (same ID)
createParagraphChunk('dynamic-paragraph', 'This paragraph was updated with new content.'),
// Replace again with even more content (same ID)
createParagraphChunk(
'dynamic-paragraph',
'This paragraph was updated multiple times with different content. The ID-based replacement allows for dynamic updates.'
),
// Create a new paragraph (different ID)
createParagraphChunk('final-paragraph', 'This is a new paragraph that was added.')
];
for (const chunk of intelligentChunks) {
// Stream the intelligent chunk
getStreamingPlugin().api.streamContent.streamChunk(chunk);
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 800));
}
handleStopStreaming();
}, [isStreaming, handleStartStreaming, handleStopStreaming]);
// Simple test for intelligent replacement
const handleSimpleIntelligentTest = useCallback(async () => {
if (isStreaming) return;
handleStartStreaming();
// Test 1: First chunk
const chunk1 = createParagraphChunk('test-paragraph', 'Hello world');
getStreamingPlugin().api.streamContent.streamChunk(chunk1);
// Test 2: Update same chunk
setTimeout(() => {
const chunk2 = createParagraphChunk('test-paragraph', 'Hello world! This is updated.');
getStreamingPlugin().api.streamContent.streamChunk(chunk2);
}, 1000);
// Test 3: Update same chunk again
setTimeout(() => {
const chunk3 = createParagraphChunk(
'test-paragraph',
'Hello world! This is the final version.'
);
getStreamingPlugin().api.streamContent.streamChunk(chunk3);
}, 2000);
// Test 4: Add new chunk
setTimeout(() => {
const chunk4 = createParagraphChunk('new-paragraph', 'This is a new paragraph.');
getStreamingPlugin().api.streamContent.streamChunk(chunk4);
}, 3000);
setTimeout(() => {
handleStopStreaming();
}, 4000);
}, [isStreaming, handleStartStreaming, handleStopStreaming]);
const handleStreamFullTest = useCallback(async () => {
if (isStreaming) return;
handleStartStreaming();
const arrayOfContent = [
createParagraphChunk('para-1', 'First paragraph'),
createParagraphChunk('para-1', 'First paragraph this is a test'),
createParagraphChunk('para-2', 'Second paragraph'),
createParagraphChunk('para-3', 'Third paragraph'),
createParagraphChunk('para-4', 'Fourth paragraph'),
createParagraphChunk('para-5', 'Fifth paragraph'),
createParagraphChunk('para-6', 'Sixth paragraph'),
createParagraphChunk('para-7', 'Seventh paragraph')
];
for await (const chunk of arrayOfContent) {
getStreamingPlugin().api.streamContent.streamChunk(chunk);
await new Promise((resolve) => setTimeout(resolve, 1000));
}
handleStopStreaming();
}, [isStreaming, handleStartStreaming, handleStopStreaming]);
// Stream custom Value content with IDs
const handleStreamCustomValue = useCallback(async () => {
if (!isStreaming) {
handleStartStreaming();
}
// Simulate custom Value chunks from server with IDs
const customChunks: StreamChunk[] = [
createStreamChunk(
'custom-heading',
[{ type: 'h1', children: [{ text: 'Custom Value Streaming' }] }],
'heading'
),
createStreamChunk(
'custom-paragraph-1',
[
{
type: 'p',
children: [
{ text: 'This is streaming content in ' },
{ text: 'Value', bold: true },
{ text: ' format directly from the server.' }
]
}
],
'paragraph'
),
createStreamChunk(
'custom-paragraph-2',
[
{
type: 'p',
children: [{ text: 'This paragraph will be replaced: ' }]
}
],
'paragraph'
),
createStreamChunk(
'custom-paragraph-2',
[
{
type: 'p',
children: [{ text: 'This paragraph was replaced with new content. ' }]
}
],
'paragraph'
),
createStreamChunk(
'custom-paragraph-2',
[
{
type: 'p',
children: [{ text: 'This paragraph was replaced again with final content!' }]
}
],
'paragraph'
)
];
for (const chunk of customChunks) {
// Stream the custom Value chunk
getStreamingPlugin().api.streamContent.streamChunk(chunk);
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 500));
}
handleStopStreaming();
}, [isStreaming, handleStartStreaming, handleStopStreaming]);
// Clear the editor
const handleClear = useCallback(() => {
editor.tf.selectAll();
editor.tf.deleteBackward();
editor.tf.insertNodes(
[
{
type: 'p',
children: [{ text: 'Editor cleared. Start typing or use streaming...' }]
}
],
{ at: [0] }
);
// Add a default paragraph back
// editor.tf.insertNodes(
// [
// {
// type: 'p',
// children: [{ text: 'Editor cleared. Start typing or use streaming...' }]
// }
// ],
// { at: [0] }
// );
}, []);
// Get current streaming status
const currentStreamingStatus = getStreamingPlugin().api.streamContent.isStreaming();
return (
<div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
<h1>Enhanced Streaming Content Plugin Example</h1>
<div style={{ marginBottom: '20px' }}>
<h3>Controls</h3>
<div
style={{
display: 'flex',
gap: '10px',
flexWrap: 'wrap',
marginBottom: '10px'
}}>
<button
onClick={handleStreamIntelligentContent}
disabled={isStreaming}
style={{
padding: '8px 16px',
backgroundColor: '#ffc107',
color: 'black',
border: 'none',
borderRadius: '4px',
cursor: isStreaming ? 'not-allowed' : 'pointer'
}}>
Stream Intelligent Content
</button>
<button
onClick={handleStreamCustomValue}
disabled={isStreaming}
style={{
padding: '8px 16px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isStreaming ? 'not-allowed' : 'pointer'
}}>
Stream Custom Value
</button>
<button
onClick={handleSimpleIntelligentTest}
disabled={isStreaming}
style={{
padding: '8px 16px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isStreaming ? 'not-allowed' : 'pointer'
}}>
Simple Intelligent Test
</button>
<button
onClick={handleStreamFullTest}
disabled={isStreaming}
style={{
padding: '8px 16px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: isStreaming ? 'not-allowed' : 'pointer'
}}>
Stream Full Test
</button>
<button
onClick={handleClear}
style={{
padding: '8px 16px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}>
Clear Editor
</button>
</div>
<div style={{ fontSize: '14px', color: '#666' }}>
<strong>Status:</strong> {currentStreamingStatus ? 'Streaming...' : 'Ready'}
</div>
</div>
<div
style={{
border: '1px solid #ccc',
borderRadius: '4px',
minHeight: '400px',
backgroundColor: '#fff'
}}>
<Plate editor={editor}>
<PlateContent
style={{
padding: '16px',
minHeight: '400px',
outline: 'none'
}}
/>
</Plate>
</div>
<div style={{ marginTop: '20px', fontSize: '14px', color: '#666' }}>
<h3>How it works:</h3>
<ul>
<li>
<strong>Stream Text Content:</strong> Simple text streaming (backward compatibility)
</li>
<li>
<strong>Stream Markdown:</strong> Markdown to Value conversion and streaming
</li>
<li>
<strong>Stream Intelligent Content:</strong> Content with IDs that can append to
existing nodes
</li>
<li>
<strong>Stream Custom Value:</strong> Pre-formatted Value content with intelligent
appending
</li>
<li>
<strong>Stream Full:</strong> Efficient length-based updates for complete document state
</li>
</ul>
<h3>Enhanced Features:</h3>
<ul>
<li> Content ID tracking for intelligent appending</li>
<li> Append to existing nodes with same ID</li>
<li> Create new nodes when ID doesn't exist</li>
<li> Support for paragraphs, headings, and other block types</li>
<li> Real-time streaming with intelligent content management</li>
<li> TypeScript support with proper typing</li>
<li> Backward compatibility with simple streaming</li>
<li> Utility functions for creating different chunk types</li>
<li>
<strong>NEW:</strong> Efficient length-based updates with <code>streamFull</code>
</li>
<li>
<strong>NEW:</strong> Complete document state management
</li>
<li>
<strong>NEW:</strong> Smart truncation and expansion handling
</li>
</ul>
<h3>Intelligent Streaming Logic:</h3>
<ul>
<li>
<strong>Content ID Matching:</strong> When streaming content with an ID, the plugin
checks if a node with that ID already exists
</li>
<li>
<strong>Appending to Existing:</strong> If the node exists and{' '}
<code>appendToExisting</code> is true, content is appended to the existing node
</li>
<li>
<strong>Creating New Nodes:</strong> If no matching ID is found, a new node is created
</li>
<li>
<strong>Text Appending:</strong> For text nodes (paragraphs, headings), text content is
intelligently appended
</li>
<li>
<strong>Block Appending:</strong> For other node types, new blocks are inserted after
the existing node
</li>
</ul>
</div>
</div>
);
}
/** Utility function to create a stream chunk with ID */
export const createStreamChunk = (
id: string,
content: Value,
type: string = 'paragraph'
): StreamChunk => {
return {
id,
content,
type
};
};
/** Utility function to create a paragraph chunk */
const createParagraphChunk = (id: string, text: string): StreamChunk => {
return createStreamChunk(id, [{ type: 'p', children: [{ text }] }], 'paragraph');
};
/** Utility function to create a heading chunk */
const createHeadingChunk = (id: string, text: string, level: 1 | 2 | 3 = 1): StreamChunk => {
return createStreamChunk(id, [{ type: `h${level}`, children: [{ text }] }], 'heading');
};
/** Utility function to convert string content to Value format */
const stringToValue = (text: string): Value => {
return [
{
type: 'p',
children: [{ text }]
}
];
};

View File

@ -0,0 +1,266 @@
import type { PlateEditor } from 'platejs/react';
import type { Value, Element, Text } from 'platejs';
import { type PluginConfig, KEYS } from 'platejs';
import { createPlatePlugin } from 'platejs/react';
export interface StreamChunkOptions {
// No options needed - always uses simple logic
}
export interface StreamChunk {
/** Unique identifier for the content chunk */
id: string;
/** The content to stream */
content: Value;
/** Type of content (e.g., 'paragraph', 'heading', 'code') */
type?: string;
}
export const StreamContentPlugin = createPlatePlugin({
key: 'streamContent',
options: {
isStreaming: false,
previousChunkId: null as string | null
}
}).extendEditorApi((ctx) => ({
streamContent: {
/**
* Start streaming mode
*/
start: () => {
ctx.setOption('isStreaming', true);
ctx.setOption('previousChunkId', null);
},
/**
* Stop streaming mode
*/
stop: () => {
ctx.setOption('isStreaming', false);
ctx.setOption('previousChunkId', null);
},
/**
* Check if currently streaming
*/
isStreaming: () => {
return ctx.getOption('isStreaming') as boolean;
},
/**
* Stream a single chunk with intelligent replacement logic
*/
streamChunk: (chunk: StreamChunk) => {
const editor = ctx.editor as PlateEditor;
const previousChunkId = ctx.getOption('previousChunkId') as string | null;
if (previousChunkId === chunk.id) {
// Replace the last node with new content
replaceLastNode(editor, chunk.content);
} else {
// Append new chunk to the end
insertContentAtEndWithId(editor, chunk.content, chunk.id);
}
// Update the previous chunk ID
ctx.setOption('previousChunkId', chunk.id);
},
/**
* Stream complete array of chunks with efficient length-based updates
*/
streamFull: (chunks: StreamChunk[]) => {
const editor = ctx.editor as PlateEditor;
if (!chunks || chunks.length === 0) {
console.warn('streamFull: No chunks provided');
return;
}
const currentLength = editor.children.length;
const incomingLength = chunks.length;
console.log('=== STREAM FULL DEBUG ===');
console.log('Current editor length:', currentLength);
console.log('Incoming chunks length:', incomingLength);
console.log('Incoming chunks:', chunks);
if (currentLength === incomingLength) {
// Same length: update the last node only
console.log('🔄 Same length - updating last node');
const lastChunk = chunks[chunks.length - 1];
replaceLastNode(editor, lastChunk.content);
} else if (incomingLength > currentLength) {
// Incoming longer: update last node + append new nodes
console.log(' Incoming longer - updating last + appending new');
// First, update the last existing node
if (currentLength > 0) {
const lastChunk = chunks[currentLength - 1];
replaceLastNode(editor, lastChunk.content);
}
// Then append any additional chunks
const additionalChunks = chunks.slice(currentLength);
for (const chunk of additionalChunks) {
insertContentAtEndWithId(editor, chunk.content, chunk.id);
}
} else {
// Incoming shorter: truncate and update
console.log(' Incoming shorter - truncating and updating');
// Remove excess nodes
const nodesToRemove = currentLength - incomingLength;
for (let i = 0; i < nodesToRemove; i++) {
const lastPoint = editor.api.end([]);
const lastPath = lastPoint?.path;
if (lastPath && Array.isArray(lastPath) && lastPath.length > 0) {
const pathToRemove = [...lastPath];
pathToRemove[pathToRemove.length - 1] -= 1;
if (pathToRemove[pathToRemove.length - 1] >= 0) {
editor.tf.removeNodes({ at: pathToRemove });
}
}
}
// Update the last remaining node
if (incomingLength > 0) {
const lastChunk = chunks[incomingLength - 1];
replaceLastNode(editor, lastChunk.content);
}
}
console.log('Editor children after streamFull:', JSON.stringify(editor.children, null, 2));
console.log('=== END STREAM FULL DEBUG ===');
},
/**
* Find a node with a specific ID
*/
findNodeWithId: (id: string) => {
const editor = ctx.editor as PlateEditor;
return findNodeWithId(editor, id);
}
}
}));
/**
* Find a node with a specific ID in the editor (only checks the last node)
*/
const findNodeWithId = (editor: PlateEditor, id: string): number[] | null => {
// Only check the last node since we always append to the end
const lastIndex = editor.children.length - 1;
if (lastIndex >= 0) {
const lastNode = editor.children[lastIndex];
if (lastNode.id === id) {
return [lastIndex];
}
}
return null;
};
/**
* Insert content at the end of the document with ID
*/
const insertContentAtEndWithId = (editor: PlateEditor, content: Value, id: string) => {
if (!content || content.length === 0) return;
// Add ID to the content nodes
const contentWithId = addIdToContent(content, id);
// Get the last path in the document
const lastPath = editor.api.end([]);
// Insert content at the end
editor.tf.insertNodes(contentWithId, {
at: lastPath,
select: false
});
// Move cursor to the end of the inserted content
const newEndPath = editor.api.end([]);
editor.tf.select(newEndPath);
};
/**
* Add ID to content nodes
*/
const addIdToContent = (content: Value, id: string): Value => {
const result = content.map((node) => ({
...node,
id: id
}));
return result;
};
/**
* Replace the last node in the document with new content
*/
const replaceLastNode = (editor: PlateEditor, newContent: Value) => {
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
if (lastPath && Array.isArray(lastPath)) {
editor.tf.insertNodes(newContent, {
at: lastPath,
select: false
});
}
};
/**
* Generate a unique chunk ID
*/
const generateChunkId = (): string => {
return `chunk_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};
/**
* Utility function to create a stream chunk with ID
*/
export const createStreamChunk = (
id: string,
content: Value,
type: string = 'paragraph'
): StreamChunk => {
return {
id,
content,
type
};
};
/**
* Utility function to create a paragraph chunk
*/
export const createParagraphChunk = (id: string, text: string): StreamChunk => {
return createStreamChunk(id, [{ type: 'p', children: [{ text }] }], 'paragraph');
};
/**
* Utility function to create a heading chunk
*/
export const createHeadingChunk = (id: string, text: string, level: 1 | 2 | 3 = 1): StreamChunk => {
return createStreamChunk(id, [{ type: `h${level}`, children: [{ text }] }], 'heading');
};

View File

@ -6,6 +6,7 @@ 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';
export const useReportEditor = ({
value,
@ -41,6 +42,35 @@ export const useReportEditor = ({
}
}, [value]);
useMount(() => {
setTimeout(() => {
const lastPath = editor.api.end([]);
console.log(lastPath, editor);
editor.tf.insertNodes([{ type: 'p', children: [{ text: 'test' }] }], { at: lastPath });
// Wait 500ms and then append additional text to the same node
setTimeout(() => {
const lastPath = editor.api.end([]);
// Find the last block (paragraph) at that location
const lastBlock = editor.api.block({ at: lastPath });
if (lastBlock && lastBlock[0].type === 'p') {
const [blockNode, blockPath] = lastBlock;
// Get the end point of the block
const endPoint = editor.api.end(blockPath);
// Insert "WOW!" at the end
editor.tf.insertText('WOW!', {
at: endPoint
});
}
}, 500);
}, 3000);
});
const editor = usePlateEditor({
plugins,
value,