Plate
PlateEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Comments
  • Discussion
  • Suggestions
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Details
  • Marks
    • Bold
    • Italic
    • Underline
    • Code
    • Highlight
    • Keyboard Input
    • Strikethrough
    • Subscript
    • Superscript
      • Font
      • Line Height
      • Text Align
    • Indent
    • List
      • Exit Break
      • Single Block
      • Trailing Block
    • Autoformat
    • Block Menu
    • Block Placeholder
    • Combobox
      • Emoji
      • MentionElement
      • Slash Command
    • Drag & Drop
    • Navigation Feedback
    • Tabbable
    • Toolbar
    • Yjs
    • Multi SelectEditor
    • CSV
    • DOCX
    • HTML
    • Markdown

AI

PreviousNext

AI-powered writing assistance.

Plus
Loading…
OverviewCopilot

On This Page

FeaturesKit usageInstallationAdd kitRender the editorAdd API routeConfigure environmentManual usageInstallationConfigure the kitBuild API routeOwn the chat lifecyclePrompt templatesClient promptingServer promptingKeyboard shortcutsStreamingTracked AI editsStreaming examplePlate PlusAPI ReferenceAIPluginAIChatPlugineditor.plugin(AIChatPlugin).api.submit(input, options?)editor.plugin(AIChatPlugin).api.reset()editor.read.aiChat.node(options?)editor.plugin(AIChatPlugin).api.reload()editor.plugin(AIChatPlugin).api.stop()editor.plugin(AIChatPlugin).api.show()editor.plugin(AIChatPlugin).api.hide(options?)editor.plugin(AIChatPlugin).api.accept()editor.plugin(AIChatPlugin).api.insertBelow(options?)editor.plugin(AIChatPlugin).api.replaceSelection(options?)editor.update.ai.insertNodes(nodes, options?)editor.update.ai.removeMarks(options?)editor.update.ai.removeNodes(options?)Registry chat previewuseAIChatAI chat capabilitiesai.api.findTextRangeInBlockCustomizationAdding custom AI commandsSimple custom commandCommand with complex logic
Build your editor
Production-ready AI template and reusable components.
Get all-access

Features

  • Context-aware command menu that adapts to cursor, text selection, and block selection workflows.
  • Streaming Markdown/MDX previews with table, column, and code block support through editor.plugin(AIChatPlugin).api.setPreview.
  • Insert and chat review modes backed by a temporary draft.
  • Block selection aware actions to replace or append entire sections using editor.plugin(AIChatPlugin).api.replaceSelection and insertBelow.
  • Direct integration with @ai-sdk/react so editor.plugin(AIChatPlugin).api.submit can stream responses from Vercel AI SDK helpers.
  • Explicit acceptance that applies a draft in one undo step and preserves the selected editing mode.
Report an issue

Kit usage

Installation

The fastest way to add AI functionality is with the AIKit. It ships the configured AIPlugin, AIChatPlugin, Markdown streaming helpers, cursor overlay, and their Plate UI components.

'use client';
 
import { BaseParagraphPlugin } from 'platejs';
import { AIChatPlugin, AIPlugin } from 'platejs/ai/react';
import {
  EditorText,
  usePluginStore,
  type EditorTextProps,
  type RenderNodeWrapperProps,
} from 'platejs/react';
import * as React from 'react';
 
import { cn } from '@/lib/utils';
import {
  AIChatEditor,
  AILoadingBar,
  AIMenu,
} from '@/components/editor/ai-menu';
 
import { AIChatTransportPlugin, useEditorChat } from './use-chat';
 
export function AILeaf(props: EditorTextProps<typeof AIPlugin>) {
  return (
    <EditorText
      className={cn(
        'border-b-2 border-b-purple-100 bg-purple-50 text-purple-800',
        'transition-all duration-200 ease-in-out'
      )}
      {...props}
    />
  );
}
 
function AIInlinePreview({
  children,
  editor,
  element,
}: RenderNodeWrapperProps) {
  const replacesEmptyParagraph =
    element.type === editor.plugin(BaseParagraphPlugin).schema.type &&
    editor.read.nodes.isEmpty(element);
  return (
    <div data-editor-ai-preview-wrapper="">
      <div hidden={replacesEmptyParagraph}>{children}</div>
      <div contentEditable={false} data-editor-ai-preview="">
        <AIChatEditor inline />
      </div>
    </div>
  );
}
 
export const AIKit = [
  AIPlugin.configure({ component: AILeaf }),
  AIChatTransportPlugin.extend(({ api, store }) => ({
    render: {
      useViewElementAttributes() {
        const key = usePluginStore(AIChatPlugin, (state) =>
          state.mode === 'insert' && state.previewValue.length > 0
            ? state._blockKey
            : null
        );
 
        return key
          ? [{ key, attributes: { 'data-editor-ai-preview-anchor': '' } }]
          : [];
      },
    },
    slots: {
      wrapNode: {
        component: AIInlinePreview,
        match: ({ editor, element }) => {
          const state = store.get();
          return (
            state.mode === 'insert' &&
            state.previewValue.length > 0 &&
            editor.key(element) === state._blockKey
          );
        },
      },
      afterContainer: AILoadingBar,
      afterEditable: AIMenu,
      // oxlint-disable-next-line eslint/func-name-matching -- Hooks require a named React component in this slot.
      wrapRoot: function AIIntegration({ children, editableRef }) {
        useEditorChat(editableRef);
        return children;
      },
    },
    shortcuts: {
      show: {
        keys: 'mod+j',
        handler: ({ editor }) => {
          editor.plugin(AIChatPlugin).api.show();
        },
      },
      stop: {
        keys: 'escape',
        handler: () => {
          const status = store.get().chat?.status;
          if (status !== 'streaming' && status !== 'submitted') return false;
          api.stop();
          return true;
        },
      },
    },
  })),
];
'use client';
 
import { BaseParagraphPlugin } from 'platejs';
import { AIChatPlugin, AIPlugin } from 'platejs/ai/react';
import {
  EditorText,
  usePluginStore,
  type EditorTextProps,
  type RenderNodeWrapperProps,
} from 'platejs/react';
import * as React from 'react';
 
import { cn } from '@/lib/utils';
import {
  AIChatEditor,
  AILoadingBar,
  AIMenu,
} from '@/components/editor/ai-menu';
 
import { AIChatTransportPlugin, useEditorChat } 





















































































  • AIMenu: Floating command surface for prompts, tool shortcuts, and chat review.
  • AILoadingBar: Displays streaming status at the editor container.
  • AILeaf: Renders AI-marked text with subtle styling.

Add kit

import { createEditor } from 'platejs/react';
import { AIKit } from '@/components/editor/ai';
 
const editor = createEditor({
  plugins: [
    // ...otherPlugins,
    ...AIKit,
  ],
});
import { createEditor } from 'platejs/react';
import { AIKit } from '@/components/editor/ai';
 
const editor = createEditor({




Render the editor

AIKit connects one chat session to each editor object. Render the editor inside EditorRoot; the kit owns transport and streaming cleanup independently of menu visibility.

import { EditorRoot, useCreateEditor } from 'platejs/react';
import { AIKit } from '@/components/editor/ai';
import { Editor, EditorContainer } from '@/components/editor/editor';
 
export function AIEditor() {
  const editor = useCreateEditor({ plugins: AIKit });
 
  return (
    <EditorRoot editor={editor}>
      <EditorContainer>
        <Editor />
      </EditorContainer>
    </EditorRoot>
  );
}

Views of the same editor share that session. A writable mounted view can continue streaming when a sibling detaches. The last view detaching aborts pending work. Put controlled readOnly state on each EditorRoot boundary and its Editor.

Add API route

Expose a streaming command endpoint that proxies your model provider:

import { createGateway } from '@ai-sdk/gateway';
import {
  type LanguageModel,
  type UIMessageStreamWriter,
  createUIMessageStream,
  createUIMessageStreamResponse,
  generateText,
  Output,
  streamText,
  tool,
} from 'ai';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { createEditor, nanoid } from 'platejs';
import {
  type AIChatRequestContext,
  type AIChatRequestRefs,
  resolveAIChatRequestContext,
} from



























































































































































































































































































































Configure environment

Set your AI Gateway key locally (replace with your provider secret if you are not using a gateway):

.env.local
AI_GATEWAY_API_KEY="your-api-key"
.env.local
AI_GATEWAY_API_KEY="your-api-key"

Manual usage

Installation

pnpm add platejs @ai-sdk/react @tanstack/react-virtual ai fastest-levenshtein marked remark-mdx remark-parse remark-stringify unified
pnpm add platejs @ai-sdk/react @tanstack/react-virtual ai fastest-levenshtein marked remark-mdx remark-parse remark-stringify unified

platejs supplies the editor and AI plugins.

Configure the kit

Copy the ai and use-chat components to configure transport, comment persistence, and rendering. The package owns streaming and session cleanup. Install AIKit, then configure its endpoint and presentation through AIChatTransportPlugin:

import { createEditor } from 'platejs/react';
import { AIKit } from '@/components/editor/ai';
import { AILoadingBar, AIMenu } from '@/components/editor/ai-menu';
import { AIChatTransportPlugin } from '@/components/editor/use-chat';
 
const editor = createEditor({
  plugins: [
    ...AIKit,
    AIChatTransportPlugin.configure({
      slots: {
        afterContainer: AILoadingBar,
        afterEditable: AIMenu,
      },
      initialState: {
        chatOptions: {
          api: '/api/ai/command',
          body: { model: 'openai/gpt-4o-mini' },
        },



AIKit includes the AI mark, Markdown dependency, and required React integration. Custom afterContainer and afterEditable slots preserve the session. Replacing wrapRoot replaces its React attachment; compose the attachment inside your wrapper when customizing that slot. For AI comments, install DiscussionKit and configure CommentsPlugin with the current user and the matching revision's initialComments: CommentsJSON.

Completed generated comments publish directly as ordinary threads without an approval prompt. Configure authorization and canonical persistence through CommentsPlugin.configure({ initialState: { mutate } }); the integration awaits the durable mutation result before treating publication as successful. The draft acceptance controls for generated document edits do not gate comment publication. See Comments for persistence and mutation results.

Build API route

Handle editor.plugin(AIChatPlugin).api.submit requests on the server. Each request includes the chat messages from @ai-sdk/react and a ctx payload that contains the editor children, current selection, and last toolName. Complete API example

app/api/ai/command/route.ts
import { createGateway } from '@ai-sdk/gateway';
import { convertToModelMessages, streamText } from 'ai';
import { createEditor } from 'platejs';
 
import { BaseEditorKit } from '@/registry/components/editor/plugins-static';
import { markdownJoinerTransform } from '@/registry/lib/markdown-joiner-transform';
 
export async function POST(req: Request) {
  const { apiKey, ctx, messages, model } = await req.json();
 
  const editor = createEditor

















  • ctx.children and ctx.selection are rehydrated into a Plate editor so you can build rich prompts (see Prompt Templates).
  • Forward provider settings (model, apiKey, temperature, gateway flags, etc.) through chatOptions.body; everything you add is passed verbatim in the JSON payload and can be read before calling createGateway.
  • Always read secrets from the server. The client should only send opaque identifiers or short-lived tokens.
  • Return a streaming response so the kit can apply chunks incrementally.

Own the chat lifecycle

The copied use-chat implementation owns one SDK session and chunk consumer per editor object. It publishes the adapter through AIChatPlugin, checks writable view authority, and aborts requests when no writable view remains. Customize that implementation when integrating a provider; AIKit owns its React attachment.

'use client';
 
import { type UIMessage, DefaultChatTransport } from 'ai';
import { NodeApi } from 'platejs';
import { AIChatPlugin, useAIChat } from 'platejs/ai/react';
import { CommentsPlugin } from 'platejs/comments/react';
import { MarkdownPlugin } from 'platejs/markdown';
import { useEditor, usePluginStore } from 'platejs/react';
import * as React from 'react';
import { toast } from 'sonner';
 
import { createCommentValue } from '@/components/editor/comment';
 

















































































































































































The copied ai-menu item owns floating-menu anchoring for cursor, text, and block selections. Keep product-specific menu effects beside that component.

'use client';
 
import { Command as CommandPrimitive } from 'cmdk';
import {
  Album,
  BadgeHelp,
  BookOpenCheck,
  Check,
  CornerUpLeft,
  FeatherIcon,
  ListEnd,
  ListMinus,
  ListPlus,
  Loader2Icon,
  PauseIcon,
  PenLine,
  SmileIcon,
  Wand,
  X,
} from 'lucide-react';
import {
  createEditorView,
  ElementApi,
  isHotkey,
  NodeApi,
  TextApi,
} from






















































































































































































































































































































































































































































































































































































































































































































































































Now you can submit prompts programmatically:

editor.plugin(AIChatPlugin).api.submit('', {
  prompt: {
    default: 'Continue the document after {block}',
    selecting: 'Rewrite {selection} with a clearer tone',
  },
  toolName: 'generate',
});
editor.plugin(AIChatPlugin).api.submit('', {
  prompt: {
    default: 'Continue the document after {block}',
    selecting: 'Rewrite {selection} with a clearer tone',
  },

Prompt templates

Client prompting

  • aiChat.api.submit accepts an EditorPrompt: a string, a default/selecting/nodeSelecting object, or a function receiving { editor, isSelecting, isNodeSelecting }.
  • isSelecting reports whether the representative range is expanded. isNodeSelecting reports exact node membership and remains true for selected empty nodes whose representative range is collapsed. nodeSelecting takes precedence over selecting.
  • aiChat.read.prompt resolves that input for the current snapshot.
  • aiChat.read.resolvePlaceholders expands {editor}, {block}, {nodeSelection}, and {prompt} with snapshot Markdown.
const aiChat = editor.plugin(AIChatPlugin);
const template = aiChat.read.resolvePlaceholders(
  'Rewrite {nodeSelection} using a friendly tone.'
);
 
aiChat.api.submit('Improve tone', {
  prompt: template,
  toolName: 'generate',
});
const aiChat = editor.plugin(AIChatPlugin);
const template = aiChat.read.resolvePlaceholders(
  'Rewrite {nodeSelection} using a friendly tone.'
);
 
aiChat.api.submit('Improve tone', {
  prompt: template,
  toolName: 'generate',
});

Server prompting

The demo backend in apps/www/src/app/api/ai/command reconstructs the editor from ctx and builds structured prompts:

  • getChooseToolPrompt decides whether the request is generate, edit, or comment.
  • getGeneratePrompt, getEditPrompt, and getCommentPrompt transform the current editor state into instructions tailored to each mode.
  • Server prompt helpers serialize explicit editor snapshots through editor.api.markdown.serialize and assemble selections, block IDs, and MDX tags with buildStructuredPrompt.

Augment the payload you send from the client to fine-tune server prompts:

editor.plugin(AIChatTransportPlugin).store.set({
  chatOptions: {
    api: '/api/ai/command',
    body: {
      model: 'openai/gpt-4o-mini',
      tone: 'playful',
      temperature: 0.4,
    },
  },
});
editor.plugin(AIChatTransportPlugin).store.set({
  chatOptions: {
    api: '/api/ai/command',
    body: {
      model: 'openai/gpt-4o-mini',
      tone: 'playful',
      temperature: 0.4,
    },
  },
});

Everything under chatOptions.body arrives in the route handler, letting you swap providers, pass user-specific metadata, or branch into different prompt templates.

Keyboard shortcuts

KeyDescription
SpaceOpen the AI menu in an empty block (cursor mode)
Cmd + JShow the AI menu (set via shortcuts.show)
EscapeHide the AI menu and stop streaming

Streaming

Publish the complete accumulated Markdown response with aiChat.api.setPreview(content, { requestId }). The plugin parses it with the editor's installed Markdown codecs and stores the result in previewValue. useAIChat handles this publication for AI SDK transports. Structured table responses use aiChat.api.setTablePreview({ ref, content }, { requestId }).

The draft does not modify the document, history, or editing mode. accept() applies it in one history batch. reset() drops it and restores the mapped invoking selection without reverting other edits. A deleted target cannot be replaced through its former path.

Tracked AI edits

AI keeps the current view's input policy. Accepting in Editing writes ordinary content. Accepting in Suggesting creates a native tracked edit for subsequent review. Streaming itself does not create suggestions or change the mode.

Copilot ghost text also stays temporary until accepted.

Streaming example

Open in New Tab
Loading…

Plate Plus

Combobox menu with free-form prompt input

  • Additional trigger methods:
    • Block menu button
    • Slash command menu
  • Beautifully crafted UI
Get the code

API Reference

AIPlugin

Adds an ai mark to streamed text and exposes transaction helpers for inserting and removing AI-marked text. Use .configure({ component }) to render AI-marked text with a custom component.

Options

    AI content is stored as a boolean text property using property.boolean({ default: false, omitDefault: true }) with explicit lifecycle rules.

    AI marks render once around each text node.

AIChatPlugin

Main plugin that powers the AI menu, chat state, and transforms.

Options

    Character(s) that open the command menu. Defaults to ' '.

    Pattern that must match the character before the trigger. Defaults to /^\s?$/.

    Return false to cancel opening in specific contexts.

    Current messages, status, error, and controls published by useAIChat (managed internally).

    Node snapshots paired with editor-scoped node keys for native authored replacements (managed internally).

    Selection captured before submitting a prompt (managed internally).

    Controls whether responses stream directly into the document or open a review panel. Defaults to 'insert'.

    Whether the AI menu is visible. Defaults to false.

    Generated preview nodes parsed by the editor session for insert and replace commands (managed internally).

    True while a response is streaming. Defaults to false.

    Active tool used to interpret the response.

editor.plugin(AIChatPlugin).api.submit(input, options?)

Submits a prompt to your model provider. When mode is omitted it defaults to 'insert' for a collapsed cursor and 'chat' otherwise.

Parameters

    Raw input from the user.

    Fine-tune submission behaviour.

Optionsobject

    Override the response mode.

    Forwarded to chat.sendMessage (model, headers, etc.).

    String, config, or function resolved against the current editor snapshot.

    Tags the submission so hooks can react differently.

editor.plugin(AIChatPlugin).api.reset()

Stops the request, clears the draft and chat state, and restores the mapped invoking selection when its target still exists in a writable view. It does not undo document edits or decide tracked suggestions.

editor.read.aiChat.node(options?)

Retrieves the source block anchoring the current AI draft.

Parameters

    Set streaming: true to retrieve the source block while a response is streaming.

ReturnsNodeEntry | undefined

    Matching node entry, if found.

editor.plugin(AIChatPlugin).api.reload()

Replays the last prompt using the stored chat adapter, restoring the original selection or block selection before resubmitting.

editor.plugin(AIChatPlugin).api.stop()

Stops generation, publishes all received text, and keeps the partial draft available for review.

editor.plugin(AIChatPlugin).api.show()

Opens the AI menu, clears previous chat messages, and resets tool state. Inside a non-empty block, a collapsed caret selects that block for editing. At the block end or in an empty block, the caret remains an insertion target. The session retains this target through submission and retry.

editor.plugin(AIChatPlugin).api.hide(options?)

Closes the AI menu, stops its request, and discards the unapplied draft. The source document and its history stay unchanged. Use stop() to pause generation while keeping the partial response available for review.

Parameters

    Set focus: false to keep focus outside the editor.

editor.plugin(AIChatPlugin).api.accept()

Applies the current draft as one reversible edit under the current editing mode.

editor.plugin(AIChatPlugin).api.insertBelow(options?)

Inserts the draft below the captured selection in one history batch.

Parameters

    Copy formatting from the source selection. Defaults to 'single'.

editor.plugin(AIChatPlugin).api.replaceSelection(options?)

Replaces the mapped selection with the draft in one history batch.

Parameters

    Controls how much formatting from the original selection should be applied.

editor.update.ai.insertNodes(nodes, options?)

Inserts nodes tagged with the AI mark at the current selection (or options.target).

editor.update.ai.removeMarks(options?)

Clears the AI mark from matching nodes.

editor.update.ai.removeNodes(options?)

Removes text nodes that are marked as AI-generated.

Registry chat preview

AIChatEditor renders previewValue with purple draft text and a trailing stream indicator. Insert mode places it after the invoking block; chat mode places it in the review menu. Both use the same draft and acceptance actions. The package parses Markdown using the editor's installed plugins, so applying a draft also works without a mounted preview component.

'use client';
 
import { Command as CommandPrimitive } from 'cmdk';
import {
  Album,
  BadgeHelp,
  BookOpenCheck,
  Check,
  CornerUpLeft,
  FeatherIcon,
  ListEnd,
  ListMinus,
  ListPlus,
  Loader2Icon,
  PauseIcon,
  PenLine,
  SmileIcon,
  Wand,
  X,
} from 'lucide-react';
import {
  createEditorView,
  ElementApi,
  isHotkey,
  NodeApi,
  TextApi,
} from 'platejs';
import { BaseAIPlugin } from 'platejs/ai';
import { AIChatPlugin } from 'platejs/ai/react';
import { CommentsPlugin } from 'platejs/comments/react';
import {
  useEditorRuntimeState,
  useCreateEditor,
  useEditorSelector,
  useFocusedLast,
  usePluginStore,
  type Editor,
  useEditor,
} from 'platejs/react';
import * as React from 'react';
 
import { Button } from '@/components/ui/button';
import {
  Command,
  CommandGroup,
  CommandItem,
  CommandList,
} from '@/components/ui/command';
import { cn } from '@/lib/utils';
import {
  FloatingPopover,
  FloatingPopoverAnchor,
  FloatingPopoverContent,
} from '@/components/editor/floating-popover';
import { BaseEditorKit } from '@/components/editor/plugins-static';
 
import { EditorStatic } from './editor-static';
 
const PreviewAIPlugin = BaseAIPlugin.extend(({ editor }) => ({
  decorate: {
    read: ({ entry: [node, path] }) => {
      if (!TextApi.isText(node) || node.text.length === 0) return [];
 
      return [
        {
          key: 'ai-preview',
          range: {
            anchor: { path, offset: 0 },
            focus: { path, offset: node.text.length },
          },
          attributes: {
            className:
              'border-b-2 border-b-purple-100 bg-purple-50 text-purple-800',
            'data-editor-ai-end':
              NodeApi.last(
                { children: editor.read.children(), type: '' },
                []
              )[0] === node
                ? ''
                : undefined,
          },
        },
      ];
    },
  },
}));
 
const scrollAIPreviewEnd = (editor: Editor, draft: HTMLElement | null) => {
  const scrollElement = editor.api.dom.scroll();
  const target = draft?.querySelector<HTMLElement>('[data-editor-ai-end]');
  if (!scrollElement || !target) return;
 
  const scrollBounds = scrollElement.getBoundingClientRect();
  const targetBounds = target.getBoundingClientRect();
 
  scrollElement.scrollTop +=
    targetBounds.top +
    targetBounds.height / 2 -
    (scrollBounds.top + scrollBounds.height / 2);
};
 
export function AIChatEditor({ inline = false }: { inline?: boolean }) {
  const editor = useEditor();
  const draftRef = React.useRef<HTMLDivElement>(null);
  const aiEditor = useCreateEditor({
    plugins: [...BaseEditorKit, PreviewAIPlugin],
  });
  const document = usePluginStore(AIChatPlugin, 'previewValue');
  const streaming = usePluginStore(AIChatPlugin, 'streaming');
 
  const preview = useEditorRuntimeState(
    aiEditor,
    React.useCallback(
      () => createEditorView(aiEditor, { readOnly: true }),
      [aiEditor]
    )
  );
 
  React.useLayoutEffect(() => {
    aiEditor.update({ history: 'skip' }).value.replace({ children: document });
  }, [aiEditor, document]);
 
  React.useEffect(() => {
    if (!inline) return;
 
    scrollAIPreviewEnd(editor, draftRef.current);
  }, [editor, inline, preview]);
 
  React.useEffect(() => {
    const draft = draftRef.current;
    const Observer = draft?.ownerDocument.defaultView?.ResizeObserver;
    if (!inline || !draft || !Observer) return undefined;
 
    const observer = new Observer(() => scrollAIPreviewEnd(editor, draft));
    observer.observe(draft);
 
    return () => observer.disconnect();
  }, [editor, inline]);
 
  const last =
    document.length > 0
      ? NodeApi.last({ children: document, type: '' }, [])[0]
      : null;
 
  return (
    <div ref={draftRef} data-editor-ai-draft="">
      <EditorStatic
        variant={inline ? 'none' : 'aiChat'}
        editor={preview}
        className={cn(
          streaming &&
            '[&_[data-editor-ai-end]]:after:ml-1.5 [&_[data-editor-ai-end]]:after:inline-block [&_[data-editor-ai-end]]:after:size-3 [&_[data-editor-ai-end]]:after:rounded-full [&_[data-editor-ai-end]]:after:bg-purple-600 [&_[data-editor-ai-end]]:after:align-middle [&_[data-editor-ai-end]]:after:content-[""]'
        )}
      />
      {streaming && (!last || !TextApi.isText(last) || !last.text) && (
        <span
          data-editor-ai-end=""
          className="inline-block size-3 rounded-full bg-purple-600 align-middle"
        />
      )}
    </div>
  );
}
 
export function AIMenu() {
  const editor = useEditor();
  const { api, read } = useEditor().plugin(AIChatPlugin);
  const mode = usePluginStore(AIChatPlugin, 'mode');
  const toolName = usePluginStore(AIChatPlugin, 'toolName');
 
  const streaming = usePluginStore(AIChatPlugin, 'streaming');
  const editAnchorKey = useEditorSelector((innerEditor) => {
    const entry = innerEditor.read.selection.nodes().at(-1);
 
    return entry && ElementApi.isElement(entry[0])
      ? innerEditor.key(entry[0])
      : null;
  });
  const isFocusedLast = useFocusedLast();
  const chatOpen = usePluginStore(AIChatPlugin, 'open');
  const open = chatOpen && isFocusedLast;
  const [value, setValue] = React.useState('');
 
  const [input, setInput] = React.useState('');
 
  const chat = usePluginStore(AIChatPlugin, 'chat');
  const previewValue = usePluginStore(AIChatPlugin, 'previewValue');
 
  const messages = chat?.messages;
  const status = chat?.status ?? 'ready';
  const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(
    null
  );
 
  React.useEffect(() => {
    if (!streaming && previewValue.length === 0) return undefined;
 
    const anchorEntry = read.node();
    if (!anchorEntry) return undefined;
 
    const anchorDom = editor.api.dom.resolveDOMNode(anchorEntry[0]);
    if (!anchorDom) return undefined;
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(
        anchorDom.closest<HTMLElement>('[data-editor-ai-preview-wrapper]') ??
          anchorDom
      );
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [editor, previewValue, read, streaming]);
 
  const setOpen = (innerOpen: boolean) => {
    if (innerOpen) {
      if (!chatOpen) api.show();
    } else if (chatOpen) {
      api.hide({ focus: false });
    }
  };
 
  React.useEffect(() => {
    if (!chatOpen) {
      const animationFrame = window.requestAnimationFrame(() => {
        setAnchorElement(null);
        setInput('');
      });
 
      return () => {
        window.cancelAnimationFrame(animationFrame);
      };
    }
 
    let nextAnchor: HTMLElement | null = null;
    const block =
      editor.read.nodes.blocks().at(-1) ?? editor.read.nodes.block();
    if (block && ElementApi.isElement(block[0])) {
      nextAnchor = editor.api.dom.resolveDOMNode(block[0]);
    }
 
    if (!nextAnchor) return undefined;
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(nextAnchor);
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [chatOpen, editor]);
 
  const isLoading = status === 'streaming' || status === 'submitted';
 
  React.useEffect(() => {
    if (toolName !== 'edit' || mode !== 'chat' || isLoading) return undefined;
 
    let anchorNode = editAnchorKey
      ? editor.read.nodes.get(editAnchorKey, {
          match: ElementApi.isElement,
        })
      : undefined;
 
    if (!anchorNode) {
      anchorNode =
        editor.read.nodes.blocks().at(-1) ?? editor.read.nodes.block();
    }
 
    if (!anchorNode) return undefined;
 
    const block = editor.read.nodes.block({ at: anchorNode[1] });
    const domNode = block ? editor.api.dom.resolveDOMNode(block[0]) : null;
 
    if (!domNode) return undefined;
 
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(domNode);
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [editAnchorKey, editor, isLoading, mode, toolName]);
 
  if (isLoading && mode === 'insert') return null;
 
  if (toolName === 'comment') return null;
 
  if (!anchorElement) return null;
 
  return (
    <FloatingPopover open={open} onOpenChange={setOpen} modal={false}>
      <FloatingPopoverAnchor element={anchorElement} />
 
      <FloatingPopoverContent
        className="w-(--floating-popover-anchor-width) max-w-[calc(100vw-16px)] border-none bg-transparent p-0 shadow-none ring-0"
        onEscapeKeyDown={(e) => {
          e.preventDefault();
 
          api.hide();
        }}
        align="center"
        side="bottom"
      >
        <Command
          className="w-full rounded-lg border shadow-md"
          value={value}
          onValueChange={setValue}
        >
          {mode === 'chat' && previewValue.length > 0 && <AIChatEditor />}
 
          {isLoading ? (
            <div className="flex grow items-center gap-2 p-2 text-sm text-muted-foreground select-none">
              <Loader2Icon className="size-4 animate-spin" />
              {(messages?.length ?? 0) > 1 ? 'Editing...' : 'Thinking...'}
            </div>
          ) : (
            <CommandPrimitive.Input
              className={cn(
                'flex h-9 w-full min-w-0 border-input bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground md:text-sm dark:bg-input/30',
                'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
                'border-b focus-visible:ring-transparent'
              )}
              value={input}
              onKeyDown={(e) => {
                if (isHotkey('backspace')(e) && input.length === 0) {
                  e.preventDefault();
                  api.hide();
                }
                if (isHotkey('enter')(e) && !e.shiftKey && !value) {
                  e.preventDefault();
                  api.submit(input);
                  setInput('');
                }
              }}
              onValueChange={setInput}
              placeholder="Ask AI anything..."
              data-editor-keep-selection-visible
              autoFocus
            />
          )}
 
          {!isLoading && (
            <CommandList>
              <AIMenuItems
                input={input}
                setInput={setInput}
                setValue={setValue}
              />
            </CommandList>
          )}
        </Command>
      </FloatingPopoverContent>
    </FloatingPopover>
  );
}
 
type EditorChatState =
  | 'cursorCommand'
  | 'cursorSuggestion'
  | 'selectionCommand'
  | 'selectionSuggestion';
 
const AICommentIcon = () => (
  <svg
    fill="none"
    height="24"
    stroke="currentColor"
    strokeLinecap="round"
    strokeLinejoin="round"
    strokeWidth="2"
    viewBox="0 0 24 24"
    width="24"
    xmlns="http://www.w3.org/2000/svg"
  >
    <path d="M0 0h24v24H0z" fill="none" stroke="none" />
    <path d="M8 9h8" />
    <path d="M8 13h4.5" />
    <path d="M10 19l-1 -1h-3a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v4.5" />
    <path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138z" />
  </svg>
);
 
const aiChatItems = {
  accept: {
    icon: <Check />,
    label: 'Accept',
    value: 'accept',
    onSelect: ({ editor }) => {
      editor.plugin(AIChatPlugin).api.accept();
      editor.api.dom.focus();
    },
  },
  comment: {
    icon: <AICommentIcon />,
    label: 'Comment',
    value: 'comment',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        mode: 'insert',
        prompt:
          'Please comment on the following content and provide reasonable and meaningful feedback.',
        toolName: 'comment',
      });
    },
  },
  continueWrite: {
    icon: <PenLine />,
    label: 'Continue writing',
    value: 'continueWrite',
    onSelect: ({ editor, input }) => {
      const ancestorNode = editor.read.nodes.block();
 
      if (!ancestorNode) return;
 
      const isEmpty = NodeApi.string(ancestorNode[0]).trim().length === 0;
 
      editor.plugin(AIChatPlugin).api.submit(input, {
        mode: 'insert',
        prompt: isEmpty
          ? `<Document>
{editor}
</Document>
Start writing a new paragraph AFTER <Document> ONLY ONE SENTENCE`
          : `<Block>
{block}
</Block>
Continue writing AFTER <Block> with ONLY ONE SENTENCE. DO NOT REPEAT THE TEXT.`,
        toolName: 'generate',
      });
    },
  },
  discard: {
    icon: <X />,
    label: 'Discard',
    shortcut: 'Escape',
    value: 'discard',
    onSelect: ({ editor }) => {
      editor.plugin(AIChatPlugin).api.hide();
    },
  },
  emojify: {
    icon: <SmileIcon />,
    label: 'Emojify',
    value: 'emojify',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Add a small number of contextually relevant emojis within each block only. You may insert emojis, but do not remove, replace, or rewrite existing text, and do not modify Markdown syntax, links, or line breaks.',
        toolName: 'edit',
      });
    },
  },
  explain: {
    icon: <BadgeHelp />,
    label: 'Explain',
    value: 'explain',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt: {
          default: 'Explain {editor}',
          selecting: 'Explain',
        },
        toolName: 'generate',
      });
    },
  },
  fixSpelling: {
    icon: <Check />,
    label: 'Fix spelling & grammar',
    value: 'fixSpelling',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Fix spelling, grammar, and punctuation errors within each block only, without changing meaning, tone, or adding new information.',
        toolName: 'edit',
      });
    },
  },
  generateMarkdownSample: {
    icon: <BookOpenCheck />,
    label: 'Generate Markdown sample',
    value: 'generateMarkdownSample',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt: 'Generate a markdown sample',
        toolName: 'generate',
      });
    },
  },
  generateMdxSample: {
    icon: <BookOpenCheck />,
    label: 'Generate MDX sample',
    value: 'generateMdxSample',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt: 'Generate a mdx sample',
        toolName: 'generate',
      });
    },
  },
  improveWriting: {
    icon: <Wand />,
    label: 'Improve writing',
    value: 'improveWriting',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Improve the writing for clarity and flow, without changing meaning or adding new information.',
        toolName: 'edit',
      });
    },
  },
  insertBelow: {
    icon: <ListEnd />,
    label: 'Insert below',
    value: 'insertBelow',
    onSelect: ({ editor }) => {
      /** Format: 'none' Fix insert table */
      editor.plugin(AIChatPlugin).api.insertBelow({ format: 'none' });
      editor.api.dom.focus();
    },
  },
  makeLonger: {
    icon: <ListPlus />,
    label: 'Make longer',
    value: 'makeLonger',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Make the content longer by elaborating on existing ideas within each block only, without changing meaning or adding new information.',
        toolName: 'edit',
      });
    },
  },
  makeShorter: {
    icon: <ListMinus />,
    label: 'Make shorter',
    value: 'makeShorter',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Make the content shorter by reducing verbosity within each block only, without changing meaning or removing essential information.',
        toolName: 'edit',
      });
    },
  },
  replace: {
    icon: <Check />,
    label: 'Replace selection',
    value: 'replace',
    onSelect: ({ editor }) => {
      editor.plugin(AIChatPlugin).api.replaceSelection();
      editor.api.dom.focus();
    },
  },
  simplifyLanguage: {
    icon: <FeatherIcon />,
    label: 'Simplify language',
    value: 'simplifyLanguage',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Simplify the language by using clearer and more straightforward wording within each block only, without changing meaning or adding new information.',
        toolName: 'edit',
      });
    },
  },
  summarize: {
    icon: <Album />,
    label: 'Add a summary',
    value: 'summarize',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        mode: 'insert',
        prompt: {
          default: 'Summarize {editor}',
          selecting: 'Summarize',
        },
        toolName: 'generate',
      });
    },
  },
  tryAgain: {
    icon: <CornerUpLeft />,
    label: 'Try again',
    value: 'tryAgain',
    onSelect: ({ editor }) => {
      editor.plugin(AIChatPlugin).api.reload();
    },
  },
} satisfies Record<
  string,
  {
    icon: React.ReactNode;
    label: string;
    value: string;
    component?: React.ComponentType<{ menuState: EditorChatState }>;
    filterItems?: boolean;
    items?: Array<{ label: string; value: string }>;
    shortcut?: string;
    onSelect?: ({ editor, input }: { editor: Editor; input: string }) => void;
  }
