From f68d0835b3539ca2de431cf7ab542e27e23e6156 Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Tue, 19 Aug 2025 18:02:53 -0600 Subject: [PATCH] Make a streaming plugin --- .../src/app/test/report-playground/2/page.tsx | 11 + .../ui/report/StreamingContentExample.tsx | 456 ++++++++++++++++++ .../report/plugins/stream-content-plugin.ts | 266 ++++++++++ .../components/ui/report/useReportEditor.tsx | 30 ++ 4 files changed, 763 insertions(+) create mode 100644 apps/web/src/app/test/report-playground/2/page.tsx create mode 100644 apps/web/src/components/ui/report/StreamingContentExample.tsx create mode 100644 apps/web/src/components/ui/report/plugins/stream-content-plugin.ts diff --git a/apps/web/src/app/test/report-playground/2/page.tsx b/apps/web/src/app/test/report-playground/2/page.tsx new file mode 100644 index 000000000..9965f7375 --- /dev/null +++ b/apps/web/src/app/test/report-playground/2/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { StreamingContentExample } from '@/components/ui/report/StreamingContentExample'; + +export default function StreamingContentPage() { + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/ui/report/StreamingContentExample.tsx b/apps/web/src/components/ui/report/StreamingContentExample.tsx new file mode 100644 index 000000000..9f445ff59 --- /dev/null +++ b/apps/web/src/components/ui/report/StreamingContentExample.tsx @@ -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 ( +
+

Enhanced Streaming Content Plugin Example

+ +
+

Controls

+
+ + + + + + + + + +
+ +
+ Status: {currentStreamingStatus ? 'Streaming...' : 'Ready'} +
+
+ +
+ + + +
+ +
+

How it works:

+
    +
  • + Stream Text Content: Simple text streaming (backward compatibility) +
  • +
  • + Stream Markdown: Markdown to Value conversion and streaming +
  • +
  • + Stream Intelligent Content: Content with IDs that can append to + existing nodes +
  • +
  • + Stream Custom Value: Pre-formatted Value content with intelligent + appending +
  • +
  • + Stream Full: Efficient length-based updates for complete document state +
  • +
+ +

Enhanced Features:

+
    +
  • ✅ Content ID tracking for intelligent appending
  • +
  • ✅ Append to existing nodes with same ID
  • +
  • ✅ Create new nodes when ID doesn't exist
  • +
  • ✅ Support for paragraphs, headings, and other block types
  • +
  • ✅ Real-time streaming with intelligent content management
  • +
  • ✅ TypeScript support with proper typing
  • +
  • ✅ Backward compatibility with simple streaming
  • +
  • ✅ Utility functions for creating different chunk types
  • +
  • + ✅ NEW: Efficient length-based updates with streamFull +
  • +
  • + ✅ NEW: Complete document state management +
  • +
  • + ✅ NEW: Smart truncation and expansion handling +
  • +
+ +

Intelligent Streaming Logic:

+
    +
  • + Content ID Matching: When streaming content with an ID, the plugin + checks if a node with that ID already exists +
  • +
  • + Appending to Existing: If the node exists and{' '} + appendToExisting is true, content is appended to the existing node +
  • +
  • + Creating New Nodes: If no matching ID is found, a new node is created +
  • +
  • + Text Appending: For text nodes (paragraphs, headings), text content is + intelligently appended +
  • +
  • + Block Appending: For other node types, new blocks are inserted after + the existing node +
  • +
+
+
+ ); +} + +/** 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 }] + } + ]; +}; diff --git a/apps/web/src/components/ui/report/plugins/stream-content-plugin.ts b/apps/web/src/components/ui/report/plugins/stream-content-plugin.ts new file mode 100644 index 000000000..55f7d9677 --- /dev/null +++ b/apps/web/src/components/ui/report/plugins/stream-content-plugin.ts @@ -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'); +}; diff --git a/apps/web/src/components/ui/report/useReportEditor.tsx b/apps/web/src/components/ui/report/useReportEditor.tsx index a2d80923d..f3161515b 100644 --- a/apps/web/src/components/ui/report/useReportEditor.tsx +++ b/apps/web/src/components/ui/report/useReportEditor.tsx @@ -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,