blokhaus

AIPreviewNode

Ephemeral node for AI-generated content streaming.

AIPreviewNode is a DecoratorNode that renders an ephemeral preview of AI-generated content inside the editor. Its React component manages the streaming response entirely in local React state -- it never calls editor.update() during streaming. This design guarantees zero undo-history pollution while the AI is generating.

Import

import {
  AIPreviewNode,
  $createAIPreviewNode,
  $isAIPreviewNode,
} from "@blokhaus/core";
import type { AIPreviewPayload } from "@blokhaus/core";

AIPreviewPayload

interface AIPreviewPayload {
  prompt: string;
  context: string;
  key?: NodeKey;
}
FieldTypeRequiredDescription
promptstringYesThe user's prompt text.
contextstringYesSurrounding editor content serialized as Markdown.
keyNodeKeyNoExplicit Lexical node key. Omit to let Lexical auto-generate one.

Functions

$createAIPreviewNode(payload: AIPreviewPayload): AIPreviewNode

Creates a new AIPreviewNode. Must be called inside editor.update(). Typically invoked by the AIPlugin when the user triggers /ai from the slash menu.

editor.update(() => {
  const node = $createAIPreviewNode({
    prompt: "Summarize the above in three bullet points",
    context: markdownContext,
  });
  // Insert below the current cursor position
  const selection = $getSelection();
  if ($isRangeSelection(selection)) {
    const anchor = selection.anchor.getNode();
    const topLevel = anchor.getTopLevelElement();
    if (topLevel) {
      topLevel.insertAfter(node);
    }
  }
});

$isAIPreviewNode(node: LexicalNode | null | undefined): node is AIPreviewNode

Type guard that returns true if the given node is an AIPreviewNode.

Streaming lifecycle

The AIPreviewNode's React component follows this lifecycle:

Stream starts
  -> AIPreviewNode inserted into AST (single editor.update() call)
  -> Tokens arrive -> local React state updates (NO editor.update())
  -> Pulsing cursor animation while streaming
  -> Stream ends -> "Accept" / "Discard" buttons appear

"Accept" pressed
  -> Parse final Markdown content -> $parseMarkdownToLexicalNodes()
  -> Replace AIPreviewNode with parsed Lexical nodes (single editor.update())
  -> Creates exactly ONE undo history entry

"Discard" pressed
  -> Remove AIPreviewNode from AST (single editor.update())
  -> AST returns to its state before the node was inserted

Stream states

The component tracks three internal states:

StateVisualButtons
streamingContent appears progressively with a pulsing cursor.None (wait for stream to complete).
completeFull content displayed.Accept and Discard.
errorError message displayed in destructive color.Retry (if retries remain) and Dismiss.

Styling

The node uses the --blokhaus-ai-stream CSS variable for its accent color (left border, header text, accept button background) and --blokhaus-ai-stream-bg for its background. Override these tokens in your tokens.css to match your brand:

:root {
  --blokhaus-ai-stream: hsl(262 83% 58%);
  --blokhaus-ai-stream-bg: rgba(51, 102, 204, 0.05);
}

Retry behavior

When the stream encounters an error, the node shows a "Retry" button. Each click re-triggers the stream from scratch. Retry behavior is configured via AIPluginConfig.retry:

interface AIRetryConfig {
  /** Maximum number of retries allowed. Set to 0 to disable retry. Default: Infinity */
  maxRetries?: number;
}

When retryCount >= maxRetries, the Retry button is hidden and only Dismiss remains.

Customizable labels

All button labels and status text can be customized via AIPluginConfig.labels:

interface AIPreviewLabels {
  header?: string; // default: "AI"
  streaming?: string; // default: "generating..."
  accept?: string; // default: "Accept"
  discard?: string; // default: "Discard"
  retry?: string; // default: "Retry"
  dismiss?: string; // default: "Dismiss"
  defaultError?: string; // default: "An error occurred"
}

Serialized format

type SerializedAIPreviewNode = {
  type: "ai-preview";
  version: 1;
  prompt: string;
  context: string;
};

AIPreviewNode is ephemeral. While it implements exportJSON/importJSON for Lexical compatibility, it should not be persisted to a database. If the editor state is saved while an AI preview is active, it will appear as a static, non-functional block when restored.

Undo stack integrity

The critical design constraint of AIPreviewNode is undo stack protection:

  • During streaming: Zero editor.update() calls. All content updates happen in React useState. The undo stack is untouched.
  • On accept: Exactly one editor.update() call replaces the preview node with parsed Lexical nodes. This creates exactly one history entry, so pressing Cmd+Z removes all accepted AI content in a single step.
  • On discard: Exactly one editor.update() call removes the preview node. The editor returns to its pre-AI state.