>;
 
const menuStateItems: Record<
  EditorChatState,
  Array<{
    items: Array<(typeof aiChatItems)[keyof typeof aiChatItems]>;
    heading?: string;
  }>
> = {
  cursorCommand: [
    {
      items: [
        aiChatItems.comment,
        aiChatItems.generateMdxSample,
        aiChatItems.generateMarkdownSample,
        aiChatItems.continueWrite,
        aiChatItems.summarize,
        aiChatItems.explain,
      ],
    },
  ],
  cursorSuggestion: [
    {
      items: [aiChatItems.accept, aiChatItems.discard, aiChatItems.tryAgain],
    },
  ],
  selectionCommand: [
    {
      items: [
        aiChatItems.improveWriting,
        aiChatItems.comment,
        aiChatItems.emojify,
        aiChatItems.makeLonger,
        aiChatItems.makeShorter,
        aiChatItems.fixSpelling,
        aiChatItems.simplifyLanguage,
      ],
    },
  ],
  selectionSuggestion: [
    {
      items: [
        aiChatItems.accept,
        aiChatItems.discard,
        aiChatItems.insertBelow,
        aiChatItems.tryAgain,
      ],
    },
  ],
};
 
export const AIMenuItems = ({
  input,
  setInput,
  setValue,
}: {
  input: string;
  setInput: (value: string) => void;
  setValue: (value: string) => void;
}) => {
  const editor = useEditor();
  const comments = editor.plugin(CommentsPlugin);
  const messages = usePluginStore(AIChatPlugin, 'chat')?.messages;
  const mode = usePluginStore(AIChatPlugin, 'mode');
  const isSelecting = useEditorSelector(
    (innerEditor2) =>
      innerEditor2.read.selection.nodes().length > 0 ||
      innerEditor2.read.selection.isExpanded()
  );
 
  const menuState: EditorChatState =
    (messages?.length ?? 0) > 0
      ? mode === 'chat'
        ? 'selectionSuggestion'
        : 'cursorSuggestion'
      : isSelecting
        ? 'selectionCommand'
        : 'cursorCommand';
  const menuGroups = comments.installed
    ? menuStateItems[menuState]
    : menuStateItems[menuState].map((group) => ({
        ...group,
        items: group.items.filter((item) => item !== aiChatItems.comment),
      }));
  const firstItemValue = menuGroups[0]?.items[0]?.value;
 
  React.useEffect(() => {
    if (firstItemValue) setValue(firstItemValue);
  }, [firstItemValue, setValue]);
 
  return (
    <>
      {menuGroups.map((group) => (
        <CommandGroup
          key={group.heading ?? group.items[0]?.value}
          heading={group.heading}
        >
          {group.items.map((menuItem) => (
            <CommandItem
              key={menuItem.value}
              className="[&_svg]:text-muted-foreground"
              value={menuItem.value}
              onSelect={() => {
                menuItem.onSelect?.({ editor, input });
                setInput('');
              }}
            >
              {menuItem.icon}
              <span>{menuItem.label}</span>
            </CommandItem>
          ))}
        </CommandGroup>
      ))}
    </>
  );
};
 
export function AILoadingBar() {
  const toolName = usePluginStore(AIChatPlugin, 'toolName');
  const chat = usePluginStore(AIChatPlugin, 'chat');
  const mode = usePluginStore(AIChatPlugin, 'mode');
 
  const status = chat?.status ?? 'ready';
 
  const { api } = useEditor().plugin(AIChatPlugin);
 
  const isLoading = status === 'streaming' || status === 'submitted';
 
  if (isLoading && (mode === 'insert' || toolName === 'comment')) {
    return (
      <div
        className={cn(
          'fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-md border border-border bg-muted px-3 py-1.5 text-muted-foreground text-sm shadow-md transition-all duration-300'
        )}
      >
        <span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
        <span>{status === 'submitted' ? 'Thinking...' : 'Writing...'}</span>
        <Button
          size="sm"
          variant="ghost"
          className="flex items-center gap-1 text-xs"
          onKeyDown={(event) => {
            if (event.key !== 'Escape' || event.nativeEvent.isComposing) return;
            event.preventDefault();
            event.stopPropagation();
            api.stop();
          }}
          onClick={() => {
            api.stop();
          }}
        >
          <PauseIcon className="h-4 w-4" />
          Stop
          <kbd className="ml-1 rounded bg-border px-1 font-mono text-[10px] text-muted-foreground shadow-sm">
            Esc
          </kbd>
        </Button>
      </div>
    );
  }
 
  if (toolName === 'comment' && status === 'error') {
    return (
      <div
        className="fixed bottom-4 left-1/2 z-50 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center gap-2 rounded-lg border bg-popover p-2 text-sm shadow-lg"
        data-ai-comment-error=""
        data-editor-keep-selection-visible
      >
        <p className="w-full px-1 text-destructive" role="alert">
          Could not generate comments.
        </p>
        <Button onClick={() => api.reload()} size="sm" variant="outline">
          Try again
        </Button>
        <Button onClick={() => api.hide()} size="sm" variant="outline">
          <X data-icon="inline-start" />
          Dismiss
        </Button>
      </div>
    );
  }
 
  return null;
}
'use client';
 
import { Command as CommandPrimitive } from 'cmdk';
import {
  Album,
  BadgeHelp,
  BookOpenCheck,
  Check,
  CornerUpLeft,
  FeatherIcon,
  ListEnd,
  ListMinus,
  ListPlus,
  Loader2Icon,
  PauseIcon,
  PenLine,
  SmileIcon,
  Wand,
  X,
} from 'lucide-react';
import {
  createEditorView,
  ElementApi,
  isHotkey,
  NodeApi,
  TextApi,
} from 'platejs';






















































































































































































































































































































































































































































































































































































































































































































































































useAIChat

Connects a shared AI SDK transport to an editor's mounted views. The hook owns draft publication and request cancellation. It returns void.

import { DefaultChatTransport } from 'ai';
import { AIChatPlugin, useAIChat } from 'platejs/ai/react';
 
const CustomAIChatPlugin = AIChatPlugin.extend(() => {
  const transport = new DefaultChatTransport({ api: '/api/ai/command' });
 
  return {
    slots: {
      wrapRoot: function ChatIntegration({ children, editableRef }) {
        useAIChat({ editableRef, transport });
        return children;
      },
    },
  };
});
import { DefaultChatTransport } from 'ai';
import { AIChatPlugin, useAIChat } from 'platejs/ai/react';
 
const CustomAIChatPlugin = AIChatPlugin.extend(() => {
  const transport = new DefaultChatTransport({ api: '/api/ai/command' });
 
  return {
    slots: {
      wrapRoot: function ChatIntegration({ children, editableRef }) {
        useAIChat({ editableRef, transport });
        return children;
      },
    },
  };
});

Share the transport between views of one editor. Replacing it cancels the previous request. The last view detaching or the editor becoming read-only also cancels pending work. A stopped request cannot write after a view is remounted.

Parameters

    The view's editable root.

    The application's shared AI SDK transport.

    Handles application data such as comment persistence. Check signal.aborted after asynchronous work before applying its result.

The copied use-chat component keeps endpoint/body settings and comment persistence local. Production transports report HTTP errors; documentation demos provide sample responses from their own server endpoints.

AI chat capabilities

Use the installed plugin for AI Chat services, snapshot reads, and updates:

const aiChat = editor.plugin(AIChatPlugin);
 
const prompt = aiChat.read.prompt({ prompt: 'Improve this' });
aiChat.api.setPreview(accumulatedMarkdown);
const aiChat = editor.plugin(AIChatPlugin);
 
