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,