const prompt = aiChat.read.prompt({ prompt: 'Improve this' });
aiChat.api.setPreview(accumulatedMarkdown);
NamespaceMethods
ai.apifindTextRangeInBlock
aiChat.apiaccept, setPreview, setTablePreview, hide, insertBelow, reload, replaceSelection, reset, show, stop, submit
aiChat.readcommentRange, insertStart, markdown, node, prompt, resolvePlaceholders
aiChat.store.getlastAssistantMessage

ai.api.findTextRangeInBlock

Find an exact, typo-tolerant, or partial-prefix text match inside a block:

const range = editor.plugin(AIPlugin).api.findTextRangeInBlock({
  block,
  findText: 'Text to locate',
});
const range = editor.plugin(AIPlugin).api.findTextRangeInBlock({
  block,
  findText: 'Text to locate',
});

It returns a Plate Range or null.

Customization

Adding custom AI commands

'use client';
 
import { Command as CommandPrimitive } from 'cmdk';
import {
  Album,
  BadgeHelp,
  BookOpenCheck,
  Check,
  CornerUpLeft,
  FeatherIcon,
  ListEnd,
  ListMinus,
  ListPlus,
  Loader2Icon,
  PauseIcon,
  PenLine,
  SmileIcon,
  Wand,
  X,
} from 'lucide-react';
import {
  createEditorView,
  ElementApi,
  isHotkey,
  NodeApi,
  TextApi,
} from 'platejs';
import { BaseAIPlugin } from 'platejs/ai';
import { AIChatPlugin } from 'platejs/ai/react';
import { CommentsPlugin } from 'platejs/comments/react';
import {
  useEditorRuntimeState,
  useCreateEditor,
  useEditorSelector,
  useFocusedLast,
  usePluginStore,
  type Editor,
  useEditor,
} from 'platejs/react';
import * as React from 'react';
 
import { Button } from '@/components/ui/button';
import {
  Command,
  CommandGroup,
  CommandItem,
  CommandList,
} from '@/components/ui/command';
import { cn } from '@/lib/utils';
import {
  FloatingPopover,
  FloatingPopoverAnchor,
  FloatingPopoverContent,
} from '@/components/editor/floating-popover';
import { BaseEditorKit } from '@/components/editor/plugins-static';
 
import { EditorStatic } from './editor-static';
 
const PreviewAIPlugin = BaseAIPlugin.extend(({ editor }) => ({
  decorate: {
    read: ({ entry: [node, path] }) => {
      if (!TextApi.isText(node) || node.text.length === 0) return [];
 
      return [
        {
          key: 'ai-preview',
          range: {
            anchor: { path, offset: 0 },
            focus: { path, offset: node.text.length },
          },
          attributes: {
            className:
              'border-b-2 border-b-purple-100 bg-purple-50 text-purple-800',
            'data-editor-ai-end':
              NodeApi.last(
                { children: editor.read.children(), type: '' },
                []
              )[0] === node
                ? ''
                : undefined,
          },
        },
      ];
    },
  },
}));
 
const scrollAIPreviewEnd = (editor: Editor, draft: HTMLElement | null) => {
  const scrollElement = editor.api.dom.scroll();
  const target = draft?.querySelector<HTMLElement>('[data-editor-ai-end]');
  if (!scrollElement || !target) return;
 
  const scrollBounds = scrollElement.getBoundingClientRect();
  const targetBounds = target.getBoundingClientRect();
 
  scrollElement.scrollTop +=
    targetBounds.top +
    targetBounds.height / 2 -
    (scrollBounds.top + scrollBounds.height / 2);
};
 
export function AIChatEditor({ inline = false }: { inline?: boolean }) {
  const editor = useEditor();
  const draftRef = React.useRef<HTMLDivElement>(null);
  const aiEditor = useCreateEditor({
    plugins: [...BaseEditorKit, PreviewAIPlugin],
  });
  const document = usePluginStore(AIChatPlugin, 'previewValue');
  const streaming = usePluginStore(AIChatPlugin, 'streaming');
 
  const preview = useEditorRuntimeState(
    aiEditor,
    React.useCallback(
      () => createEditorView(aiEditor, { readOnly: true }),
      [aiEditor]
    )
  );
 
  React.useLayoutEffect(() => {
    aiEditor.update({ history: 'skip' }).value.replace({ children: document });
  }, [aiEditor, document]);
 
  React.useEffect(() => {
    if (!inline) return;
 
    scrollAIPreviewEnd(editor, draftRef.current);
  }, [editor, inline, preview]);
 
  React.useEffect(() => {
    const draft = draftRef.current;
    const Observer = draft?.ownerDocument.defaultView?.ResizeObserver;
    if (!inline || !draft || !Observer) return undefined;
 
    const observer = new Observer(() => scrollAIPreviewEnd(editor, draft));
    observer.observe(draft);
 
    return () => observer.disconnect();
  }, [editor, inline]);
 
  const last =
    document.length > 0
      ? NodeApi.last({ children: document, type: '' }, [])[0]
      : null;
 
  return (
    <div ref={draftRef} data-editor-ai-draft="">
      <EditorStatic
        variant={inline ? 'none' : 'aiChat'}
        editor={preview}
        className={cn(
          streaming &&
            '[&_[data-editor-ai-end]]:after:ml-1.5 [&_[data-editor-ai-end]]:after:inline-block [&_[data-editor-ai-end]]:after:size-3 [&_[data-editor-ai-end]]:after:rounded-full [&_[data-editor-ai-end]]:after:bg-purple-600 [&_[data-editor-ai-end]]:after:align-middle [&_[data-editor-ai-end]]:after:content-[""]'
        )}
      />
      {streaming && (!last || !TextApi.isText(last) || !last.text) && (
        <span
          data-editor-ai-end=""
          className="inline-block size-3 rounded-full bg-purple-600 align-middle"
        />
      )}
    </div>
  );
}
 
export function AIMenu() {
  const editor = useEditor();
  const { api, read } = useEditor().plugin(AIChatPlugin);
  const mode = usePluginStore(AIChatPlugin, 'mode');
  const toolName = usePluginStore(AIChatPlugin, 'toolName');
 
  const streaming = usePluginStore(AIChatPlugin, 'streaming');
  const editAnchorKey = useEditorSelector((innerEditor) => {
    const entry = innerEditor.read.selection.nodes().at(-1);
 
    return entry && ElementApi.isElement(entry[0])
      ? innerEditor.key(entry[0])
      : null;
  });
  const isFocusedLast = useFocusedLast();
  const chatOpen = usePluginStore(AIChatPlugin, 'open');
  const open = chatOpen && isFocusedLast;
  const [value, setValue] = React.useState('');
 
  const [input, setInput] = React.useState('');
 
  const chat = usePluginStore(AIChatPlugin, 'chat');
  const previewValue = usePluginStore(AIChatPlugin, 'previewValue');
 
  const messages = chat?.messages;
  const status = chat?.status ?? 'ready';
  const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(
    null
  );
 
  React.useEffect(() => {
    if (!streaming && previewValue.length === 0) return undefined;
 
    const anchorEntry = read.node();
    if (!anchorEntry) return undefined;
 
    const anchorDom = editor.api.dom.resolveDOMNode(anchorEntry[0]);
    if (!anchorDom) return undefined;
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(
        anchorDom.closest<HTMLElement>('[data-editor-ai-preview-wrapper]') ??
          anchorDom
      );
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [editor, previewValue, read, streaming]);
 
  const setOpen = (innerOpen: boolean) => {
    if (innerOpen) {
      if (!chatOpen) api.show();
    } else if (chatOpen) {
      api.hide({ focus: false });
    }
  };
 
  React.useEffect(() => {
    if (!chatOpen) {
      const animationFrame = window.requestAnimationFrame(() => {
        setAnchorElement(null);
        setInput('');
      });
 
      return () => {
        window.cancelAnimationFrame(animationFrame);
      };
    }
 
    let nextAnchor: HTMLElement | null = null;
    const block =
      editor.read.nodes.blocks().at(-1) ?? editor.read.nodes.block();
    if (block && ElementApi.isElement(block[0])) {
      nextAnchor = editor.api.dom.resolveDOMNode(block[0]);
    }
 
    if (!nextAnchor) return undefined;
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(nextAnchor);
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [chatOpen, editor]);
 
  const isLoading = status === 'streaming' || status === 'submitted';
 
  React.useEffect(() => {
    if (toolName !== 'edit' || mode !== 'chat' || isLoading) return undefined;
 
    let anchorNode = editAnchorKey
      ? editor.read.nodes.get(editAnchorKey, {
          match: ElementApi.isElement,
        })
      : undefined;
 
    if (!anchorNode) {
      anchorNode =
        editor.read.nodes.blocks().at(-1) ?? editor.read.nodes.block();
    }
 
    if (!anchorNode) return undefined;
 
    const block = editor.read.nodes.block({ at: anchorNode[1] });
    const domNode = block ? editor.api.dom.resolveDOMNode(block[0]) : null;
 
    if (!domNode) return undefined;
 
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(domNode);
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [editAnchorKey, editor, isLoading, mode, toolName]);
 
  if (isLoading && mode === 'insert') return null;
 
  if (toolName === 'comment') return null;
 
  if (!anchorElement) return null;
 
  return (
    <FloatingPopover open={open} onOpenChange={setOpen} modal={false}>
      <FloatingPopoverAnchor element={anchorElement} />
 
      <FloatingPopoverContent
        className="w-(--floating-popover-anchor-width) max-w-[calc(100vw-16px)] border-none bg-transparent p-0 shadow-none ring-0"
        onEscapeKeyDown={(e) => {
          e.preventDefault();
 
          api.hide();
        }}
        align="center"
        side="bottom"
      >
        <Command
          className="w-full rounded-lg border shadow-md"
          value={value}
          onValueChange={setValue}
        >
          {mode === 'chat' && previewValue.length > 0 && <AIChatEditor />}
 
          {isLoading ? (
            <div className="flex grow items-center gap-2 p-2 text-sm text-muted-foreground select-none">
              <Loader2Icon className="size-4 animate-spin" />
              {(messages?.length ?? 0) > 1 ? 'Editing...' : 'Thinking...'}
            </div>
          ) : (
            <CommandPrimitive.Input
              className={cn(
                'flex h-9 w-full min-w-0 border-input bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground md:text-sm dark:bg-input/30',
                'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
                'border-b focus-visible:ring-transparent'
              )}
              value={input}
              onKeyDown={(e) => {
                if (isHotkey('backspace')(e) && input.length === 0) {
                  e.preventDefault();
                  api.hide();
                }
                if (isHotkey('enter')(e) && !e.shiftKey && !value) {
                  e.preventDefault();
                  api.submit(input);
                  setInput('');
                }
              }}
              onValueChange={setInput}
              placeholder="Ask AI anything..."
              data-editor-keep-selection-visible
              autoFocus
            />
          )}
 
          {!isLoading && (
            <CommandList>
              <AIMenuItems
                input={input}
                setInput={setInput}
                setValue={setValue}
              />
            </CommandList>
          )}
        </Command>
      </FloatingPopoverContent>
    </FloatingPopover>
  );
}
 
type EditorChatState =
  | 'cursorCommand'
  | 'cursorSuggestion'
  | 'selectionCommand'
  | 'selectionSuggestion';
 
const AICommentIcon = () => (
  <svg
    fill="none"
    height="24"
    stroke="currentColor"
    strokeLinecap="round"
    strokeLinejoin="round"
    strokeWidth="2"
    viewBox="0 0 24 24"
    width="24"
    xmlns="http://www.w3.org/2000/svg"
  >
    <path d="M0 0h24v24H0z" fill="none" stroke="none" />
    <path d="M8 9h8" />
    <path d="M8 13h4.5" />
    <path d="M10 19l-1 -1h-3a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v4.5" />
    <path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138z" />
  </svg>
);
 
const aiChatItems = {
  accept: {
    icon: <Check />,
    label: 'Accept',
    value: 'accept',
    onSelect: ({ editor }) => {
      editor.plugin(AIChatPlugin).api.accept();
      editor.api.dom.focus();
    },
  },
  comment: {
    icon: <AICommentIcon />,
    label: 'Comment',
    value: 'comment',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        mode: 'insert',
        prompt:
          'Please comment on the following content and provide reasonable and meaningful feedback.',
        toolName: 'comment',
      });
    },
  },
  continueWrite: {
    icon: <PenLine />,
    label: 'Continue writing',
    value: 'continueWrite',
    onSelect: ({ editor, input }) => {
      const ancestorNode = editor.read.nodes.block();
 
      if (!ancestorNode) return;
 
      const isEmpty = NodeApi.string(ancestorNode[0]).trim().length === 0;
 
      editor.plugin(AIChatPlugin).api.submit(input, {
        mode: 'insert',
        prompt: isEmpty
          ? `<Document>
{editor}
</Document>
Start writing a new paragraph AFTER <Document> ONLY ONE SENTENCE`
          : `<Block>
{block}
</Block>
Continue writing AFTER <Block> with ONLY ONE SENTENCE. DO NOT REPEAT THE TEXT.`,
        toolName: 'generate',
      });
    },
  },
  discard: {
    icon: <X />,
    label: 'Discard',
    shortcut: 'Escape',
    value: 'discard',
    onSelect: ({ editor }) => {
      editor.plugin(AIChatPlugin).api.hide();
    },
  },
  emojify: {
    icon: <SmileIcon />,
    label: 'Emojify',
    value: 'emojify',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Add a small number of contextually relevant emojis within each block only. You may insert emojis, but do not remove, replace, or rewrite existing text, and do not modify Markdown syntax, links, or line breaks.',
        toolName: 'edit',
      });
    },
  },
  explain: {
    icon: <BadgeHelp />,
    label: 'Explain',
    value: 'explain',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt: {
          default: 'Explain {editor}',
          selecting: 'Explain',
        },
        toolName: 'generate',
      });
    },
  },
  fixSpelling: {
    icon: <Check />,
    label: 'Fix spelling & grammar',
    value: 'fixSpelling',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Fix spelling, grammar, and punctuation errors within each block only, without changing meaning, tone, or adding new information.',
        toolName: 'edit',
      });
    },
  },
  generateMarkdownSample: {
    icon: <BookOpenCheck />,
    label: 'Generate Markdown sample',
    value: 'generateMarkdownSample',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt: 'Generate a markdown sample',
        toolName: 'generate',
      });
    },
  },
  generateMdxSample: {
    icon: <BookOpenCheck />,
    label: 'Generate MDX sample',
    value: 'generateMdxSample',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt: 'Generate a mdx sample',
        toolName: 'generate',
      });
    },
  },
  improveWriting: {
    icon: <Wand />,
    label: 'Improve writing',
    value: 'improveWriting',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Improve the writing for clarity and flow, without changing meaning or adding new information.',
        toolName: 'edit',
      });
    },
  },
  insertBelow: {
    icon: <ListEnd />,
    label: 'Insert below',
    value: 'insertBelow',
    onSelect: ({ editor }) => {
      /** Format: 'none' Fix insert table */
      editor.plugin(AIChatPlugin).api.insertBelow({ format: 'none' });
      editor.api.dom.focus();
    },
  },
  makeLonger: {
    icon: <ListPlus />,
    label: 'Make longer',
    value: 'makeLonger',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Make the content longer by elaborating on existing ideas within each block only, without changing meaning or adding new information.',
        toolName: 'edit',
      });
    },
  },
  makeShorter: {
    icon: <ListMinus />,
    label: 'Make shorter',
    value: 'makeShorter',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Make the content shorter by reducing verbosity within each block only, without changing meaning or removing essential information.',
        toolName: 'edit',
      });
    },
  },
  replace: {
    icon: <Check />,
    label: 'Replace selection',
    value: 'replace',
    onSelect: ({ editor }) => {
      editor.plugin(AIChatPlugin).api.replaceSelection();
      editor.api.dom.focus();
    },
  },
  simplifyLanguage: {
    icon: <FeatherIcon />,
    label: 'Simplify language',
    value: 'simplifyLanguage',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Simplify the language by using clearer and more straightforward wording within each block only, without changing meaning or adding new information.',
        toolName: 'edit',
      });
    },
  },
  summarize: {
    icon: <Album />,
    label: 'Add a summary',
    value: 'summarize',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        mode: 'insert',
        prompt: {
          default: 'Summarize {editor}',
          selecting: 'Summarize',
        },
        toolName: 'generate',
      });
    },
  },
  tryAgain: {
    icon: <CornerUpLeft />,
    label: 'Try again',
    value: 'tryAgain',
    onSelect: ({ editor }) => {
      editor.plugin(AIChatPlugin).api.reload();
    },
  },
} satisfies Record<
  string,
  {
    icon: React.ReactNode;
    label: string;
    value: string;
    component?: React.ComponentType<{ menuState: EditorChatState }>;
    filterItems?: boolean;
    items?: Array<{ label: string; value: string }>;
    shortcut?: string;
    onSelect?: ({ editor, input }: { editor: Editor; input: string }) => void;
  }
>;
 
const menuStateItems: Record<
  EditorChatState,
  Array<{
    items: Array<(typeof aiChatItems)[keyof typeof aiChatItems]>;
    heading?: string;
  }>
> = {
  cursorCommand: [
    {
      items: [
        aiChatItems.comment,
        aiChatItems.generateMdxSample,
        aiChatItems.generateMarkdownSample,
        aiChatItems.continueWrite,
        aiChatItems.summarize,
        aiChatItems.explain,
      ],
    },
  ],
  cursorSuggestion: [
    {
      items: [aiChatItems.accept, aiChatItems.discard, aiChatItems.tryAgain],
    },
  ],
  selectionCommand: [
    {
      items: [
        aiChatItems.improveWriting,
        aiChatItems.comment,
        aiChatItems.emojify,
        aiChatItems.makeLonger,
        aiChatItems.makeShorter,
        aiChatItems.fixSpelling,
        aiChatItems.simplifyLanguage,
      ],
    },
  ],
  selectionSuggestion: [
    {
      items: [
        aiChatItems.accept,
        aiChatItems.discard,
        aiChatItems.insertBelow,
        aiChatItems.tryAgain,
      ],
    },
  ],
};
 
export const AIMenuItems = ({
  input,
  setInput,
  setValue,
}: {
  input: string;
  setInput: (value: string) => void;
  setValue: (value: string) => void;
}) => {
  const editor = useEditor();
  const comments = editor.plugin(CommentsPlugin);
  const messages = usePluginStore(AIChatPlugin, 'chat')?.messages;
  const mode = usePluginStore(AIChatPlugin, 'mode');
  const isSelecting = useEditorSelector(
    (innerEditor2) =>
      innerEditor2.read.selection.nodes().length > 0 ||
      innerEditor2.read.selection.isExpanded()
  );
 
  const menuState: EditorChatState =
    (messages?.length ?? 0) > 0
      ? mode === 'chat'
        ? 'selectionSuggestion'
        : 'cursorSuggestion'
      : isSelecting
        ? 'selectionCommand'
        : 'cursorCommand';
  const menuGroups = comments.installed
    ? menuStateItems[menuState]
    : menuStateItems[menuState].map((group) => ({
        ...group,
        items: group.items.filter((item) => item !== aiChatItems.comment),
      }));
  const firstItemValue = menuGroups[0]?.items[0]?.value;
 
  React.useEffect(() => {
    if (firstItemValue) setValue(firstItemValue);
  }, [firstItemValue, setValue]);
 
  return (
    <>
      {menuGroups.map((group) => (
        <CommandGroup
          key={group.heading ?? group.items[0]?.value}
          heading={group.heading}
        >
          {group.items.map((menuItem) => (
            <CommandItem
              key={menuItem.value}
              className="[&_svg]:text-muted-foreground"
              value={menuItem.value}
              onSelect={() => {
                menuItem.onSelect?.({ editor, input });
                setInput('');
              }}
            >
              {menuItem.icon}
              <span>{menuItem.label}</span>
            </CommandItem>
          ))}
        </CommandGroup>
      ))}
    </>
  );
};
 
export function AILoadingBar() {
  const toolName = usePluginStore(AIChatPlugin, 'toolName');
  const chat = usePluginStore(AIChatPlugin, 'chat');
  const mode = usePluginStore(AIChatPlugin, 'mode');
 
  const status = chat?.status ?? 'ready';
 
  const { api } = useEditor().plugin(AIChatPlugin);
 
  const isLoading = status === 'streaming' || status === 'submitted';
 
  if (isLoading && (mode === 'insert' || toolName === 'comment')) {
    return (
      <div
        className={cn(
          'fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-md border border-border bg-muted px-3 py-1.5 text-muted-foreground text-sm shadow-md transition-all duration-300'
        )}
      >
        <span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
        <span>{status === 'submitted' ? 'Thinking...' : 'Writing...'}</span>
        <Button
          size="sm"
          variant="ghost"
          className="flex items-center gap-1 text-xs"
          onKeyDown={(event) => {
            if (event.key !== 'Escape' || event.nativeEvent.isComposing) return;
            event.preventDefault();
            event.stopPropagation();
            api.stop();
          }}
          onClick={() => {
            api.stop();
          }}
        >
          <PauseIcon className="h-4 w-4" />
          Stop
          <kbd className="ml-1 rounded bg-border px-1 font-mono text-[10px] text-muted-foreground shadow-sm">
            Esc
          </kbd>
        </Button>
      </div>
    );
  }
 
  if (toolName === 'comment' && status === 'error') {
    return (
      <div
        className="fixed bottom-4 left-1/2 z-50 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center gap-2 rounded-lg border bg-popover p-2 text-sm shadow-lg"
        data-ai-comment-error=""
        data-editor-keep-selection-visible
      >
        <p className="w-full px-1 text-destructive" role="alert">
          Could not generate comments.
        </p>
        <Button onClick={() => api.reload()} size="sm" variant="outline">
          Try again
        </Button>
        <Button onClick={() => api.hide()} size="sm" variant="outline">
          <X data-icon="inline-start" />
          Dismiss
        </Button>
      </div>
    );
  }
 
  return null;
}
'use client';
 
import { Command as CommandPrimitive } from 'cmdk';
import {
  Album,
  BadgeHelp,
  BookOpenCheck,
  Check,
  CornerUpLeft,
  FeatherIcon,
  ListEnd,
  ListMinus,
  ListPlus,
  Loader2Icon,
  PauseIcon,
  PenLine,
  SmileIcon,
  Wand,
  X,
} from 'lucide-react';
import {
  createEditorView,
  ElementApi,
  isHotkey,
  NodeApi,
  TextApi,
} from 'platejs';






















































































































































































































































































































































































































































































































































































































































































































































































Extend the aiChatItems map to add new commands. Each command receives { editor, input } and can call editor.plugin(AIChatPlugin).api.submit with custom prompts or transforms.

Simple custom command

import { AIChatPlugin } from 'platejs/ai/react';
import { ListIcon } from 'lucide-react';
 
export const aiChatItems = {
  summarizeInBullets: {
    icon: <ListIcon />,
    label: 'Summarize in bullets',
    value: 'summarizeInBullets',
    onSelect: ({ editor }) => {
      void editor.plugin(AIChatPlugin).api.submit('', {
        prompt: 'Summarize the current selection using bullet points',
        toolName: 'generate',
      });
    },
  },
};
import { AIChatPlugin } from 'platejs/ai/react';
import { ListIcon } from 'lucide-react';
 
export const aiChatItems = {
  summarizeInBullets: {
    icon: <ListIcon />,
    label: 'Summarize in bullets',
    value: 'summarizeInBullets',
    onSelect: ({ editor }) => {
      void editor.plugin(AIChatPlugin).api.submit('', {
        prompt: 'Summarize the current selection using bullet points',
        toolName: 'generate',
      });
    },
  },
};

Command with complex logic

import { AIChatPlugin } from 'platejs/ai/react';
import { BaseHeadingPlugin } from 'platejs';
import { BookIcon } from 'lucide-react';
 
export const aiChatItems = {
  generateTOC: {
    icon: <BookIcon />,
    label: 'Generate table of contents',
    value: 'generateTOC',
    onSelect: ({ editor }) => {
      const heading = editor.plugin(BaseHeadingPlugin);
      const headingTypes = heading.installed ? [heading.schema.type] : [];
      const headings = editor.read.nodes.toArray({
        at: [],
        type: headingTypes,
      });
 
      const prompt =
        headings.length === 0
          ? 'Create a realistic table of contents for this document'
          : 'Generate a table of contents that reflects the existing headings';
 
      void editor.plugin(AIChatPlugin).api.submit('', {
        mode: 'insert',
        prompt,
        toolName: 'generate',
      });
    },
  },
};
import { AIChatPlugin } from 'platejs/ai/react';
import { BaseHeadingPlugin } from 'platejs';
import { BookIcon } from 'lucide-react';
 
export const aiChatItems = {
  generateTOC: {
    icon: <BookIcon />,
    label: 'Generate table of contents',
    value: 'generateTOC',
    onSelect: ({ editor }) => {
      const heading = editor.plugin(BaseHeadingPlugin);
      const headingTypes = heading.installed ? [heading.schema.type] : [];
      const headings = editor.read.nodes.
















The command reads the installed H1-H3 capabilities. If the editor omits all three, it uses the fallback prompt instead of assuming persisted type strings.

The menu automatically switches between command and suggestion states:

  • cursorCommand: Cursor is collapsed and no response yet.
  • selectionCommand: Text is selected and no response yet.
  • cursorSuggestion / selectionSuggestion: A response exists, so actions like Accept, Try Again, or Insert Below are shown.

Use toolName ('generate' | 'edit' | 'comment') to control how streaming hooks process the response. Both 'generate' and 'edit' publish a draft; 'comment' can map feedback through aiChat.read.commentRange.

from
'./use-chat'
;
export function AILeaf(props: EditorTextProps<typeof AIPlugin>) {
return (
<EditorText
className={cn(
'border-b-2 border-b-purple-100 bg-purple-50 text-purple-800',
'transition-all duration-200 ease-in-out'
)}
{...props}
/>
);
}
function AIInlinePreview({
children,
editor,
element,
}: RenderNodeWrapperProps) {
const replacesEmptyParagraph =
element.type === editor.plugin(BaseParagraphPlugin).schema.type &&
editor.read.nodes.isEmpty(element);
return (
<div data-editor-ai-preview-wrapper="">
<div hidden={replacesEmptyParagraph}>{children}</div>
<div contentEditable={false} data-editor-ai-preview="">
<AIChatEditor inline />
</div>
</div>
);
}
export const AIKit = [
AIPlugin.configure({ component: AILeaf }),
AIChatTransportPlugin.extend(({ api, store }) => ({
render: {
useViewElementAttributes() {
const key = usePluginStore(AIChatPlugin, (state) =>
state.mode === 'insert' && state.previewValue.length > 0
? state._blockKey
: null
);
return key
? [{ key, attributes: { 'data-editor-ai-preview-anchor': '' } }]
: [];
},
},
slots: {
wrapNode: {
component: AIInlinePreview,
match: ({ editor, element }) => {
const state = store.get();
return (
state.mode === 'insert' &&
state.previewValue.length > 0 &&
editor.key(element) === state._blockKey
);
},
},
afterContainer: AILoadingBar,
afterEditable: AIMenu,
// oxlint-disable-next-line eslint/func-name-matching -- Hooks require a named React component in this slot.
wrapRoot: function AIIntegration({ children, editableRef }) {
useEditorChat(editableRef);
return children;
},
},
shortcuts: {
show: {
keys: 'mod+j',
handler: ({ editor }) => {
editor.plugin(AIChatPlugin).api.show();
},
},
stop: {
keys: 'escape',
handler: () => {
const status = store.get().chat?.status;
if (status !== 'streaming' && status !== 'submitted') return false;
api.stop();
return true;
},
},
},
})),
];
plugins: [
// ...otherPlugins,
...AIKit,
],
});
import { EditorRoot, useCreateEditor } from 'platejs/react';
import { AIKit } from '@/components/editor/ai';
import { Editor, EditorContainer } from '@/components/editor/editor';
 
export function AIEditor() {
  const editor = useCreateEditor({ plugins: AIKit });
 
  return (
    <EditorRoot editor={editor}>
      <EditorContainer>
        <Editor />
      </EditorContainer>
    </EditorRoot>
  );
}
'platejs/ai'
;
import type { MarkdownEditor } from 'platejs/markdown';
import { z } from 'zod';
import { BaseEditorKit } from '@/components/editor/plugins-static';
import type {
ChatMessage,
ToolName,
} from '@/components/editor/use-chat';
import { markdownJoinerTransform } from '@/lib/markdown-joiner-transform';
import { getChooseToolPrompt } from './prompt/getChooseToolPrompt';
import { getCommentPrompt } from './prompt/getCommentPrompt';
import { getEditPrompt } from './prompt/getEditPrompt';
import { buildEditTableMultiCellPrompt } from './prompt/getEditTablePrompt';
import { getGeneratePrompt } from './prompt/getGeneratePrompt';
const toolNameSchema = z.enum(['comment', 'edit', 'generate']);
export async function POST(req: NextRequest) {
const { apiKey: key, ctx, messages: messagesRaw, model } = await req.json();
const {
children,
nodeSelection,
refs,
selection,
toolName: toolNameParam,
} = ctx as AIChatRequestContext;
const request = resolveAIChatRequestContext({ nodeSelection, selection });
const { isSelecting } = request;
const editor = createEditor({
plugins: BaseEditorKit,
selection: request.selection,
initialValue: children,
});
const apiKey = key || process.env.AI_GATEWAY_API_KEY;
if (!apiKey) {
return NextResponse.json(
{ error: 'Missing AI Gateway API key.' },
{ status: 401 }
);
}
const gatewayProvider = createGateway({
apiKey,
});
try {
const stream = createUIMessageStream<ChatMessage>({
execute: async ({ writer }) => {
let toolName = toolNameParam;
if (!toolName) {
const prompt = getChooseToolPrompt({
isSelecting,
messages: messagesRaw,
});
const enumOptions: ToolName[] = isSelecting
? ['generate', 'edit', 'comment']
: ['generate', 'comment'];
const modelId = model || 'google/gemini-2.5-flash';
const { output } = await generateText({
model: gatewayProvider(modelId),
output: Output.choice({ options: enumOptions }),
prompt,
});
const selectedToolName = toolNameSchema.parse(output);
writer.write({
data: selectedToolName,
type: 'data-toolName',
});
toolName = selectedToolName;
}
const innerStream = streamText({
experimental_transform: markdownJoinerTransform(),
model: gatewayProvider(model || 'openai/gpt-4o-mini'),
// Not used
prompt: '',
tools: {
comment: getCommentTool(editor, {
messagesRaw,
model: gatewayProvider(model || 'google/gemini-2.5-flash'),
refs: refs.blocks,
writer,
}),
table: getTableTool(editor, {
messagesRaw,
model: gatewayProvider(model || 'google/gemini-2.5-flash'),
refs: refs.tableCells,
writer,
}),
},
prepareStep: (step) => {
if (toolName === 'comment') {
// The selection task is more challenging, so use Gemini 2.5 Flash.
return {
...step,
toolChoice: { toolName: 'comment', type: 'tool' },
};
}
if (toolName === 'edit') {
const [editPrompt, editType] = getEditPrompt(editor, {
isSelecting,
messages: messagesRaw,
tableCellRefs: refs.tableCells,
});
// Table editing uses the table tool
if (editType === 'table') {
return {
...step,
toolChoice: { toolName: 'table', type: 'tool' },
};
}
return {
...step,
activeTools: [],
model:
editType === 'selection'
? gatewayProvider(model || 'google/gemini-2.5-flash')
: gatewayProvider(model || 'openai/gpt-4o-mini'),
messages: [
{
content: editPrompt,
role: 'user',
},
],
};
}
if (toolName === 'generate') {
const generatePrompt = getGeneratePrompt(editor, {
isSelecting,
messages: messagesRaw,
});
return {
...step,
activeTools: [],
messages: [
{
content: generatePrompt,
role: 'user',
},
],
model: gatewayProvider(model || 'openai/gpt-4o-mini'),
};
}
return undefined;
},
});
writer.merge(innerStream.toUIMessageStream({ sendFinish: false }));
},
});
return createUIMessageStreamResponse({ stream });
} catch {
return NextResponse.json(
{ error: 'Failed to process AI request' },
{ status: 500 }
);
}
}
const getCommentTool = (
editor: MarkdownEditor,
{
messagesRaw,
model,
refs,
writer,
}: {
messagesRaw: ChatMessage[];
model: LanguageModel;
refs: AIChatRequestRefs['blocks'];
writer: UIMessageStreamWriter<ChatMessage>;
}
) =>
tool({
description: 'Comment on the content',
inputSchema: z.object({}),
strict: true,
execute: async () => {
const commentSchema = z.object({
blockRef: z
.string()
.describe(
'The request-local reference of the starting block. If the comment spans multiple blocks, use the reference of the first block.'
),
comment: z
.string()
.describe('A brief comment or explanation for this fragment.'),
content: z
.string()
.describe(
String.raw`The original document fragment to be commented on.It can be the entire block, a small part within a block, or span multiple blocks. If spanning multiple blocks, separate them with two \n\n.`
),
});
const { partialOutputStream } = streamText({
model,
output: Output.array({ element: commentSchema }),
prompt: getCommentPrompt(editor, {
messages: messagesRaw,
refs,
}),
});
let lastLength = 0;
for await (const partialArray of partialOutputStream) {
for (let i = lastLength; i < partialArray.length; i++) {
const comment = partialArray[i];
const commentDataId = nanoid();
writer.write({
id: commentDataId,
data: {
comment,
status: 'streaming',
},
type: 'data-comment',
});
}
lastLength = partialArray.length;
}
writer.write({
id: nanoid(),
data: {
comment: null,
status: 'finished',
},
type: 'data-comment',
});
},
});
const getTableTool = (
editor: MarkdownEditor,
{
messagesRaw,
model,
refs,
writer,
}: {
messagesRaw: ChatMessage[];
model: LanguageModel;
refs: AIChatRequestRefs['tableCells'];
writer: UIMessageStreamWriter<ChatMessage>;
}
) =>
tool({
description: 'Edit table cells',
inputSchema: z.object({}),
strict: true,
execute: async () => {
const cellUpdateSchema = z.object({
content: z
.string()
.describe(
String.raw`The new content for the cell. Can contain multiple paragraphs separated by \n\n.`
),
ref: z
.string()
.describe('The request-local reference of the table cell to update.'),
});
const { partialOutputStream } = streamText({
model,
output: Output.array({ element: cellUpdateSchema }),
prompt: buildEditTableMultiCellPrompt(editor, messagesRaw, refs),
});
let lastLength = 0;
for await (const partialArray of partialOutputStream) {
for (let i = lastLength; i < partialArray.length; i++) {
const cellUpdate = partialArray[i];
writer.write({
id: nanoid(),
data: {
cellUpdate,
status: 'streaming',
},
type: 'data-table',
});
}
lastLength = partialArray.length;
}
writer.write({
id: nanoid(),
data: {
cellUpdate: null,
status: 'finished',
},
type: 'data-table',
});
},
});
import { createGateway } from '@ai-sdk/gateway';
import {
  type LanguageModel,
  type UIMessageStreamWriter,
  createUIMessageStream,
  createUIMessageStreamResponse,
  generateText,
  Output,
  streamText,
  tool,
} from 'ai';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { createEditor, nanoid } from 'platejs';
import {
  type AIChatRequestContext,
  type AIChatRequestRefs,
  resolveAIChatRequestContext,
} from 'platejs/ai';
import type { MarkdownEditor } from 'platejs/markdown';
import { z } from 'zod';
 
import { BaseEditorKit } from '@/components/editor/plugins-static';
import type {
  ChatMessage,
  ToolName,
} from '@/components/editor/use-chat';
import { markdownJoinerTransform } from '@/lib/markdown-joiner-transform';
 
import { getChooseToolPrompt } from './prompt/getChooseToolPrompt';
import { getCommentPrompt } from './prompt/getCommentPrompt';
import { getEditPrompt } from './prompt/getEditPrompt';
import { buildEditTableMultiCellPrompt } from './prompt/getEditTablePrompt';
import { getGeneratePrompt } from './prompt/getGeneratePrompt';
 
const toolNameSchema = z.enum(['comment', 'edit', 'generate']);
 
export async function POST(req: NextRequest) {
  const { apiKey: key, ctx, messages: messagesRaw, model } = await req.json();
 
  const {
    children,
    nodeSelection,
    refs,
    selection,
    toolName: toolNameParam,
  } = ctx as AIChatRequestContext;
  const request = resolveAIChatRequestContext({ nodeSelection, selection });
  const { isSelecting } = request;
 
  const editor = createEditor({
    plugins: BaseEditorKit,
    selection: request.selection,
    initialValue: children,
  });
 
  const apiKey = key || process.env.AI_GATEWAY_API_KEY;
 
  if (!apiKey) {
    return NextResponse.json(
      { error: 'Missing AI Gateway API key.' },
      { status: 401 }
    );
  }
 
  const gatewayProvider = createGateway({
    apiKey,
  });
 
  try {
    const stream = createUIMessageStream<ChatMessage>({
      execute: async ({ writer }) => {
        let toolName = toolNameParam;
 
        if (!toolName) {
          const prompt = getChooseToolPrompt({
            isSelecting,
            messages: messagesRaw,
          });
 
          const enumOptions: ToolName[] = isSelecting
            ? ['generate', 'edit', 'comment']
            : ['generate', 'comment'];
          const modelId = model || 'google/gemini-2.5-flash';
 
          const { output } = await generateText({
            model: gatewayProvider(modelId),
            output: Output.choice({ options: enumOptions }),
            prompt,
          });
          const selectedToolName = toolNameSchema.parse(output);
 
          writer.write({
            data: selectedToolName,
            type: 'data-toolName',
          });
 
          toolName = selectedToolName;
        }
 
        const innerStream = streamText({
          experimental_transform: markdownJoinerTransform(),
          model: gatewayProvider(model || 'openai/gpt-4o-mini'),
          // Not used
          prompt: '',
          tools: {
            comment: getCommentTool(editor, {
              messagesRaw,
              model: gatewayProvider(model || 'google/gemini-2.5-flash'),
              refs: refs.blocks,
              writer,
            }),
            table: getTableTool(editor, {
              messagesRaw,
              model: gatewayProvider(model || 'google/gemini-2.5-flash'),
              refs: refs.tableCells,
              writer,
            }),
          },
          prepareStep: (step) => {
            if (toolName === 'comment') {
              // The selection task is more challenging, so use Gemini 2.5 Flash.
              return {
                ...step,
                toolChoice: { toolName: 'comment', type: 'tool' },
              };
            }
 
            if (toolName === 'edit') {
              const [editPrompt, editType] = getEditPrompt(editor, {
                isSelecting,
                messages: messagesRaw,
                tableCellRefs: refs.tableCells,
              });
 
              // Table editing uses the table tool
              if (editType === 'table') {
                return {
                  ...step,
                  toolChoice: { toolName: 'table', type: 'tool' },
                };
              }
 
              return {
                ...step,
                activeTools: [],
                model:
                  editType === 'selection'
                    ? gatewayProvider(model || 'google/gemini-2.5-flash')
                    : gatewayProvider(model || 'openai/gpt-4o-mini'),
                messages: [
                  {
                    content: editPrompt,
                    role: 'user',
                  },
                ],
              };
            }
 
            if (toolName === 'generate') {
              const generatePrompt = getGeneratePrompt(editor, {
                isSelecting,
                messages: messagesRaw,
              });
 
              return {
                ...step,
                activeTools: [],
                messages: [
                  {
                    content: generatePrompt,
                    role: 'user',
                  },
                ],
                model: gatewayProvider(model || 'openai/gpt-4o-mini'),
              };
            }
 
            return undefined;
          },
        });
 
        writer.merge(innerStream.toUIMessageStream({ sendFinish: false }));
      },
    });
 
    return createUIMessageStreamResponse({ stream });
  } catch {
    return NextResponse.json(
      { error: 'Failed to process AI request' },
      { status: 500 }
    );
  }
}
 
const getCommentTool = (
  editor: MarkdownEditor,
  {
    messagesRaw,
    model,
    refs,
    writer,
  }: {
    messagesRaw: ChatMessage[];
    model: LanguageModel;
    refs: AIChatRequestRefs['blocks'];
    writer: UIMessageStreamWriter<ChatMessage>;
  }
) =>
  tool({
    description: 'Comment on the content',
    inputSchema: z.object({}),
    strict: true,
    execute: async () => {
      const commentSchema = z.object({
        blockRef: z
          .string()
          .describe(
            'The request-local reference of the starting block. If the comment spans multiple blocks, use the reference of the first block.'
          ),
        comment: z
          .string()
          .describe('A brief comment or explanation for this fragment.'),
        content: z
          .string()
          .describe(
            String.raw`The original document fragment to be commented on.It can be the entire block, a small part within a block, or span multiple blocks. If spanning multiple blocks, separate them with two \n\n.`
          ),
      });
 
      const { partialOutputStream } = streamText({
        model,
        output: Output.array({ element: commentSchema }),
        prompt: getCommentPrompt(editor, {
          messages: messagesRaw,
          refs,
        }),
      });
 
      let lastLength = 0;
 
      for await (const partialArray of partialOutputStream) {
        for (let i = lastLength; i < partialArray.length; i++) {
          const comment = partialArray[i];
          const commentDataId = nanoid();
 
          writer.write({
            id: commentDataId,
            data: {
              comment,
              status: 'streaming',
            },
            type: 'data-comment',
          });
        }
 
        lastLength = partialArray.length;
      }
 
      writer.write({
        id: nanoid(),
        data: {
          comment: null,
          status: 'finished',
        },
        type: 'data-comment',
      });
    },
  });
 
const getTableTool = (
  editor: MarkdownEditor,
  {
    messagesRaw,
    model,
    refs,
    writer,
  }: {
    messagesRaw: ChatMessage[];
    model: LanguageModel;
    refs: AIChatRequestRefs['tableCells'];
    writer: UIMessageStreamWriter<ChatMessage>;
  }
) =>
  tool({
    description: 'Edit table cells',
    inputSchema: z.object({}),
    strict: true,
    execute: async () => {
      const cellUpdateSchema = z.object({
        content: z
          .string()
          .describe(
            String.raw`The new content for the cell. Can contain multiple paragraphs separated by \n\n.`
          ),
        ref: z
          .string()
          .describe('The request-local reference of the table cell to update.'),
      });
 
      const { partialOutputStream } = streamText({
        model,
        output: Output.array({ element: cellUpdateSchema }),
        prompt: buildEditTableMultiCellPrompt(editor, messagesRaw, refs),
      });
 
      let lastLength = 0;
 
      for await (const partialArray of partialOutputStream) {
        for (let i = lastLength; i < partialArray.length; i++) {
          const cellUpdate = partialArray[i];
 
          writer.write({
            id: nanoid(),
            data: {
              cellUpdate,
              status: 'streaming',
            },
            type: 'data-table',
          });
        }
 
        lastLength = partialArray.length;
      }
 
      writer.write({
        id: nanoid(),
        data: {
          cellUpdate: null,
          status: 'finished',
        },
        type: 'data-table',
      });
    },
  });
},
}),
],
});
import { createEditor } from 'platejs/react';
import { AIKit } from '@/components/editor/ai';
import { AILoadingBar, AIMenu } from '@/components/editor/ai-menu';
import { AIChatTransportPlugin } from '@/components/editor/use-chat';
 
const editor = createEditor({
  plugins: [
    ...AIKit,
    AIChatTransportPlugin.configure({
      slots: {
        afterContainer: AILoadingBar,
        afterEditable: AIMenu,
      },
      initialState: {
        chatOptions: {
          api: '/api/ai/command',
          body: { model: 'openai/gpt-4o-mini' },
        },
      },
    }),
  ],
});
({
plugins: BaseEditorKit,
selection: ctx.selection,
initialValue: ctx.children,
});
const gateway = createGateway({
apiKey: apiKey ?? process.env.AI_GATEWAY_API_KEY,
});
const result = streamText({
experimental_transform: markdownJoinerTransform(),
messages: await convertToModelMessages(messages),
model: gateway(model ?? 'openai/gpt-4o-mini'),
system: ctx.toolName === 'edit' ? 'You are an editor that rewrites user text.' : undefined,
});
return result.toUIMessageStreamResponse();
}
app/api/ai/command/route.ts
import { createGateway } from '@ai-sdk/gateway';
import { convertToModelMessages, streamText } from 'ai';
import { createEditor } from 'platejs';
 
import { BaseEditorKit } from '@/registry/components/editor/plugins-static';
import { markdownJoinerTransform } from '@/registry/lib/markdown-joiner-transform';
 
export async function POST(req: Request) {
  const { apiKey, ctx, messages, model } = await req.json();
 
  const editor = createEditor({
    plugins: BaseEditorKit,
    selection: ctx.selection,
    initialValue: ctx.children,
  });
 
  const gateway = createGateway({
    apiKey: apiKey ?? process.env.AI_GATEWAY_API_KEY,
  });
 
  const result = streamText({
    experimental_transform: markdownJoinerTransform(),
    messages: await convertToModelMessages(messages),
    model: gateway(model ?? 'openai/gpt-4o-mini'),
    system: ctx.toolName === 'edit' ? 'You are an editor that rewrites user text.' : undefined,
  });
 
  return result.toUIMessageStreamResponse();
}
export
type
AIChatTransportPluginState
=
{
chatOptions: { api: string; body: Record<string, unknown> };
};
const initialState: AIChatTransportPluginState = {
chatOptions: { api: '/api/ai/command', body: {} },
};
export const AIChatTransportPlugin = AIChatPlugin.extend({
initialState,
}).extend(({ store }) => {
let api: string | undefined;
let transport: DefaultChatTransport<UIMessage> | undefined;
return {
api: () => ({
transport: () => {
const options = store.get('chatOptions');
if (!transport || options.api !== api) {
({ api } = options);
transport = new DefaultChatTransport<UIMessage>({
api,
body: () => store.get('chatOptions').body,
});
}
return transport;
},
}),
};
});
export type ToolName = 'comment' | 'edit' | 'generate';
export type TComment = {
comment: {
blockRef: string;
comment: string;
content: string;
} | null;
status: 'finished' | 'streaming';
};
export type TTableCellUpdate = {
cellUpdate: {
content: string;
ref: string;
} | null;
status: 'finished' | 'streaming';
};
export type MessageDataPart = {
toolName: ToolName;
comment: TComment;
table: TTableCellUpdate;
};
export type ChatMessage = UIMessage<unknown, MessageDataPart>;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;
const isStreamStatus = (value: unknown): value is 'finished' | 'streaming' =>
value === 'finished' || value === 'streaming';
const isComment = (value: unknown): value is TComment =>
isRecord(value) &&
isStreamStatus(value.status) &&
(value.comment === null ||
(isRecord(value.comment) &&
typeof value.comment.blockRef === 'string' &&
typeof value.comment.comment === 'string' &&
typeof value.comment.content === 'string'));
export function useEditorChat(
editableRef: React.RefObject<HTMLElement | null>
) {
const editor = useEditor();
const comments = editor.plugin(CommentsPlugin);
usePluginStore(AIChatTransportPlugin, 'chatOptions');
const chat = usePluginStore(AIChatPlugin, 'chat');
const toolName = usePluginStore(AIChatPlugin, 'toolName');
const transport = editor.plugin(AIChatTransportPlugin).api.transport();
const markdownApi = editor.plugin(MarkdownPlugin).api;
React.useEffect(() => {
if (
toolName === 'comment' &&
chat?.status === 'ready' &&
chat.messages.length > 0
) {
// Completed streams can still have comment saves awaiting persistence.
editor.plugin(AIChatPlugin).store.set({ open: false });
}
}, [chat?.messages.length, chat?.status, editor, toolName]);
useAIChat({
editableRef,
transport,
onData: (data, signal) => {
if (data.type === 'data-comment' && isComment(data.data)) {
const commentData = data.data;
if (commentData.status === 'finished') {
editor.update.selection.set(null);
return;
}
const aiComment = commentData.comment;
if (aiComment == null) {
throw new Error('Streaming comment data requires a comment');
}
const range = editor.plugin(AIChatPlugin).read.commentRange(aiComment);
if (!range) {
console.warn('No range found for AI comment');
return;
}
if (!comments.installed) {
console.warn('AI comments require CommentsPlugin');
return;
}
void (async () => {
if (signal.aborted) return;
let id: string | null = null;
const discard = () => {
if (id) comments.api.discardDraft(id);
};
try {
id = comments.api.createDraft({
target: { range, type: 'range' },
body: createCommentValue(aiComment.comment),
excerpt: markdownApi
.deserialize(aiComment.content)
.children.map((node) => NodeApi.string(node))
.join('\n'),
});
if (!id) {
console.warn('Could not create AI comment');
toast.error(
'Could not save AI comment. Try generating comments again.'
);
return;
}
signal.addEventListener('abort', discard, { once: true });
if (signal.aborted) {
discard();
return;
}
const result = await comments.api.publishDraft(id);
if (signal.aborted) return;
if (result.status !== 'applied') {
discard();
console.warn('Could not publish AI comment');
toast.error(
'Could not save AI comment. Try generating comments again.'
);
return;
}
comments.api.setActive([id]);
} catch (error) {
discard();
// The SDK does not await onData promises, so rejection must terminate here.
if (!signal.aborted) {
console.warn('Could not publish AI comment', error);
toast.error(
'Could not save AI comment. Try generating comments again.'
);
}
} finally {
signal.removeEventListener('abort', discard);
}
})();
}
},
});
}
'use client';
 
import { type UIMessage, DefaultChatTransport } from 'ai';
import { NodeApi } from 'platejs';
import { AIChatPlugin, useAIChat } from 'platejs/ai/react';
import { CommentsPlugin } from 'platejs/comments/react';
import { MarkdownPlugin } from 'platejs/markdown';
import { useEditor, usePluginStore } from 'platejs/react';
import * as React from 'react';
import { toast } from 'sonner';
 
import { createCommentValue } from '@/components/editor/comment';
 
export type AIChatTransportPluginState = {
  chatOptions: { api: string; body: Record<string, unknown> };
};
const initialState: AIChatTransportPluginState = {
  chatOptions: { api: '/api/ai/command', body: {} },
};
 
export const AIChatTransportPlugin = AIChatPlugin.extend({
  initialState,
}).extend(({ store }) => {
  let api: string | undefined;
  let transport: DefaultChatTransport<UIMessage> | undefined;
 
  return {
    api: () => ({
      transport: () => {
        const options = store.get('chatOptions');
        if (!transport || options.api !== api) {
          ({ api } = options);
          transport = new DefaultChatTransport<UIMessage>({
            api,
            body: () => store.get('chatOptions').body,
          });
        }
        return transport;
      },
    }),
  };
});
 
export type ToolName = 'comment' | 'edit' | 'generate';
 
export type TComment = {
  comment: {
    blockRef: string;
    comment: string;
    content: string;
  } | null;
  status: 'finished' | 'streaming';
};
 
export type TTableCellUpdate = {
  cellUpdate: {
    content: string;
    ref: string;
  } | null;
  status: 'finished' | 'streaming';
};
 
export type MessageDataPart = {
  toolName: ToolName;
  comment: TComment;
  table: TTableCellUpdate;
};
 
export type ChatMessage = UIMessage<unknown, MessageDataPart>;
 
const isRecord = (value: unknown): value is Record<string, unknown> =>
  typeof value === 'object' && value !== null;
const isStreamStatus = (value: unknown): value is 'finished' | 'streaming' =>
  value === 'finished' || value === 'streaming';
const isComment = (value: unknown): value is TComment =>
  isRecord(value) &&
  isStreamStatus(value.status) &&
  (value.comment === null ||
    (isRecord(value.comment) &&
      typeof value.comment.blockRef === 'string' &&
      typeof value.comment.comment === 'string' &&
      typeof value.comment.content === 'string'));
 
export function useEditorChat(
  editableRef: React.RefObject<HTMLElement | null>
) {
  const editor = useEditor();
  const comments = editor.plugin(CommentsPlugin);
  usePluginStore(AIChatTransportPlugin, 'chatOptions');
  const chat = usePluginStore(AIChatPlugin, 'chat');
  const toolName = usePluginStore(AIChatPlugin, 'toolName');
  const transport = editor.plugin(AIChatTransportPlugin).api.transport();
  const markdownApi = editor.plugin(MarkdownPlugin).api;
 
  React.useEffect(() => {
    if (
      toolName === 'comment' &&
      chat?.status === 'ready' &&
      chat.messages.length > 0
    ) {
      // Completed streams can still have comment saves awaiting persistence.
      editor.plugin(AIChatPlugin).store.set({ open: false });
    }
  }, [chat?.messages.length, chat?.status, editor, toolName]);
 
  useAIChat({
    editableRef,
    transport,
    onData: (data, signal) => {
      if (data.type === 'data-comment' && isComment(data.data)) {
        const commentData = data.data;
 
        if (commentData.status === 'finished') {
          editor.update.selection.set(null);
 
          return;
        }
 
        const aiComment = commentData.comment;
 
        if (aiComment == null) {
          throw new Error('Streaming comment data requires a comment');
        }
 
        const range = editor.plugin(AIChatPlugin).read.commentRange(aiComment);
 
        if (!range) {
          console.warn('No range found for AI comment');
          return;
        }
 
        if (!comments.installed) {
          console.warn('AI comments require CommentsPlugin');
          return;
        }
 
        void (async () => {
          if (signal.aborted) return;
          let id: string | null = null;
          const discard = () => {
            if (id) comments.api.discardDraft(id);
          };
          try {
            id = comments.api.createDraft({
              target: { range, type: 'range' },
              body: createCommentValue(aiComment.comment),
              excerpt: markdownApi
                .deserialize(aiComment.content)
                .children.map((node) => NodeApi.string(node))
                .join('\n'),
            });
            if (!id) {
              console.warn('Could not create AI comment');
              toast.error(
                'Could not save AI comment. Try generating comments again.'
              );
              return;
            }
            signal.addEventListener('abort', discard, { once: true });
            if (signal.aborted) {
              discard();
              return;
            }
            const result = await comments.api.publishDraft(id);
            if (signal.aborted) return;
            if (result.status !== 'applied') {
              discard();
              console.warn('Could not publish AI comment');
              toast.error(
                'Could not save AI comment. Try generating comments again.'
              );
              return;
            }
            comments.api.setActive([id]);
          } catch (error) {
            discard();
            // The SDK does not await onData promises, so rejection must terminate here.
            if (!signal.aborted) {
              console.warn('Could not publish AI comment', error);
              toast.error(
                'Could not save AI comment. Try generating comments again.'
              );
            }
          } finally {
            signal.removeEventListener('abort', discard);
          }
        })();
      }
    },
  });
}
'platejs'
;
import { BaseAIPlugin } from 'platejs/ai';
import { AIChatPlugin } from 'platejs/ai/react';
import { CommentsPlugin } from 'platejs/comments/react';
import {
useEditorRuntimeState,
useCreateEditor,
useEditorSelector,
useFocusedLast,
usePluginStore,
type Editor,
useEditor,
} from 'platejs/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { cn } from '@/lib/utils';
import {
FloatingPopover,
FloatingPopoverAnchor,
FloatingPopoverContent,
} from '@/components/editor/floating-popover';
import { BaseEditorKit } from '@/components/editor/plugins-static';
import { EditorStatic } from './editor-static';
const PreviewAIPlugin = BaseAIPlugin.extend(({ editor }) => ({
decorate: {
read: ({ entry: [node, path] }) => {
if (!TextApi.isText(node) || node.text.length === 0) return [];
return [
{
key: 'ai-preview',
range: {
anchor: { path, offset: 0 },
focus: { path, offset: node.text.length },
},
attributes: {
className:
'border-b-2 border-b-purple-100 bg-purple-50 text-purple-800',
'data-editor-ai-end':
NodeApi.last(
{ children: editor.read.children(), type: '' },
[]
)[0] === node
? ''
: undefined,
},
},
];
},
},
}));
const scrollAIPreviewEnd = (editor: Editor, draft: HTMLElement | null) => {
const scrollElement = editor.api.dom.scroll();
const target = draft?.querySelector<HTMLElement>('[data-editor-ai-end]');
if (!scrollElement || !target) return;
const scrollBounds = scrollElement.getBoundingClientRect();
const targetBounds = target.getBoundingClientRect();
scrollElement.scrollTop +=
targetBounds.top +
targetBounds.height / 2 -
(scrollBounds.top + scrollBounds.height / 2);
};
export function AIChatEditor({ inline = false }: { inline?: boolean }) {
const editor = useEditor();
const draftRef = React.useRef<HTMLDivElement>(null);
const aiEditor = useCreateEditor({
plugins: [...BaseEditorKit, PreviewAIPlugin],
});
const document = usePluginStore(AIChatPlugin, 'previewValue');
const streaming = usePluginStore(AIChatPlugin, 'streaming');
const preview = useEditorRuntimeState(
aiEditor,
React.useCallback(
() => createEditorView(aiEditor, { readOnly: true }),
[aiEditor]
)
);
React.useLayoutEffect(() => {
aiEditor.update({ history: 'skip' }).value.replace({ children: document });
}, [aiEditor, document]);
React.useEffect(() => {
if (!inline) return;
scrollAIPreviewEnd(editor, draftRef.current);
}, [editor, inline, preview]);
React.useEffect(() => {
const draft = draftRef.current;
const Observer = draft?.ownerDocument.defaultView?.ResizeObserver;
if (!inline || !draft || !Observer) return undefined;
const observer = new Observer(() => scrollAIPreviewEnd(editor, draft));
observer.observe(draft);
return () => observer.disconnect();
}, [editor, inline]);
const last =
document.length > 0
? NodeApi.last({ children: document, type: '' }, [])[0]
: null;
return (
<div ref={draftRef} data-editor-ai-draft="">
<EditorStatic
variant={inline ? 'none' : 'aiChat'}
editor={preview}
className={cn(
streaming &&
'[&_[data-editor-ai-end]]:after:ml-1.5 [&_[data-editor-ai-end]]:after:inline-block [&_[data-editor-ai-end]]:after:size-3 [&_[data-editor-ai-end]]:after:rounded-full [&_[data-editor-ai-end]]:after:bg-purple-600 [&_[data-editor-ai-end]]:after:align-middle [&_[data-editor-ai-end]]:after:content-[""]'
)}
/>
{streaming && (!last || !TextApi.isText(last) || !last.text) && (
<span
data-editor-ai-end=""
className="inline-block size-3 rounded-full bg-purple-600 align-middle"
/>
)}
</div>
);
}
export function AIMenu() {
const editor = useEditor();
const { api, read } = useEditor().plugin(AIChatPlugin);
const mode = usePluginStore(AIChatPlugin, 'mode');
const toolName = usePluginStore(AIChatPlugin, 'toolName');
const streaming = usePluginStore(AIChatPlugin, 'streaming');
const editAnchorKey = useEditorSelector((innerEditor) => {
const entry = innerEditor.read.selection.nodes().at(-1);
return entry && ElementApi.isElement(entry[0])
? innerEditor.key(entry[0])
: null;
});
const isFocusedLast = useFocusedLast();
const chatOpen = usePluginStore(AIChatPlugin, 'open');
const open = chatOpen && isFocusedLast;
const [value, setValue] = React.useState('');
const [input, setInput] = React.useState('');
const chat = usePluginStore(AIChatPlugin, 'chat');
const previewValue = usePluginStore(AIChatPlugin, 'previewValue');
const messages = chat?.messages;
const status = chat?.status ?? 'ready';
const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(
null
);
React.useEffect(() => {
if (!streaming && previewValue.length === 0) return undefined;
const anchorEntry = read.node();
if (!anchorEntry) return undefined;
const anchorDom = editor.api.dom.resolveDOMNode(anchorEntry[0]);
if (!anchorDom) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(
anchorDom.closest<HTMLElement>('[data-editor-ai-preview-wrapper]') ??
anchorDom
);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [editor, previewValue, read, streaming]);
const setOpen = (innerOpen: boolean) => {
if (innerOpen) {
if (!chatOpen) api.show();
} else if (chatOpen) {
api.hide({ focus: false });
}
};
React.useEffect(() => {
if (!chatOpen) {
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(null);
setInput('');
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}
let nextAnchor: HTMLElement | null = null;
const block =
editor.read.nodes.blocks().at(-1) ?? editor.read.nodes.block();
if (block && ElementApi.isElement(block[0])) {
nextAnchor = editor.api.dom.resolveDOMNode(block[0]);
}
if (!nextAnchor) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(nextAnchor);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [chatOpen, editor]);
const isLoading = status === 'streaming' || status === 'submitted';
React.useEffect(() => {
if (toolName !== 'edit' || mode !== 'chat' || isLoading) return undefined;
let anchorNode = editAnchorKey
? editor.read.nodes.get(editAnchorKey, {
match: ElementApi.isElement,
})
: undefined;
if (!anchorNode) {
anchorNode =
editor.read.nodes.blocks().at(-1) ?? editor.read.nodes.block();
}
if (!anchorNode) return undefined;
const block = editor.read.nodes.block({ at: anchorNode[1] });
const domNode = block ? editor.api.dom.resolveDOMNode(block[0]) : null;
if (!domNode) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(domNode);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [editAnchorKey, editor, isLoading, mode, toolName]);
if (isLoading && mode === 'insert') return null;
if (toolName === 'comment') return null;
if (!anchorElement) return null;
return (
<FloatingPopover open={open} onOpenChange={setOpen} modal={false}>
<FloatingPopoverAnchor element={anchorElement} />
<FloatingPopoverContent
className="w-(--floating-popover-anchor-width) max-w-[calc(100vw-16px)] border-none bg-transparent p-0 shadow-none ring-0"
onEscapeKeyDown={(e) => {
e.preventDefault();
api.hide();
}}
align="center"
side="bottom"
>
<Command
className="w-full rounded-lg border shadow-md"
value={value}
onValueChange={setValue}
>
{mode === 'chat' && previewValue.length > 0 && <AIChatEditor />}
{isLoading ? (
<div className="flex grow items-center gap-2 p-2 text-sm text-muted-foreground select-none">
<Loader2Icon className="size-4 animate-spin" />
{(messages?.length ?? 0) > 1 ? 'Editing...' : 'Thinking...'}
</div>
) : (
<CommandPrimitive.Input
className={cn(
'flex h-9 w-full min-w-0 border-input bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground md:text-sm dark:bg-input/30',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
'border-b focus-visible:ring-transparent'
)}
value={input}
onKeyDown={(e) => {
if (isHotkey('backspace')(e) && input.length === 0) {
e.preventDefault();
api.hide();
}
if (isHotkey('enter')(e) && !e.shiftKey && !value) {
e.preventDefault();
api.submit(input);
setInput('');
}
}}
onValueChange={setInput}
placeholder="Ask AI anything..."
data-editor-keep-selection-visible
autoFocus
/>
)}
{!isLoading && (
<CommandList>
<AIMenuItems
input={input}
setInput={setInput}
setValue={setValue}
/>
</CommandList>
)}
</Command>
</FloatingPopoverContent>
</FloatingPopover>
);
}
type EditorChatState =
| 'cursorCommand'
| 'cursorSuggestion'
| 'selectionCommand'
| 'selectionSuggestion';
const AICommentIcon = () => (
<svg
fill="none"
height="24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" stroke="none" />
<path d="M8 9h8" />
<path d="M8 13h4.5" />
<path d="M10 19l-1 -1h-3a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v4.5" />
<path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138z" />
</svg>
);
const aiChatItems = {
accept: {
icon: <Check />,
label: 'Accept',
value: 'accept',
onSelect: ({ editor }) => {
editor.plugin(AIChatPlugin).api.accept();
editor.api.dom.focus();
},
},
comment: {
icon: <AICommentIcon />,
label: 'Comment',
value: 'comment',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
mode: 'insert',
prompt:
'Please comment on the following content and provide reasonable and meaningful feedback.',
toolName: 'comment',
});
},
},
continueWrite: {
icon: <PenLine />,
label: 'Continue writing',
value: 'continueWrite',
onSelect: ({ editor, input }) => {
const ancestorNode = editor.read.nodes.block();
if (!ancestorNode) return;
const isEmpty = NodeApi.string(ancestorNode[0]).trim().length === 0;
editor.plugin(AIChatPlugin).api.submit(input, {
mode: 'insert',
prompt: isEmpty
? `<Document>
{editor}
</Document>
Start writing a new paragraph AFTER <Document> ONLY ONE SENTENCE`
: `<Block>
{block}
</Block>
Continue writing AFTER <Block> with ONLY ONE SENTENCE. DO NOT REPEAT THE TEXT.`,
toolName: 'generate',
});
},
},
discard: {
icon: <X />,
label: 'Discard',
shortcut: 'Escape',
value: 'discard',
onSelect: ({ editor }) => {
editor.plugin(AIChatPlugin).api.hide();
},
},
emojify: {
icon: <SmileIcon />,
label: 'Emojify',
value: 'emojify',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Add a small number of contextually relevant emojis within each block only. You may insert emojis, but do not remove, replace, or rewrite existing text, and do not modify Markdown syntax, links, or line breaks.',
toolName: 'edit',
});
},
},
explain: {
icon: <BadgeHelp />,
label: 'Explain',
value: 'explain',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt: {
default: 'Explain {editor}',
selecting: 'Explain',
},
toolName: 'generate',
});
},
},
fixSpelling: {
icon: <Check />,
label: 'Fix spelling & grammar',
value: 'fixSpelling',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Fix spelling, grammar, and punctuation errors within each block only, without changing meaning, tone, or adding new information.',
toolName: 'edit',
});
},
},
generateMarkdownSample: {
icon: <BookOpenCheck />,
label: 'Generate Markdown sample',
value: 'generateMarkdownSample',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt: 'Generate a markdown sample',
toolName: 'generate',
});
},
},
generateMdxSample: {
icon: <BookOpenCheck />,
label: 'Generate MDX sample',
value: 'generateMdxSample',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt: 'Generate a mdx sample',
toolName: 'generate',
});
},
},
improveWriting: {
icon: <Wand />,
label: 'Improve writing',
value: 'improveWriting',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Improve the writing for clarity and flow, without changing meaning or adding new information.',
toolName: 'edit',
});
},
},
insertBelow: {
icon: <ListEnd />,
label: 'Insert below',
value: 'insertBelow',
onSelect: ({ editor }) => {
/** Format: 'none' Fix insert table */
editor.plugin(AIChatPlugin).api.insertBelow({ format: 'none' });
editor.api.dom.focus();
},
},
makeLonger: {
icon: <ListPlus />,
label: 'Make longer',
value: 'makeLonger',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Make the content longer by elaborating on existing ideas within each block only, without changing meaning or adding new information.',
toolName: 'edit',
});
},
},
makeShorter: {
icon: <ListMinus />,
label: 'Make shorter',
value: 'makeShorter',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Make the content shorter by reducing verbosity within each block only, without changing meaning or removing essential information.',
toolName: 'edit',
});
},
},
replace: {
icon: <Check />,
label: 'Replace selection',
value: 'replace',
onSelect: ({ editor }) => {
editor.plugin(AIChatPlugin).api.replaceSelection();
editor.api.dom.focus();
},
},
simplifyLanguage: {
icon: <FeatherIcon />,
label: 'Simplify language',
value: 'simplifyLanguage',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Simplify the language by using clearer and more straightforward wording within each block only, without changing meaning or adding new information.',
toolName: 'edit',
});
},
},
summarize: {
icon: <Album />,
label: 'Add a summary',
value: 'summarize',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
mode: 'insert',
prompt: {
default: 'Summarize {editor}',
selecting: 'Summarize',
},
toolName: 'generate',
});
},
},
tryAgain: {
icon: <CornerUpLeft />,
label: 'Try again',
value: 'tryAgain',
onSelect: ({ editor }) => {
editor.plugin(AIChatPlugin).api.reload();
},
},
} satisfies Record<
string,
{
icon: React.ReactNode;
label: string;
value: string;
component?: React.ComponentType<{ menuState: EditorChatState }>;
filterItems?: boolean;
items?: Array<{ label: string; value: string }>;
shortcut?: string;
onSelect?: ({ editor, input }: { editor: Editor; input: string }) => void;
}
>;
const menuStateItems: Record<
EditorChatState,
Array<{
items: Array<(typeof aiChatItems)[keyof typeof aiChatItems]>;
heading?: string;
}>
> = {
cursorCommand: [
{
items: [
aiChatItems.comment,
aiChatItems.generateMdxSample,
aiChatItems.generateMarkdownSample,
aiChatItems.continueWrite,
aiChatItems.summarize,
aiChatItems.explain,
],
},
],
cursorSuggestion: [
{
items: [aiChatItems.accept, aiChatItems.discard, aiChatItems.tryAgain],
},
],
selectionCommand: [
{
items: [
aiChatItems.improveWriting,
aiChatItems.comment,
aiChatItems.emojify,
aiChatItems.makeLonger,
aiChatItems.makeShorter,
aiChatItems.fixSpelling,
aiChatItems.simplifyLanguage,
],
},
],
selectionSuggestion: [
{
items: [
aiChatItems.accept,
aiChatItems.discard,
aiChatItems.insertBelow,
aiChatItems.tryAgain,
],
},
],
};
export const AIMenuItems = ({
input,
setInput,
setValue,
}: {
input: string;
setInput: (value: string) => void;
setValue: (value: string) => void;
}) => {
const editor = useEditor();
const comments = editor.plugin(CommentsPlugin);
const messages = usePluginStore(AIChatPlugin, 'chat')?.messages;
const mode = usePluginStore(AIChatPlugin, 'mode');
const isSelecting = useEditorSelector(
(innerEditor2) =>
innerEditor2.read.selection.nodes().length > 0 ||
innerEditor2.read.selection.isExpanded()
);
const menuState: EditorChatState =
(messages?.length ?? 0) > 0
? mode === 'chat'
? 'selectionSuggestion'
: 'cursorSuggestion'
: isSelecting
? 'selectionCommand'
: 'cursorCommand';
const menuGroups = comments.installed
? menuStateItems[menuState]
: menuStateItems[menuState].map((group) => ({
...group,
items: group.items.filter((item) => item !== aiChatItems.comment),
}));
const firstItemValue = menuGroups[0]?.items[0]?.value;
React.useEffect(() => {
if (firstItemValue) setValue(firstItemValue);
}, [firstItemValue, setValue]);
return (
<>
{menuGroups.map((group) => (
<CommandGroup
key={group.heading ?? group.items[0]?.value}
heading={group.heading}
>
{group.items.map((menuItem) => (
<CommandItem
key={menuItem.value}
className="[&_svg]:text-muted-foreground"
value={menuItem.value}
onSelect={() => {
menuItem.onSelect?.({ editor, input });
setInput('');
}}
>
{menuItem.icon}
<span>{menuItem.label}</span>
</CommandItem>
))}
</CommandGroup>
))}
</>
);
};
export function AILoadingBar() {
const toolName = usePluginStore(AIChatPlugin, 'toolName');
const chat = usePluginStore(AIChatPlugin, 'chat');
const mode = usePluginStore(AIChatPlugin, 'mode');
const status = chat?.status ?? 'ready';
const { api } = useEditor().plugin(AIChatPlugin);
const isLoading = status === 'streaming' || status === 'submitted';
if (isLoading && (mode === 'insert' || toolName === 'comment')) {
return (
<div
className={cn(
'fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-md border border-border bg-muted px-3 py-1.5 text-muted-foreground text-sm shadow-md transition-all duration-300'
)}
>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
<span>{status === 'submitted' ? 'Thinking...' : 'Writing...'}</span>
<Button
size="sm"
variant="ghost"
className="flex items-center gap-1 text-xs"
onKeyDown={(event) => {
if (event.key !== 'Escape' || event.nativeEvent.isComposing) return;
event.preventDefault();
event.stopPropagation();
api.stop();
}}
onClick={() => {
api.stop();
}}
>
<PauseIcon className="h-4 w-4" />
Stop
<kbd className="ml-1 rounded bg-border px-1 font-mono text-[10px] text-muted-foreground shadow-sm">
Esc
</kbd>
</Button>
</div>
);
}
if (toolName === 'comment' && status === 'error') {
return (
<div
className="fixed bottom-4 left-1/2 z-50 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center gap-2 rounded-lg border bg-popover p-2 text-sm shadow-lg"
data-ai-comment-error=""
data-editor-keep-selection-visible
>
<p className="w-full px-1 text-destructive" role="alert">
Could not generate comments.
</p>
<Button onClick={() => api.reload()} size="sm" variant="outline">
Try again
</Button>
<Button onClick={() => api.hide()} size="sm" variant="outline">
<X data-icon="inline-start" />
Dismiss
</Button>
</div>
);
}
return null;
}
'use client';
 
import { Command as CommandPrimitive } from 'cmdk';
import {
  Album,
  BadgeHelp,
  BookOpenCheck,
  Check,
  CornerUpLeft,
  FeatherIcon,
  ListEnd,
  ListMinus,
  ListPlus,
  Loader2Icon,
  PauseIcon,
  PenLine,
  SmileIcon,
  Wand,
  X,
} from 'lucide-react';
import {
  createEditorView,
  ElementApi,
  isHotkey,
  NodeApi,
  TextApi,
} from 'platejs';
import { BaseAIPlugin } from 'platejs/ai';
import { AIChatPlugin } from 'platejs/ai/react';
import { CommentsPlugin } from 'platejs/comments/react';
import {
  useEditorRuntimeState,
  useCreateEditor,
  useEditorSelector,
  useFocusedLast,
  usePluginStore,
  type Editor,
  useEditor,
} from 'platejs/react';
import * as React from 'react';
 
import { Button } from '@/components/ui/button';
import {
  Command,
  CommandGroup,
  CommandItem,
  CommandList,
} from '@/components/ui/command';
import { cn } from '@/lib/utils';
import {
  FloatingPopover,
  FloatingPopoverAnchor,
  FloatingPopoverContent,
} from '@/components/editor/floating-popover';
import { BaseEditorKit } from '@/components/editor/plugins-static';
 
import { EditorStatic } from './editor-static';
 
const PreviewAIPlugin = BaseAIPlugin.extend(({ editor }) => ({
  decorate: {
    read: ({ entry: [node, path] }) => {
      if (!TextApi.isText(node) || node.text.length === 0) return [];
 
      return [
        {
          key: 'ai-preview',
          range: {
            anchor: { path, offset: 0 },
            focus: { path, offset: node.text.length },
          },
          attributes: {
            className:
              'border-b-2 border-b-purple-100 bg-purple-50 text-purple-800',
            'data-editor-ai-end':
              NodeApi.last(
                { children: editor.read.children(), type: '' },
                []
              )[0] === node
                ? ''
                : undefined,
          },
        },
      ];
    },
  },
}));
 
const scrollAIPreviewEnd = (editor: Editor, draft: HTMLElement | null) => {
  const scrollElement = editor.api.dom.scroll();
  const target = draft?.querySelector<HTMLElement>('[data-editor-ai-end]');
  if (!scrollElement || !target) return;
 
  const scrollBounds = scrollElement.getBoundingClientRect();
  const targetBounds = target.getBoundingClientRect();
 
  scrollElement.scrollTop +=
    targetBounds.top +
    targetBounds.height / 2 -
    (scrollBounds.top + scrollBounds.height / 2);
};
 
export function AIChatEditor({ inline = false }: { inline?: boolean }) {
  const editor = useEditor();
  const draftRef = React.useRef<HTMLDivElement>(null);
  const aiEditor = useCreateEditor({
    plugins: [...BaseEditorKit, PreviewAIPlugin],
  });
  const document = usePluginStore(AIChatPlugin, 'previewValue');
  const streaming = usePluginStore(AIChatPlugin, 'streaming');
 
  const preview = useEditorRuntimeState(
    aiEditor,
    React.useCallback(
      () => createEditorView(aiEditor, { readOnly: true }),
      [aiEditor]
    )
  );
 
  React.useLayoutEffect(() => {
    aiEditor.update({ history: 'skip' }).value.replace({ children: document });
  }, [aiEditor, document]);
 
  React.useEffect(() => {
    if (!inline) return;
 
    scrollAIPreviewEnd(editor, draftRef.current);
  }, [editor, inline, preview]);
 
  React.useEffect(() => {
    const draft = draftRef.current;
    const Observer = draft?.ownerDocument.defaultView?.ResizeObserver;
    if (!inline || !draft || !Observer) return undefined;
 
    const observer = new Observer(() => scrollAIPreviewEnd(editor, draft));
    observer.observe(draft);
 
    return () => observer.disconnect();
  }, [editor, inline]);
 
  const last =
    document.length > 0
      ? NodeApi.last({ children: document, type: '' }, [])[0]
      : null;
 
  return (
    <div ref={draftRef} data-editor-ai-draft="">
      <EditorStatic
        variant={inline ? 'none' : 'aiChat'}
        editor={preview}
        className={cn(
          streaming &&
            '[&_[data-editor-ai-end]]:after:ml-1.5 [&_[data-editor-ai-end]]:after:inline-block [&_[data-editor-ai-end]]:after:size-3 [&_[data-editor-ai-end]]:after:rounded-full [&_[data-editor-ai-end]]:after:bg-purple-600 [&_[data-editor-ai-end]]:after:align-middle [&_[data-editor-ai-end]]:after:content-[""]'
        )}
      />
      {streaming && (!last || !TextApi.isText(last) || !last.text) && (
        <span
          data-editor-ai-end=""
          className="inline-block size-3 rounded-full bg-purple-600 align-middle"
        />
      )}
    </div>
  );
}
 
export function AIMenu() {
  const editor = useEditor();
  const { api, read } = useEditor().plugin(AIChatPlugin);
  const mode = usePluginStore(AIChatPlugin, 'mode');
  const toolName = usePluginStore(AIChatPlugin, 'toolName');
 
  const streaming = usePluginStore(AIChatPlugin, 'streaming');
  const editAnchorKey = useEditorSelector((innerEditor) => {
    const entry = innerEditor.read.selection.nodes().at(-1);
 
    return entry && ElementApi.isElement(entry[0])
      ? innerEditor.key(entry[0])
      : null;
  });
  const isFocusedLast = useFocusedLast();
  const chatOpen = usePluginStore(AIChatPlugin, 'open');
  const open = chatOpen && isFocusedLast;
  const [value, setValue] = React.useState('');
 
  const [input, setInput] = React.useState('');
 
  const chat = usePluginStore(AIChatPlugin, 'chat');
  const previewValue = usePluginStore(AIChatPlugin, 'previewValue');
 
  const messages = chat?.messages;
  const status = chat?.status ?? 'ready';
  const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(
    null
  );
 
  React.useEffect(() => {
    if (!streaming && previewValue.length === 0) return undefined;
 
    const anchorEntry = read.node();
    if (!anchorEntry) return undefined;
 
    const anchorDom = editor.api.dom.resolveDOMNode(anchorEntry[0]);
    if (!anchorDom) return undefined;
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(
        anchorDom.closest<HTMLElement>('[data-editor-ai-preview-wrapper]') ??
          anchorDom
      );
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [editor, previewValue, read, streaming]);
 
  const setOpen = (innerOpen: boolean) => {
    if (innerOpen) {
      if (!chatOpen) api.show();
    } else if (chatOpen) {
      api.hide({ focus: false });
    }
  };
 
  React.useEffect(() => {
    if (!chatOpen) {
      const animationFrame = window.requestAnimationFrame(() => {
        setAnchorElement(null);
        setInput('');
      });
 
      return () => {
        window.cancelAnimationFrame(animationFrame);
      };
    }
 
    let nextAnchor: HTMLElement | null = null;
    const block =
      editor.read.nodes.blocks().at(-1) ?? editor.read.nodes.block();
    if (block && ElementApi.isElement(block[0])) {
      nextAnchor = editor.api.dom.resolveDOMNode(block[0]);
    }
 
    if (!nextAnchor) return undefined;
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(nextAnchor);
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [chatOpen, editor]);
 
  const isLoading = status === 'streaming' || status === 'submitted';
 
  React.useEffect(() => {
    if (toolName !== 'edit' || mode !== 'chat' || isLoading) return undefined;
 
    let anchorNode = editAnchorKey
      ? editor.read.nodes.get(editAnchorKey, {
          match: ElementApi.isElement,
        })
      : undefined;
 
    if (!anchorNode) {
      anchorNode =
        editor.read.nodes.blocks().at(-1) ?? editor.read.nodes.block();
    }
 
    if (!anchorNode) return undefined;
 
    const block = editor.read.nodes.block({ at: anchorNode[1] });
    const domNode = block ? editor.api.dom.resolveDOMNode(block[0]) : null;
 
    if (!domNode) return undefined;
 
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(domNode);
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [editAnchorKey, editor, isLoading, mode, toolName]);
 
  if (isLoading && mode === 'insert') return null;
 
  if (toolName === 'comment') return null;
 
  if (!anchorElement) return null;
 
  return (
    <FloatingPopover open={open} onOpenChange={setOpen} modal={false}>
      <FloatingPopoverAnchor element={anchorElement} />
 
      <FloatingPopoverContent
        className="w-(--floating-popover-anchor-width) max-w-[calc(100vw-16px)] border-none bg-transparent p-0 shadow-none ring-0"
        onEscapeKeyDown={(e) => {
          e.preventDefault();
 
          api.hide();
        }}
        align="center"
        side="bottom"
      >
        <Command
          className="w-full rounded-lg border shadow-md"
          value={value}
          onValueChange={setValue}
        >
          {mode === 'chat' && previewValue.length > 0 && <AIChatEditor />}
 
          {isLoading ? (
            <div className="flex grow items-center gap-2 p-2 text-sm text-muted-foreground select-none">
              <Loader2Icon className="size-4 animate-spin" />
              {(messages?.length ?? 0) > 1 ? 'Editing...' : 'Thinking...'}
            </div>
          ) : (
            <CommandPrimitive.Input
              className={cn(
                'flex h-9 w-full min-w-0 border-input bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground md:text-sm dark:bg-input/30',
                'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
                'border-b focus-visible:ring-transparent'
              )}
              value={input}
              onKeyDown={(e) => {
                if (isHotkey('backspace')(e) && input.length === 0) {
                  e.preventDefault();
                  api.hide();
                }
                if (isHotkey('enter')(e) && !e.shiftKey && !value) {
                  e.preventDefault();
                  api.submit(input);
                  setInput('');
                }
              }}
              onValueChange={setInput}
              placeholder="Ask AI anything..."
              data-editor-keep-selection-visible
              autoFocus
            />
          )}
 
          {!isLoading && (
            <CommandList>
              <AIMenuItems
                input={input}
                setInput={setInput}
                setValue={setValue}
              />
            </CommandList>
          )}
        </Command>
      </FloatingPopoverContent>
    </FloatingPopover>
  );
}
 
type EditorChatState =
  | 'cursorCommand'
  | 'cursorSuggestion'
  | 'selectionCommand'
  | 'selectionSuggestion';
 
const AICommentIcon = () => (
  <svg
    fill="none"
    height="24"
    stroke="currentColor"
    strokeLinecap="round"
    strokeLinejoin="round"
    strokeWidth="2"
    viewBox="0 0 24 24"
    width="24"
    xmlns="http://www.w3.org/2000/svg"
  >
    <path d="M0 0h24v24H0z" fill="none" stroke="none" />
    <path d="M8 9h8" />
    <path d="M8 13h4.5" />
    <path d="M10 19l-1 -1h-3a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v4.5" />
    <path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138z" />
  </svg>
);
 
const aiChatItems = {
  accept: {
    icon: <Check />,
    label: 'Accept',
    value: 'accept',
    onSelect: ({ editor }) => {
      editor.plugin(AIChatPlugin).api.accept();
      editor.api.dom.focus();
    },
  },
  comment: {
    icon: <AICommentIcon />,
    label: 'Comment',
    value: 'comment',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        mode: 'insert',
        prompt:
          'Please comment on the following content and provide reasonable and meaningful feedback.',
        toolName: 'comment',
      });
    },
  },
  continueWrite: {
    icon: <PenLine />,
    label: 'Continue writing',
    value: 'continueWrite',
    onSelect: ({ editor, input }) => {
      const ancestorNode = editor.read.nodes.block();
 
      if (!ancestorNode) return;
 
      const isEmpty = NodeApi.string(ancestorNode[0]).trim().length === 0;
 
      editor.plugin(AIChatPlugin).api.submit(input, {
        mode: 'insert',
        prompt: isEmpty
          ? `<Document>
{editor}
</Document>
Start writing a new paragraph AFTER <Document> ONLY ONE SENTENCE`
          : `<Block>
{block}
</Block>
Continue writing AFTER <Block> with ONLY ONE SENTENCE. DO NOT REPEAT THE TEXT.`,
        toolName: 'generate',
      });
    },
  },
  discard: {
    icon: <X />,
    label: 'Discard',
    shortcut: 'Escape',
    value: 'discard',
    onSelect: ({ editor }) => {
      editor.plugin(AIChatPlugin).api.hide();
    },
  },
  emojify: {
    icon: <SmileIcon />,
    label: 'Emojify',
    value: 'emojify',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Add a small number of contextually relevant emojis within each block only. You may insert emojis, but do not remove, replace, or rewrite existing text, and do not modify Markdown syntax, links, or line breaks.',
        toolName: 'edit',
      });
    },
  },
  explain: {
    icon: <BadgeHelp />,
    label: 'Explain',
    value: 'explain',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt: {
          default: 'Explain {editor}',
          selecting: 'Explain',
        },
        toolName: 'generate',
      });
    },
  },
  fixSpelling: {
    icon: <Check />,
    label: 'Fix spelling & grammar',
    value: 'fixSpelling',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Fix spelling, grammar, and punctuation errors within each block only, without changing meaning, tone, or adding new information.',
        toolName: 'edit',
      });
    },
  },
  generateMarkdownSample: {
    icon: <BookOpenCheck />,
    label: 'Generate Markdown sample',
    value: 'generateMarkdownSample',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt: 'Generate a markdown sample',
        toolName: 'generate',
      });
    },
  },
  generateMdxSample: {
    icon: <BookOpenCheck />,
    label: 'Generate MDX sample',
    value: 'generateMdxSample',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt: 'Generate a mdx sample',
        toolName: 'generate',
      });
    },
  },
  improveWriting: {
    icon: <Wand />,
    label: 'Improve writing',
    value: 'improveWriting',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Improve the writing for clarity and flow, without changing meaning or adding new information.',
        toolName: 'edit',
      });
    },
  },
  insertBelow: {
    icon: <ListEnd />,
    label: 'Insert below',
    value: 'insertBelow',
    onSelect: ({ editor }) => {
      /** Format: 'none' Fix insert table */
      editor.plugin(AIChatPlugin).api.insertBelow({ format: 'none' });
      editor.api.dom.focus();
    },
  },
  makeLonger: {
    icon: <ListPlus />,
    label: 'Make longer',
    value: 'makeLonger',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Make the content longer by elaborating on existing ideas within each block only, without changing meaning or adding new information.',
        toolName: 'edit',
      });
    },
  },
  makeShorter: {
    icon: <ListMinus />,
    label: 'Make shorter',
    value: 'makeShorter',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Make the content shorter by reducing verbosity within each block only, without changing meaning or removing essential information.',
        toolName: 'edit',
      });
    },
  },
  replace: {
    icon: <Check />,
    label: 'Replace selection',
    value: 'replace',
    onSelect: ({ editor }) => {
      editor.plugin(AIChatPlugin).api.replaceSelection();
      editor.api.dom.focus();
    },
  },
  simplifyLanguage: {
    icon: <FeatherIcon />,
    label: 'Simplify language',
    value: 'simplifyLanguage',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        prompt:
          'Simplify the language by using clearer and more straightforward wording within each block only, without changing meaning or adding new information.',
        toolName: 'edit',
      });
    },
  },
  summarize: {
    icon: <Album />,
    label: 'Add a summary',
    value: 'summarize',
    onSelect: ({ editor, input }) => {
      editor.plugin(AIChatPlugin).api.submit(input, {
        mode: 'insert',
        prompt: {
          default: 'Summarize {editor}',
          selecting: 'Summarize',
        },
        toolName: 'generate',
      });
    },
  },
  tryAgain: {
    icon: <CornerUpLeft />,
    label: 'Try again',
    value: 'tryAgain',
    onSelect: ({ editor }) => {
      editor.plugin(AIChatPlugin).api.reload();
    },
  },
} satisfies Record<
  string,
  {
    icon: React.ReactNode;
    label: string;
    value: string;
    component?: React.ComponentType<{ menuState: EditorChatState }>;
    filterItems?: boolean;
    items?: Array<{ label: string; value: string }>;
    shortcut?: string;
    onSelect?: ({ editor, input }: { editor: Editor; input: string }) => void;
  }
>;
 
const menuStateItems: Record<
  EditorChatState,
  Array<{
    items: Array<(typeof aiChatItems)[keyof typeof aiChatItems]>;
    heading?: string;
  }>
> = {
  cursorCommand: [
    {
      items: [
        aiChatItems.comment,
        aiChatItems.generateMdxSample,
        aiChatItems.generateMarkdownSample,
        aiChatItems.continueWrite,
        aiChatItems.summarize,
        aiChatItems.explain,
      ],
    },
  ],
  cursorSuggestion: [
    {
      items: [aiChatItems.accept, aiChatItems.discard, aiChatItems.tryAgain],
    },
  ],
  selectionCommand: [
    {
      items: [
        aiChatItems.improveWriting,
        aiChatItems.comment,
        aiChatItems.emojify,
        aiChatItems.makeLonger,
        aiChatItems.makeShorter,
        aiChatItems.fixSpelling,
        aiChatItems.simplifyLanguage,
      ],
    },
  ],
  selectionSuggestion: [
    {
      items: [
        aiChatItems.accept,
        aiChatItems.discard,
        aiChatItems.insertBelow,
        aiChatItems.tryAgain,
      ],
    },
  ],
};
 
export const AIMenuItems = ({
  input,
  setInput,
  setValue,
}: {
  input: string;
  setInput: (value: string) => void;
  setValue: (value: string) => void;
}) => {
  const editor = useEditor();
  const comments = editor.plugin(CommentsPlugin);
  const messages = usePluginStore(AIChatPlugin, 'chat')?.messages;
  const mode = usePluginStore(AIChatPlugin, 'mode');
  const isSelecting = useEditorSelector(
    (innerEditor2) =>
      innerEditor2.read.selection.nodes().length > 0 ||
      innerEditor2.read.selection.isExpanded()
  );
 
  const menuState: EditorChatState =
    (messages?.length ?? 0) > 0
      ? mode === 'chat'
        ? 'selectionSuggestion'
        : 'cursorSuggestion'
      : isSelecting
        ? 'selectionCommand'
        : 'cursorCommand';
  const menuGroups = comments.installed
    ? menuStateItems[menuState]
    : menuStateItems[menuState].map((group) => ({
        ...group,
        items: group.items.filter((item) => item !== aiChatItems.comment),
      }));
  const firstItemValue = menuGroups[0]?.items[0]?.value;
 
  React.useEffect(() => {
    if (firstItemValue) setValue(firstItemValue);
  }, [firstItemValue, setValue]);
 
  return (
    <>
      {menuGroups.map((group) => (
        <CommandGroup
          key={group.heading ?? group.items[0]?.value}
          heading={group.heading}
        >
          {group.items.map((menuItem) => (
            <CommandItem
              key={menuItem.value}
              className="[&_svg]:text-muted-foreground"
              value={menuItem.value}
              onSelect={() => {
                menuItem.onSelect?.({ editor, input });
                setInput('');
              }}
            >
              {menuItem.icon}
              <span>{menuItem.label}</span>
            </CommandItem>
          ))}
        </CommandGroup>
      ))}
    </>
  );
};
 
export function AILoadingBar() {
  const toolName = usePluginStore(AIChatPlugin, 'toolName');
  const chat = usePluginStore(AIChatPlugin, 'chat');
  const mode = usePluginStore(AIChatPlugin, 'mode');
 
  const status = chat?.status ?? 'ready';
 
  const { api } = useEditor().plugin(AIChatPlugin);
 
  const isLoading = status === 'streaming' || status === 'submitted';
 
  if (isLoading && (mode === 'insert' || toolName === 'comment')) {
    return (
      <div
        className={cn(
          'fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-md border border-border bg-muted px-3 py-1.5 text-muted-foreground text-sm shadow-md transition-all duration-300'
        )}
      >
        <span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
        <span>{status === 'submitted' ? 'Thinking...' : 'Writing...'}</span>
        <Button
          size="sm"
          variant="ghost"
          className="flex items-center gap-1 text-xs"
          onKeyDown={(event) => {
            if (event.key !== 'Escape' || event.nativeEvent.isComposing) return;
            event.preventDefault();
            event.stopPropagation();
            api.stop();
          }}
          onClick={() => {
            api.stop();
          }}
        >
          <PauseIcon className="h-4 w-4" />
          Stop
          <kbd className="ml-1 rounded bg-border px-1 font-mono text-[10px] text-muted-foreground shadow-sm">
            Esc
          </kbd>
        </Button>
      </div>
    );
  }
 
  if (toolName === 'comment' && status === 'error') {
    return (
      <div
        className="fixed bottom-4 left-1/2 z-50 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center gap-2 rounded-lg border bg-popover p-2 text-sm shadow-lg"
        data-ai-comment-error=""
        data-editor-keep-selection-visible
      >
        <p className="w-full px-1 text-destructive" role="alert">
          Could not generate comments.
        </p>
        <Button onClick={() => api.reload()} size="sm" variant="outline">
          Try again
        </Button>
        <Button onClick={() => api.hide()} size="sm" variant="outline">
          <X data-icon="inline-start" />
          Dismiss
        </Button>
      </div>
    );
  }
 
  return null;
}
toolName:
'generate'
,
});
import { BaseAIPlugin } from 'platejs/ai';
import { AIChatPlugin } from 'platejs/ai/react';
import { CommentsPlugin } from 'platejs/comments/react';
import {
useEditorRuntimeState,
useCreateEditor,
useEditorSelector,
useFocusedLast,
usePluginStore,
type Editor,
useEditor,
} from 'platejs/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { cn } from '@/lib/utils';
import {
FloatingPopover,
FloatingPopoverAnchor,
FloatingPopoverContent,
} from '@/components/editor/floating-popover';
import { BaseEditorKit } from '@/components/editor/plugins-static';
import { EditorStatic } from './editor-static';
const PreviewAIPlugin = BaseAIPlugin.extend(({ editor }) => ({
decorate: {
read: ({ entry: [node, path] }) => {
if (!TextApi.isText(node) || node.text.length === 0) return [];
return [
{
key: 'ai-preview',
range: {
anchor: { path, offset: 0 },
focus: { path, offset: node.text.length },
},
attributes: {
className:
'border-b-2 border-b-purple-100 bg-purple-50 text-purple-800',
'data-editor-ai-end':
NodeApi.last(
{ children: editor.read.children(), type: '' },
[]
)[0] === node
? ''
: undefined,
},
},
];
},
},
}));
const scrollAIPreviewEnd = (editor: Editor, draft: HTMLElement | null) => {
const scrollElement = editor.api.dom.scroll();
const target = draft?.querySelector<HTMLElement>('[data-editor-ai-end]');
if (!scrollElement || !target) return;
const scrollBounds = scrollElement.getBoundingClientRect();
const targetBounds = target.getBoundingClientRect();
scrollElement.scrollTop +=
targetBounds.top +
targetBounds.height / 2 -
(scrollBounds.top + scrollBounds.height / 2);
};
export function AIChatEditor({ inline = false }: { inline?: boolean }) {
const editor = useEditor();
const draftRef = React.useRef<HTMLDivElement>(null);
const aiEditor = useCreateEditor({
plugins: [...BaseEditorKit, PreviewAIPlugin],
});
const document = usePluginStore(AIChatPlugin, 'previewValue');
const streaming = usePluginStore(AIChatPlugin, 'streaming');
const preview = useEditorRuntimeState(
aiEditor,
React.useCallback(
() => createEditorView(aiEditor, { readOnly: true }),
[aiEditor]
)
);
React.useLayoutEffect(() => {
aiEditor.update({ history: 'skip' }).value.replace({ children: document });
}, [aiEditor, document]);
React.useEffect(() => {
if (!inline) return;
scrollAIPreviewEnd(editor, draftRef.current);
}, [editor, inline, preview]);
React.useEffect(() => {
const draft = draftRef.current;
const Observer = draft?.ownerDocument.defaultView?.ResizeObserver;
if (!inline || !draft || !Observer) return undefined;
const observer = new Observer(() => scrollAIPreviewEnd(editor, draft));
observer.observe(draft);
return () => observer.disconnect();
}, [editor, inline]);
const last =
document.length > 0
? NodeApi.last({ children: document, type: '' }, [])[0]
: null;
return (
<div ref={draftRef} data-editor-ai-draft="">
<EditorStatic
variant={inline ? 'none' : 'aiChat'}
editor={preview}
className={cn(
streaming &&
'[&_[data-editor-ai-end]]:after:ml-1.5 [&_[data-editor-ai-end]]:after:inline-block [&_[data-editor-ai-end]]:after:size-3 [&_[data-editor-ai-end]]:after:rounded-full [&_[data-editor-ai-end]]:after:bg-purple-600 [&_[data-editor-ai-end]]:after:align-middle [&_[data-editor-ai-end]]:after:content-[""]'
)}
/>
{streaming && (!last || !TextApi.isText(last) || !last.text) && (
<span
data-editor-ai-end=""
className="inline-block size-3 rounded-full bg-purple-600 align-middle"
/>
)}
</div>
);
}
export function AIMenu() {
const editor = useEditor();
const { api, read } = useEditor().plugin(AIChatPlugin);
const mode = usePluginStore(AIChatPlugin, 'mode');
const toolName = usePluginStore(AIChatPlugin, 'toolName');
const streaming = usePluginStore(AIChatPlugin, 'streaming');
const editAnchorKey = useEditorSelector((innerEditor) => {
const entry = innerEditor.read.selection.nodes().at(-1);
return entry && ElementApi.isElement(entry[0])
? innerEditor.key(entry[0])
: null;
});
const isFocusedLast = useFocusedLast();
const chatOpen = usePluginStore(AIChatPlugin, 'open');
const open = chatOpen && isFocusedLast;
const [value, setValue] = React.useState('');
const [input, setInput] = React.useState('');
const chat = usePluginStore(AIChatPlugin, 'chat');
const previewValue = usePluginStore(AIChatPlugin, 'previewValue');
const messages = chat?.messages;
const status = chat?.status ?? 'ready';
const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(
null
);
React.useEffect(() => {
if (!streaming && previewValue.length === 0) return undefined;
const anchorEntry = read.node();
if (!anchorEntry) return undefined;
const anchorDom = editor.api.dom.resolveDOMNode(anchorEntry[0]);
if (!anchorDom) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(
anchorDom.closest<HTMLElement>('[data-editor-ai-preview-wrapper]') ??
anchorDom
);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [editor, previewValue, read, streaming]);
const setOpen = (innerOpen: boolean) => {
if (innerOpen) {
if (!chatOpen) api.show();
} else if (chatOpen) {
api.hide({ focus: false });
}
};
React.useEffect(() => {
if (!chatOpen) {
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(null);
setInput('');
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}
let nextAnchor: HTMLElement | null = null;
const block =
editor.read.nodes.blocks().at(-1) ?? editor.read.nodes.block();
if (block && ElementApi.isElement(block[0])) {
nextAnchor = editor.api.dom.resolveDOMNode(block[0]);
}
if (!nextAnchor) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(nextAnchor);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [chatOpen, editor]);
const isLoading = status === 'streaming' || status === 'submitted';
React.useEffect(() => {
if (toolName !== 'edit' || mode !== 'chat' || isLoading) return undefined;
let anchorNode = editAnchorKey
? editor.read.nodes.get(editAnchorKey, {
match: ElementApi.isElement,
})
: undefined;
if (!anchorNode) {
anchorNode =
editor.read.nodes.blocks().at(-1) ?? editor.read.nodes.block();
}
if (!anchorNode) return undefined;
const block = editor.read.nodes.block({ at: anchorNode[1] });
const domNode = block ? editor.api.dom.resolveDOMNode(block[0]) : null;
if (!domNode) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(domNode);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [editAnchorKey, editor, isLoading, mode, toolName]);
if (isLoading && mode === 'insert') return null;
if (toolName === 'comment') return null;
if (!anchorElement) return null;
return (
<FloatingPopover open={open} onOpenChange={setOpen} modal={false}>
<FloatingPopoverAnchor element={anchorElement} />
<FloatingPopoverContent
className="w-(--floating-popover-anchor-width) max-w-[calc(100vw-16px)] border-none bg-transparent p-0 shadow-none ring-0"
onEscapeKeyDown={(e) => {
e.preventDefault();
api.hide();
}}
align="center"
side="bottom"
>
<Command
className="w-full rounded-lg border shadow-md"
value={value}
onValueChange={setValue}
>
{mode === 'chat' && previewValue.length > 0 && <AIChatEditor />}
{isLoading ? (
<div className="flex grow items-center gap-2 p-2 text-sm text-muted-foreground select-none">
<Loader2Icon className="size-4 animate-spin" />
{(messages?.length ?? 0) > 1 ? 'Editing...' : 'Thinking...'}
</div>
) : (
<CommandPrimitive.Input
className={cn(
'flex h-9 w-full min-w-0 border-input bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground md:text-sm dark:bg-input/30',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
'border-b focus-visible:ring-transparent'
)}
value={input}
onKeyDown={(e) => {
if (isHotkey('backspace')(e) && input.length === 0) {
e.preventDefault();
api.hide();
}
if (isHotkey('enter')(e) && !e.shiftKey && !value) {
e.preventDefault();
api.submit(input);
setInput('');
}
}}
onValueChange={setInput}
placeholder="Ask AI anything..."
data-editor-keep-selection-visible
autoFocus
/>
)}
{!isLoading && (
<CommandList>
<AIMenuItems
input={input}
setInput={setInput}
setValue={setValue}
/>
</CommandList>
)}
</Command>
</FloatingPopoverContent>
</FloatingPopover>
);
}
type EditorChatState =
| 'cursorCommand'
| 'cursorSuggestion'
| 'selectionCommand'
| 'selectionSuggestion';
const AICommentIcon = () => (
<svg
fill="none"
height="24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" stroke="none" />
<path d="M8 9h8" />
<path d="M8 13h4.5" />
<path d="M10 19l-1 -1h-3a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v4.5" />
<path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138z" />
</svg>
);
const aiChatItems = {
accept: {
icon: <Check />,
label: 'Accept',
value: 'accept',
onSelect: ({ editor }) => {
editor.plugin(AIChatPlugin).api.accept();
editor.api.dom.focus();
},
},
comment: {
icon: <AICommentIcon />,
label: 'Comment',
value: 'comment',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
mode: 'insert',
prompt:
'Please comment on the following content and provide reasonable and meaningful feedback.',
toolName: 'comment',
});
},
},
continueWrite: {
icon: <PenLine />,
label: 'Continue writing',
value: 'continueWrite',
onSelect: ({ editor, input }) => {
const ancestorNode = editor.read.nodes.block();
if (!ancestorNode) return;
const isEmpty = NodeApi.string(ancestorNode[0]).trim().length === 0;
editor.plugin(AIChatPlugin).api.submit(input, {
mode: 'insert',
prompt: isEmpty
? `<Document>
{editor}
</Document>
Start writing a new paragraph AFTER <Document> ONLY ONE SENTENCE`
: `<Block>
{block}
</Block>
Continue writing AFTER <Block> with ONLY ONE SENTENCE. DO NOT REPEAT THE TEXT.`,
toolName: 'generate',
});
},
},
discard: {
icon: <X />,
label: 'Discard',
shortcut: 'Escape',
value: 'discard',
onSelect: ({ editor }) => {
editor.plugin(AIChatPlugin).api.hide();
},
},
emojify: {
icon: <SmileIcon />,
label: 'Emojify',
value: 'emojify',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Add a small number of contextually relevant emojis within each block only. You may insert emojis, but do not remove, replace, or rewrite existing text, and do not modify Markdown syntax, links, or line breaks.',
toolName: 'edit',
});
},
},
explain: {
icon: <BadgeHelp />,
label: 'Explain',
value: 'explain',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt: {
default: 'Explain {editor}',
selecting: 'Explain',
},
toolName: 'generate',
});
},
},
fixSpelling: {
icon: <Check />,
label: 'Fix spelling & grammar',
value: 'fixSpelling',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Fix spelling, grammar, and punctuation errors within each block only, without changing meaning, tone, or adding new information.',
toolName: 'edit',
});
},
},
generateMarkdownSample: {
icon: <BookOpenCheck />,
label: 'Generate Markdown sample',
value: 'generateMarkdownSample',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt: 'Generate a markdown sample',
toolName: 'generate',
});
},
},
generateMdxSample: {
icon: <BookOpenCheck />,
label: 'Generate MDX sample',
value: 'generateMdxSample',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt: 'Generate a mdx sample',
toolName: 'generate',
});
},
},
improveWriting: {
icon: <Wand />,
label: 'Improve writing',
value: 'improveWriting',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Improve the writing for clarity and flow, without changing meaning or adding new information.',
toolName: 'edit',
});
},
},
insertBelow: {
icon: <ListEnd />,
label: 'Insert below',
value: 'insertBelow',
onSelect: ({ editor }) => {
/** Format: 'none' Fix insert table */
editor.plugin(AIChatPlugin).api.insertBelow({ format: 'none' });
editor.api.dom.focus();
},
},
makeLonger: {
icon: <ListPlus />,
label: 'Make longer',
value: 'makeLonger',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Make the content longer by elaborating on existing ideas within each block only, without changing meaning or adding new information.',
toolName: 'edit',
});
},
},
makeShorter: {
icon: <ListMinus />,
label: 'Make shorter',
value: 'makeShorter',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Make the content shorter by reducing verbosity within each block only, without changing meaning or removing essential information.',
toolName: 'edit',
});
},
},
replace: {
icon: <Check />,
label: 'Replace selection',
value: 'replace',
onSelect: ({ editor }) => {
editor.plugin(AIChatPlugin).api.replaceSelection();
editor.api.dom.focus();
},
},
simplifyLanguage: {
icon: <FeatherIcon />,
label: 'Simplify language',
value: 'simplifyLanguage',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Simplify the language by using clearer and more straightforward wording within each block only, without changing meaning or adding new information.',
toolName: 'edit',
});
},
},
summarize: {
icon: <Album />,
label: 'Add a summary',
value: 'summarize',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
mode: 'insert',
prompt: {
default: 'Summarize {editor}',
selecting: 'Summarize',
},
toolName: 'generate',
});
},
},
tryAgain: {
icon: <CornerUpLeft />,
label: 'Try again',
value: 'tryAgain',
onSelect: ({ editor }) => {
editor.plugin(AIChatPlugin).api.reload();
},
},
} satisfies Record<
string,
{
icon: React.ReactNode;
label: string;
value: string;
component?: React.ComponentType<{ menuState: EditorChatState }>;
filterItems?: boolean;
items?: Array<{ label: string; value: string }>;
shortcut?: string;
onSelect?: ({ editor, input }: { editor: Editor; input: string }) => void;
}
>;
const menuStateItems: Record<
EditorChatState,
Array<{
items: Array<(typeof aiChatItems)[keyof typeof aiChatItems]>;
heading?: string;
}>
> = {
cursorCommand: [
{
items: [
aiChatItems.comment,
aiChatItems.generateMdxSample,
aiChatItems.generateMarkdownSample,
aiChatItems.continueWrite,
aiChatItems.summarize,
aiChatItems.explain,
],
},
],
cursorSuggestion: [
{
items: [aiChatItems.accept, aiChatItems.discard, aiChatItems.tryAgain],
},
],
selectionCommand: [
{
items: [
aiChatItems.improveWriting,
aiChatItems.comment,
aiChatItems.emojify,
aiChatItems.makeLonger,
aiChatItems.makeShorter,
aiChatItems.fixSpelling,
aiChatItems.simplifyLanguage,
],
},
],
selectionSuggestion: [
{
items: [
aiChatItems.accept,
aiChatItems.discard,
aiChatItems.insertBelow,
aiChatItems.tryAgain,
],
},
],
};
export const AIMenuItems = ({
input,
setInput,
setValue,
}: {
input: string;
setInput: (value: string) => void;
setValue: (value: string) => void;
}) => {
const editor = useEditor();
const comments = editor.plugin(CommentsPlugin);
const messages = usePluginStore(AIChatPlugin, 'chat')?.messages;
const mode = usePluginStore(AIChatPlugin, 'mode');
const isSelecting = useEditorSelector(
(innerEditor2) =>
innerEditor2.read.selection.nodes().length > 0 ||
innerEditor2.read.selection.isExpanded()
);
const menuState: EditorChatState =
(messages?.length ?? 0) > 0
? mode === 'chat'
? 'selectionSuggestion'
: 'cursorSuggestion'
: isSelecting
? 'selectionCommand'
: 'cursorCommand';
const menuGroups = comments.installed
? menuStateItems[menuState]
: menuStateItems[menuState].map((group) => ({
...group,
items: group.items.filter((item) => item !== aiChatItems.comment),
}));
const firstItemValue = menuGroups[0]?.items[0]?.value;
React.useEffect(() => {
if (firstItemValue) setValue(firstItemValue);
}, [firstItemValue, setValue]);
return (
<>
{menuGroups.map((group) => (
<CommandGroup
key={group.heading ?? group.items[0]?.value}
heading={group.heading}
>
{group.items.map((menuItem) => (
<CommandItem
key={menuItem.value}
className="[&_svg]:text-muted-foreground"
value={menuItem.value}
onSelect={() => {
menuItem.onSelect?.({ editor, input });
setInput('');
}}
>
{menuItem.icon}
<span>{menuItem.label}</span>
</CommandItem>
))}
</CommandGroup>
))}
</>
);
};
export function AILoadingBar() {
const toolName = usePluginStore(AIChatPlugin, 'toolName');
const chat = usePluginStore(AIChatPlugin, 'chat');
const mode = usePluginStore(AIChatPlugin, 'mode');
const status = chat?.status ?? 'ready';
const { api } = useEditor().plugin(AIChatPlugin);
const isLoading = status === 'streaming' || status === 'submitted';
if (isLoading && (mode === 'insert' || toolName === 'comment')) {
return (
<div
className={cn(
'fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-md border border-border bg-muted px-3 py-1.5 text-muted-foreground text-sm shadow-md transition-all duration-300'
)}
>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
<span>{status === 'submitted' ? 'Thinking...' : 'Writing...'}</span>
<Button
size="sm"
variant="ghost"
className="flex items-center gap-1 text-xs"
onKeyDown={(event) => {
if (event.key !== 'Escape' || event.nativeEvent.isComposing) return;
event.preventDefault();
event.stopPropagation();
api.stop();
}}
onClick={() => {
api.stop();
}}
>
<PauseIcon className="h-4 w-4" />
Stop
<kbd className="ml-1 rounded bg-border px-1 font-mono text-[10px] text-muted-foreground shadow-sm">
Esc
</kbd>
</Button>
</div>
);
}
if (toolName === 'comment' && status === 'error') {
return (
<div
className="fixed bottom-4 left-1/2 z-50 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center gap-2 rounded-lg border bg-popover p-2 text-sm shadow-lg"
data-ai-comment-error=""
data-editor-keep-selection-visible
>
<p className="w-full px-1 text-destructive" role="alert">
Could not generate comments.
</p>
<Button onClick={() => api.reload()} size="sm" variant="outline">
Try again
</Button>
<Button onClick={() => api.hide()} size="sm" variant="outline">
<X data-icon="inline-start" />
Dismiss
</Button>
</div>
);
}
return null;
}
import { BaseAIPlugin } from 'platejs/ai';
import { AIChatPlugin } from 'platejs/ai/react';
import { CommentsPlugin } from 'platejs/comments/react';
import {
useEditorRuntimeState,
useCreateEditor,
useEditorSelector,
useFocusedLast,
usePluginStore,
type Editor,
useEditor,
} from 'platejs/react';
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
Command,
CommandGroup,
CommandItem,
CommandList,
} from '@/components/ui/command';
import { cn } from '@/lib/utils';
import {
FloatingPopover,
FloatingPopoverAnchor,
FloatingPopoverContent,
} from '@/components/editor/floating-popover';
import { BaseEditorKit } from '@/components/editor/plugins-static';
import { EditorStatic } from './editor-static';
const PreviewAIPlugin = BaseAIPlugin.extend(({ editor }) => ({
decorate: {
read: ({ entry: [node, path] }) => {
if (!TextApi.isText(node) || node.text.length === 0) return [];
return [
{
key: 'ai-preview',
range: {
anchor: { path, offset: 0 },
focus: { path, offset: node.text.length },
},
attributes: {
className:
'border-b-2 border-b-purple-100 bg-purple-50 text-purple-800',
'data-editor-ai-end':
NodeApi.last(
{ children: editor.read.children(), type: '' },
[]
)[0] === node
? ''
: undefined,
},
},
];
},
},
}));
const scrollAIPreviewEnd = (editor: Editor, draft: HTMLElement | null) => {
const scrollElement = editor.api.dom.scroll();
const target = draft?.querySelector<HTMLElement>('[data-editor-ai-end]');
if (!scrollElement || !target) return;
const scrollBounds = scrollElement.getBoundingClientRect();
const targetBounds = target.getBoundingClientRect();
scrollElement.scrollTop +=
targetBounds.top +
targetBounds.height / 2 -
(scrollBounds.top + scrollBounds.height / 2);
};
export function AIChatEditor({ inline = false }: { inline?: boolean }) {
const editor = useEditor();
const draftRef = React.useRef<HTMLDivElement>(null);
const aiEditor = useCreateEditor({
plugins: [...BaseEditorKit, PreviewAIPlugin],
});
const document = usePluginStore(AIChatPlugin, 'previewValue');
const streaming = usePluginStore(AIChatPlugin, 'streaming');
const preview = useEditorRuntimeState(
aiEditor,
React.useCallback(
() => createEditorView(aiEditor, { readOnly: true }),
[aiEditor]
)
);
React.useLayoutEffect(() => {
aiEditor.update({ history: 'skip' }).value.replace({ children: document });
}, [aiEditor, document]);
React.useEffect(() => {
if (!inline) return;
scrollAIPreviewEnd(editor, draftRef.current);
}, [editor, inline, preview]);
React.useEffect(() => {
const draft = draftRef.current;
const Observer = draft?.ownerDocument.defaultView?.ResizeObserver;
if (!inline || !draft || !Observer) return undefined;
const observer = new Observer(() => scrollAIPreviewEnd(editor, draft));
observer.observe(draft);
return () => observer.disconnect();
}, [editor, inline]);
const last =
document.length > 0
? NodeApi.last({ children: document, type: '' }, [])[0]
: null;
return (
<div ref={draftRef} data-editor-ai-draft="">
<EditorStatic
variant={inline ? 'none' : 'aiChat'}
editor={preview}
className={cn(
streaming &&
'[&_[data-editor-ai-end]]:after:ml-1.5 [&_[data-editor-ai-end]]:after:inline-block [&_[data-editor-ai-end]]:after:size-3 [&_[data-editor-ai-end]]:after:rounded-full [&_[data-editor-ai-end]]:after:bg-purple-600 [&_[data-editor-ai-end]]:after:align-middle [&_[data-editor-ai-end]]:after:content-[""]'
)}
/>
{streaming && (!last || !TextApi.isText(last) || !last.text) && (
<span
data-editor-ai-end=""
className="inline-block size-3 rounded-full bg-purple-600 align-middle"
/>
)}
</div>
);
}
export function AIMenu() {
const editor = useEditor();
const { api, read } = useEditor().plugin(AIChatPlugin);
const mode = usePluginStore(AIChatPlugin, 'mode');
const toolName = usePluginStore(AIChatPlugin, 'toolName');
const streaming = usePluginStore(AIChatPlugin, 'streaming');
const editAnchorKey = useEditorSelector((innerEditor) => {
const entry = innerEditor.read.selection.nodes().at(-1);
return entry && ElementApi.isElement(entry[0])
? innerEditor.key(entry[0])
: null;
});
const isFocusedLast = useFocusedLast();
const chatOpen = usePluginStore(AIChatPlugin, 'open');
const open = chatOpen && isFocusedLast;
const [value, setValue] = React.useState('');
const [input, setInput] = React.useState('');
const chat = usePluginStore(AIChatPlugin, 'chat');
const previewValue = usePluginStore(AIChatPlugin, 'previewValue');
const messages = chat?.messages;
const status = chat?.status ?? 'ready';
const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(
null
);
React.useEffect(() => {
if (!streaming && previewValue.length === 0) return undefined;
const anchorEntry = read.node();
if (!anchorEntry) return undefined;
const anchorDom = editor.api.dom.resolveDOMNode(anchorEntry[0]);
if (!anchorDom) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(
anchorDom.closest<HTMLElement>('[data-editor-ai-preview-wrapper]') ??
anchorDom
);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [editor, previewValue, read, streaming]);
const setOpen = (innerOpen: boolean) => {
if (innerOpen) {
if (!chatOpen) api.show();
} else if (chatOpen) {
api.hide({ focus: false });
}
};
React.useEffect(() => {
if (!chatOpen) {
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(null);
setInput('');
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}
let nextAnchor: HTMLElement | null = null;
const block =
editor.read.nodes.blocks().at(-1) ?? editor.read.nodes.block();
if (block && ElementApi.isElement(block[0])) {
nextAnchor = editor.api.dom.resolveDOMNode(block[0]);
}
if (!nextAnchor) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(nextAnchor);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [chatOpen, editor]);
const isLoading = status === 'streaming' || status === 'submitted';
React.useEffect(() => {
if (toolName !== 'edit' || mode !== 'chat' || isLoading) return undefined;
let anchorNode = editAnchorKey
? editor.read.nodes.get(editAnchorKey, {
match: ElementApi.isElement,
})
: undefined;
if (!anchorNode) {
anchorNode =
editor.read.nodes.blocks().at(-1) ?? editor.read.nodes.block();
}
if (!anchorNode) return undefined;
const block = editor.read.nodes.block({ at: anchorNode[1] });
const domNode = block ? editor.api.dom.resolveDOMNode(block[0]) : null;
if (!domNode) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(domNode);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [editAnchorKey, editor, isLoading, mode, toolName]);
if (isLoading && mode === 'insert') return null;
if (toolName === 'comment') return null;
if (!anchorElement) return null;
return (
<FloatingPopover open={open} onOpenChange={setOpen} modal={false}>
<FloatingPopoverAnchor element={anchorElement} />
<FloatingPopoverContent
className="w-(--floating-popover-anchor-width) max-w-[calc(100vw-16px)] border-none bg-transparent p-0 shadow-none ring-0"
onEscapeKeyDown={(e) => {
e.preventDefault();
api.hide();
}}
align="center"
side="bottom"
>
<Command
className="w-full rounded-lg border shadow-md"
value={value}
onValueChange={setValue}
>
{mode === 'chat' && previewValue.length > 0 && <AIChatEditor />}
{isLoading ? (
<div className="flex grow items-center gap-2 p-2 text-sm text-muted-foreground select-none">
<Loader2Icon className="size-4 animate-spin" />
{(messages?.length ?? 0) > 1 ? 'Editing...' : 'Thinking...'}
</div>
) : (
<CommandPrimitive.Input
className={cn(
'flex h-9 w-full min-w-0 border-input bg-transparent px-3 py-1 text-base outline-none transition-[color,box-shadow] placeholder:text-muted-foreground md:text-sm dark:bg-input/30',
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
'border-b focus-visible:ring-transparent'
)}
value={input}
onKeyDown={(e) => {
if (isHotkey('backspace')(e) && input.length === 0) {
e.preventDefault();
api.hide();
}
if (isHotkey('enter')(e) && !e.shiftKey && !value) {
e.preventDefault();
api.submit(input);
setInput('');
}
}}
onValueChange={setInput}
placeholder="Ask AI anything..."
data-editor-keep-selection-visible
autoFocus
/>
)}
{!isLoading && (
<CommandList>
<AIMenuItems
input={input}
setInput={setInput}
setValue={setValue}
/>
</CommandList>
)}
</Command>
</FloatingPopoverContent>
</FloatingPopover>
);
}
type EditorChatState =
| 'cursorCommand'
| 'cursorSuggestion'
| 'selectionCommand'
| 'selectionSuggestion';
const AICommentIcon = () => (
<svg
fill="none"
height="24"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M0 0h24v24H0z" fill="none" stroke="none" />
<path d="M8 9h8" />
<path d="M8 13h4.5" />
<path d="M10 19l-1 -1h-3a3 3 0 0 1 -3 -3v-8a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v4.5" />
<path d="M17.8 20.817l-2.172 1.138a.392 .392 0 0 1 -.568 -.41l.415 -2.411l-1.757 -1.707a.389 .389 0 0 1 .217 -.665l2.428 -.352l1.086 -2.193a.392 .392 0 0 1 .702 0l1.086 2.193l2.428 .352a.39 .39 0 0 1 .217 .665l-1.757 1.707l.414 2.41a.39 .39 0 0 1 -.567 .411l-2.172 -1.138z" />
</svg>
);
const aiChatItems = {
accept: {
icon: <Check />,
label: 'Accept',
value: 'accept',
onSelect: ({ editor }) => {
editor.plugin(AIChatPlugin).api.accept();
editor.api.dom.focus();
},
},
comment: {
icon: <AICommentIcon />,
label: 'Comment',
value: 'comment',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
mode: 'insert',
prompt:
'Please comment on the following content and provide reasonable and meaningful feedback.',
toolName: 'comment',
});
},
},
continueWrite: {
icon: <PenLine />,
label: 'Continue writing',
value: 'continueWrite',
onSelect: ({ editor, input }) => {
const ancestorNode = editor.read.nodes.block();
if (!ancestorNode) return;
const isEmpty = NodeApi.string(ancestorNode[0]).trim().length === 0;
editor.plugin(AIChatPlugin).api.submit(input, {
mode: 'insert',
prompt: isEmpty
? `<Document>
{editor}
</Document>
Start writing a new paragraph AFTER <Document> ONLY ONE SENTENCE`
: `<Block>
{block}
</Block>
Continue writing AFTER <Block> with ONLY ONE SENTENCE. DO NOT REPEAT THE TEXT.`,
toolName: 'generate',
});
},
},
discard: {
icon: <X />,
label: 'Discard',
shortcut: 'Escape',
value: 'discard',
onSelect: ({ editor }) => {
editor.plugin(AIChatPlugin).api.hide();
},
},
emojify: {
icon: <SmileIcon />,
label: 'Emojify',
value: 'emojify',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Add a small number of contextually relevant emojis within each block only. You may insert emojis, but do not remove, replace, or rewrite existing text, and do not modify Markdown syntax, links, or line breaks.',
toolName: 'edit',
});
},
},
explain: {
icon: <BadgeHelp />,
label: 'Explain',
value: 'explain',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt: {
default: 'Explain {editor}',
selecting: 'Explain',
},
toolName: 'generate',
});
},
},
fixSpelling: {
icon: <Check />,
label: 'Fix spelling & grammar',
value: 'fixSpelling',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Fix spelling, grammar, and punctuation errors within each block only, without changing meaning, tone, or adding new information.',
toolName: 'edit',
});
},
},
generateMarkdownSample: {
icon: <BookOpenCheck />,
label: 'Generate Markdown sample',
value: 'generateMarkdownSample',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt: 'Generate a markdown sample',
toolName: 'generate',
});
},
},
generateMdxSample: {
icon: <BookOpenCheck />,
label: 'Generate MDX sample',
value: 'generateMdxSample',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt: 'Generate a mdx sample',
toolName: 'generate',
});
},
},
improveWriting: {
icon: <Wand />,
label: 'Improve writing',
value: 'improveWriting',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Improve the writing for clarity and flow, without changing meaning or adding new information.',
toolName: 'edit',
});
},
},
insertBelow: {
icon: <ListEnd />,
label: 'Insert below',
value: 'insertBelow',
onSelect: ({ editor }) => {
/** Format: 'none' Fix insert table */
editor.plugin(AIChatPlugin).api.insertBelow({ format: 'none' });
editor.api.dom.focus();
},
},
makeLonger: {
icon: <ListPlus />,
label: 'Make longer',
value: 'makeLonger',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Make the content longer by elaborating on existing ideas within each block only, without changing meaning or adding new information.',
toolName: 'edit',
});
},
},
makeShorter: {
icon: <ListMinus />,
label: 'Make shorter',
value: 'makeShorter',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Make the content shorter by reducing verbosity within each block only, without changing meaning or removing essential information.',
toolName: 'edit',
});
},
},
replace: {
icon: <Check />,
label: 'Replace selection',
value: 'replace',
onSelect: ({ editor }) => {
editor.plugin(AIChatPlugin).api.replaceSelection();
editor.api.dom.focus();
},
},
simplifyLanguage: {
icon: <FeatherIcon />,
label: 'Simplify language',
value: 'simplifyLanguage',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
prompt:
'Simplify the language by using clearer and more straightforward wording within each block only, without changing meaning or adding new information.',
toolName: 'edit',
});
},
},
summarize: {
icon: <Album />,
label: 'Add a summary',
value: 'summarize',
onSelect: ({ editor, input }) => {
editor.plugin(AIChatPlugin).api.submit(input, {
mode: 'insert',
prompt: {
default: 'Summarize {editor}',
selecting: 'Summarize',
},
toolName: 'generate',
});
},
},
tryAgain: {
icon: <CornerUpLeft />,
label: 'Try again',
value: 'tryAgain',
onSelect: ({ editor }) => {
editor.plugin(AIChatPlugin).api.reload();
},
},
} satisfies Record<
string,
{
icon: React.ReactNode;
label: string;
value: string;
component?: React.ComponentType<{ menuState: EditorChatState }>;
filterItems?: boolean;
items?: Array<{ label: string; value: string }>;
shortcut?: string;
onSelect?: ({ editor, input }: { editor: Editor; input: string }) => void;
}
>;
const menuStateItems: Record<
EditorChatState,
Array<{
items: Array<(typeof aiChatItems)[keyof typeof aiChatItems]>;
heading?: string;
}>
> = {
cursorCommand: [
{
items: [
aiChatItems.comment,
aiChatItems.generateMdxSample,
aiChatItems.generateMarkdownSample,
aiChatItems.continueWrite,
aiChatItems.summarize,
aiChatItems.explain,
],
},
],
cursorSuggestion: [
{
items: [aiChatItems.accept, aiChatItems.discard, aiChatItems.tryAgain],
},
],
selectionCommand: [
{
items: [
aiChatItems.improveWriting,
aiChatItems.comment,
aiChatItems.emojify,
aiChatItems.makeLonger,
aiChatItems.makeShorter,
aiChatItems.fixSpelling,
aiChatItems.simplifyLanguage,
],
},
],
selectionSuggestion: [
{
items: [
aiChatItems.accept,
aiChatItems.discard,
aiChatItems.insertBelow,
aiChatItems.tryAgain,
],
},
],
};
export const AIMenuItems = ({
input,
setInput,
setValue,
}: {
input: string;
setInput: (value: string) => void;
setValue: (value: string) => void;
}) => {
const editor = useEditor();
const comments = editor.plugin(CommentsPlugin);
const messages = usePluginStore(AIChatPlugin, 'chat')?.messages;
const mode = usePluginStore(AIChatPlugin, 'mode');
const isSelecting = useEditorSelector(
(innerEditor2) =>
innerEditor2.read.selection.nodes().length > 0 ||
innerEditor2.read.selection.isExpanded()
);
const menuState: EditorChatState =
(messages?.length ?? 0) > 0
? mode === 'chat'
? 'selectionSuggestion'
: 'cursorSuggestion'
: isSelecting
? 'selectionCommand'
: 'cursorCommand';
const menuGroups = comments.installed
? menuStateItems[menuState]
: menuStateItems[menuState].map((group) => ({
...group,
items: group.items.filter((item) => item !== aiChatItems.comment),
}));
const firstItemValue = menuGroups[0]?.items[0]?.value;
React.useEffect(() => {
if (firstItemValue) setValue(firstItemValue);
}, [firstItemValue, setValue]);
return (
<>
{menuGroups.map((group) => (
<CommandGroup
key={group.heading ?? group.items[0]?.value}
heading={group.heading}
>
{group.items.map((menuItem) => (
<CommandItem
key={menuItem.value}
className="[&_svg]:text-muted-foreground"
value={menuItem.value}
onSelect={() => {
menuItem.onSelect?.({ editor, input });
setInput('');
}}
>
{menuItem.icon}
<span>{menuItem.label}</span>
</CommandItem>
))}
</CommandGroup>
))}
</>
);
};
export function AILoadingBar() {
const toolName = usePluginStore(AIChatPlugin, 'toolName');
const chat = usePluginStore(AIChatPlugin, 'chat');
const mode = usePluginStore(AIChatPlugin, 'mode');
const status = chat?.status ?? 'ready';
const { api } = useEditor().plugin(AIChatPlugin);
const isLoading = status === 'streaming' || status === 'submitted';
if (isLoading && (mode === 'insert' || toolName === 'comment')) {
return (
<div
className={cn(
'fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-md border border-border bg-muted px-3 py-1.5 text-muted-foreground text-sm shadow-md transition-all duration-300'
)}
>
<span className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
<span>{status === 'submitted' ? 'Thinking...' : 'Writing...'}</span>
<Button
size="sm"
variant="ghost"
className="flex items-center gap-1 text-xs"
onKeyDown={(event) => {
if (event.key !== 'Escape' || event.nativeEvent.isComposing) return;
event.preventDefault();
event.stopPropagation();
api.stop();
}}
onClick={() => {
api.stop();
}}
>
<PauseIcon className="h-4 w-4" />
Stop
<kbd className="ml-1 rounded bg-border px-1 font-mono text-[10px] text-muted-foreground shadow-sm">
Esc
</kbd>
</Button>
</div>
);
}
if (toolName === 'comment' && status === 'error') {
return (
<div
className="fixed bottom-4 left-1/2 z-50 flex max-w-[calc(100vw-2rem)] -translate-x-1/2 flex-wrap items-center gap-2 rounded-lg border bg-popover p-2 text-sm shadow-lg"
data-ai-comment-error=""
data-editor-keep-selection-visible
>
<p className="w-full px-1 text-destructive" role="alert">
Could not generate comments.
</p>
<Button onClick={() => api.reload()} size="sm" variant="outline">
Try again
</Button>
<Button onClick={() => api.hide()} size="sm" variant="outline">
<X data-icon="inline-start" />
Dismiss
</Button>
</div>
);
}
return null;
}
toArray
({
at: [],
type: headingTypes,
});
const prompt =
headings.length === 0
? 'Create a realistic table of contents for this document'
: 'Generate a table of contents that reflects the existing headings';
void editor.plugin(AIChatPlugin).api.submit('', {
mode: 'insert',
prompt,
toolName: 'generate',
});
},
},
};