From zbeyens. The source code is available on GitHub.

Plate
PlatePliteEditorsTemplates
GitHub16kGitHub
DiscordDiscord
    • Stream
    • Copilot
  • Discussion
    • Comments
    • Suggestion
    • Basic Blocks
      • Blockquote
      • Heading
      • Horizontal Rule
    • Callout
    • Code Block
    • Column
    • Date
    • Equation
    • Link
    • List Classic
    • Media
    • MentionElement
    • Table
    • Table of Contents
    • Footnote
    • Toggle
  • 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
    • Cursor Overlay
    • 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 KitAdd API RouteConfigure EnvironmentManual UsageInstallationAdd PluginsConfigure AIChatPluginBuild API RouteConnect useChatPrompt TemplatesClient PromptingServer PromptingKeyboard ShortcutsStreamingStreaming ExamplePlate PlusAPI ReferenceAIPluginAIChatPlugineditor.plugin(AIChatPlugin).api.submit(input, options?)editor.plugin(AIChatPlugin).api.reset(options?)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.update.aiChat.accept()editor.update.aiChat.insertBelow(options?)editor.update.aiChat.replaceSelection(options?)editor.plugin(AIChatPlugin).update.remove({ at: [] })editor.update.ai.insertNodes(nodes, options?)editor.update.ai.removeMarks(options?)editor.update.ai.removeNodes(options?)editor.update.ai.beginPreview(options?)editor.update.ai.acceptPreview()editor.update.ai.cancelPreview()editor.update.ai.discardPreview()editor.read.ai.hasPreview()editor.update.ai.undo()Registry Chat PreviewuseChatChunkAI 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 insertion with table, column, and code block support through editor.update.aiChat.insertChunk.
  • Insert and chat review modes with localized insert previews plus undo-safe batching through editor.update.ai.
  • Block selection aware transforms to replace or append entire sections using editor.update.aiChat.replaceSelection and editor.update.aiChat.insertBelow.
  • Direct integration with @ai-sdk/react so editor.plugin(AIChatPlugin).api.submit can stream responses from Vercel AI SDK helpers.
  • Suggestion and comment utilities that diff AI edits, accept/reject changes, and map AI feedback back to document ranges.
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 { AIChatPlugin, AIPlugin, useChatChunk } from '@platejs/ai/react';
import cloneDeep from 'lodash/cloneDeep.js';
import { ElementApi, PathApi, PLUGINS } from 'platejs';
import {
  PlateElement,
  PlateText,
  usePluginStore,
  type PlateElementProps,
  type PlateTextProps,
} from 'platejs/react';
import * as React from 'react';
 
import { cn } from '@/lib/utils';
import { AILoadingBar, AIMenu } from '@/components/editor/ai-menu';
 
import { CursorOverlayKit } from './cursor-overlay';
import { AIChatTransportPlugin, useChat } from './use-chat';
 
export function AILeaf(props: PlateTextProps<typeof AIPlugin>) {
  const streaming = usePluginStore(AIChatPlugin, 'streaming');
  const streamingLeaf = props.editor
    .plugin(AIChatPlugin)
    .read.node({ streaming: true });
 
  const isLast = streamingLeaf?.[0] === props.text;
 
  return (
    <PlateText
      className={cn(
        'border-b-2 border-b-purple-100 bg-purple-50 text-purple-800',
        'transition-all duration-200 ease-in-out',
        isLast &&
          streaming &&
          'after:ml-1.5 after:inline-block after:h-3 after:w-3 after:rounded-full after:bg-primary after:align-middle after:content-[""]'
      )}
      {...props}
    />
  );
}
 
export function AIAnchorElement(props: PlateElementProps<typeof AIChatPlugin>) {
  return (
    <PlateElement {...props}>
      <div className="h-[0.1px]" />
    </PlateElement>
  );
}
 
export const aiChatPlugin = AIChatTransportPlugin.extend({
  render: {
    afterContainer: AILoadingBar,
    afterEditable: AIMenu,
  },
  shortcuts: { show: { keys: 'mod+j' } },
  useHooks: ({ api, editor, read, store }) => {
    useChat();
 
    const mode = usePluginStore(AIChatPlugin, 'mode');
    const toolName = usePluginStore(AIChatPlugin, 'toolName');
    useChatChunk({
      onChunk: ({ chunk, isFirst, nodes, text: content }) => {
        if (isFirst && mode === 'insert') {
          const selection = editor.read.selection();
 
          if (!selection) return;
 
          const { path, startBlock, startInEmptyParagraph } =
            read.insertStart();
 
          editor.update.ai.beginPreview({
            originalBlocks:
              startInEmptyParagraph &&
              startBlock &&
              ElementApi.isElement(startBlock)
                ? [cloneDeep(startBlock)]
                : [],
          });
 
          editor.update({ history: 'skip' }).nodes.insert(
            {
              children: [{ text: '' }],
              type: editor.plugin(PLUGINS.aiChat).schema.type,
            },
            {
              at: PathApi.next(path),
            }
          );
          store.set({ streaming: true });
        }
 
        if (mode === 'insert' && nodes.length > 0) {
          if (!store.get('streaming')) return;
 
          editor.plugin(AIChatPlugin).update.insertChunk(chunk, {
            autoScroll: true,
            textProps: {
              [editor.plugin(PLUGINS.ai).schema.key]: true,
            },
          });
        }
 
        if (toolName === 'edit' && mode === 'chat') {
          editor
            .plugin(AIChatPlugin)
            .update.applySuggestions(content, { split: isFirst });
        }
      },
      onFinish: () => {
        api.stop();
      },
    });
  },
}).configure({ component: AIAnchorElement });
 
export const AIKit = [
  ...CursorOverlayKit,
  AIPlugin.configure({ component: AILeaf }),
  aiChatPlugin,
];
'use client';
 
import { AIChatPlugin, AIPlugin, useChatChunk } from '@platejs/ai/react';
import cloneDeep from 'lodash/cloneDeep.js';
import { ElementApi, PathApi, PLUGINS } from 'platejs';
import {
  PlateElement,
  PlateText,
  usePluginStore,
  type PlateElementProps,
  type PlateTextProps,
} from 'platejs/react';
import * as React from 'react';
 
import { cn } from '@/lib/utils';
import { AILoadingBar, AIMenu } from '@/components/editor/ai-menu';
 
import { CursorOverlayKit } 






































































































  • AIMenu: Floating command surface for prompts, tool shortcuts, and chat review.
  • AILoadingBar: Displays streaming status at the editor container.
  • AIAnchorElement: Invisible anchor node used to position the floating menu during streaming.
  • AILeaf: Renders AI-marked text with subtle styling.

Add Kit

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




Add API Route

Expose a streaming command endpoint that proxies your model provider:

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





























































































































































































































































































































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 @platejs/markdown @ai-sdk/react ai
pnpm add @platejs/ai @platejs/markdown @ai-sdk/react ai

@platejs/suggestion is optional but required for diff-based edit suggestions.

Add Plugins

import { createPlateEditor } from 'platejs/react';
import { AIChatPlugin, AIPlugin } from '@platejs/ai/react';
import { MarkdownPlugin } from '@platejs/markdown';
 
export const editor = createPlateEditor({
  plugins: [
    MarkdownPlugin,
    AIPlugin,
    AIChatPlugin, // extended in the next step
  ],
});
import { createPlateEditor } from 'platejs/react';









  • Editor selection supplies text and multi-node targets to AIChatPlugin.
  • MarkdownPlugin: Provides Markdown serialization used by streaming utilities.
  • AIPlugin: Adds the AI mark and transforms for undoing AI batches.
  • AIChatPlugin: Supplies the AI combobox, API helpers, and transforms.

Use AIPlugin.configure({ component }) with your own element (or AILeaf) to highlight AI-generated text.

Configure AIChatPlugin

Configure AIChatPlugin to hook streaming and edits. The plugin owns chunk insertion, preview snapshots, and suggestion updates.

import cloneDeep from 'lodash/cloneDeep';
import {
  AIChatPlugin,
  AIPlugin,
  useChatChunk,
} from '@platejs/ai/react';
import { ElementApi, PathApi, PLUGINS } from 'platejs';
import { usePluginStore } from 'platejs/react';
 
export const aiChatPlugin = AIChatPlugin.extend({
  initialState: {
    chatOptions: {
      api: '/api/ai/command',
      body: {
        model: 'openai/gpt-4o-mini',
      },
    },
    trigger: ' ',
    triggerPreviousCharPattern: /































































  • useChatChunk: Watches the stored chat adapter and yields incremental chunks.
  • editor.update.ai.beginPreview: Captures the rollback slice and selection before the first preview chunk.
  • update.insertChunk: Streams Markdown/MDX into the document while reusing the active block.
  • update.applySuggestions: Converts edit responses into transient suggestion nodes.

Provide your own render components (toolbar button, floating menu, etc.) when you extend the plugin.

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 { convertToCoreMessages, streamText } from 'ai';
import { createBaseEditor } 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 = createBaseEditor({

















  • ctx.children and ctx.selection are rehydrated into a Plite 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 useChat and useChatChunk can process tokens incrementally.

Connect useChat

Bridge the editor and your model endpoint with @ai-sdk/react. Store helpers on the plugin so transforms can reload, stop, or show chat state.

import { useEffect } from 'react';
 
import { type UIMessage, DefaultChatTransport } from 'ai';
import { useChat } from '@ai-sdk/react';
import { AIChatPlugin, createAIChatAdapter } from '@platejs/ai/react';
import { useEditorPlugin } from 'platejs/react';
 
type ChatMessage = UIMessage<{}, { toolName: 'comment' | 'edit' | 'generate'; comment?: unknown }>;
 
export const useEditorAIChat = () => {
  const { store } = useEditorPlugin

















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 { AIChatPlugin, AIPlugin } from '@platejs/ai/react';
import { CommentPlugin } from '@platejs/comment/react';
import { SuggestionPlugin } from '@platejs/suggestion/react';
import { Command as CommandPrimitive } from 'cmdk';
import {
  Album,
  BadgeHelp,
  BookOpenCheck,
  Check,
  CornerUpLeft,
  FeatherIcon,
  ListEnd,
  ListMinus,
  ListPlus,
  Loader2Icon,
  PauseIcon,
  PenLine,
  SmileIcon,
  Wand,
  X,
} 














































































































































































































































































































































































































































































































































































































































































































































































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',
  },
  toolName: 

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(aiChatPlugin).store.set({
  chatOptions: {
    api: '/api/ai/command',
    body: {
      model: 'openai/gpt-4o-mini',
      tone: 'playful',
      temperature: 0.4,
    },
  },
});
editor.plugin(aiChatPlugin).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

The installed plugin keeps complex layouts intact while responses arrive:

  • aiChat.update.insertChunk(chunk, options) updates the active block and appends complete blocks.
  • aiChat.api.deserializeChunk and aiChat.api.deserializeInlineChunk decode complete or inline chunks.
  • aiChat.read.serializeChunk serializes from the current snapshot for drift checks.

Call aiChat.api.stop() when streaming finishes.

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 transforms to remove AI nodes or undo the last AI batch. 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 per text node rather than as decorations.

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.

    Store the adapter returned by createAIChatAdapter(useChat(...)) so API calls can access chat state and controls.

    Node snapshots paired with editor-scoped node keys for diff edit suggestions (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 registered by the copied AI chat editor 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(options?)

Clears chat state, removes AI nodes, and optionally undoes the last AI batch.

Parameters

    Pass undo: false to keep streamed content.

editor.read.aiChat.node(options?)

Retrieves the first AI node that matches the specified criteria.

Parameters

    Set anchor: true to get the anchor node or streaming: true to retrieve the node currently being streamed into.

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 streaming and calls chat.stop.

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

Opens the AI menu, clears previous chat messages, and resets tool state.

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

Closes the AI menu, optionally undoing the last AI batch and refocusing the editor.

Parameters

    Set focus: false to keep focus outside the editor or undo: false to preserve inserted content.

editor.update.aiChat.accept()

Accepts the latest response. In insert mode it removes AI marks and places the caret at the end of the streamed content. In chat mode it applies the pending suggestions.

editor.update.aiChat.insertBelow(options?)

Inserts the stored chat preview below the current selection or block selection.

Parameters

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

editor.update.aiChat.replaceSelection(options?)

Replaces the current selection or block selection with the stored chat preview.

Parameters

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

editor.plugin(AIChatPlugin).update.remove({ at: [] })

Removes every temporary AI Chat anchor without a feature-specific wrapper.

editor
  .plugin(AIChatPlugin)
  .update({ history: 'skip' })
  .remove({ at: [] });
editor
  .plugin(AIChatPlugin)
  .update({ history: 'skip' })
  .remove({ at: [] });

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.

editor.update.ai.beginPreview(options?)

Captures the rollback slice and selection for insert-mode AI preview. Call it once before writing the first unsaved preview chunk.

Parameters

    Top-level blocks that the preview will overwrite. Use [] when preview inserts after existing content.

Returnsboolean

    Returns true when a new preview rollback point was stored, or false when preview state already exists.

editor.update.ai.acceptPreview()

Commits the active preview as one fresh undoable batch, strips preview-only markers, and clears preview bookkeeping.

Returnsboolean

    Returns true when an active preview was committed.

editor.update.ai.cancelPreview()

Restores the rollback point for the active preview and clears preview bookkeeping.

Returnsboolean

    Returns true when an active preview was restored.

editor.update.ai.discardPreview()

Clears preview bookkeeping without restoring content. Use it when the previewed content should stay in place.

Returnsboolean

    Returns true when active preview bookkeeping was cleared.

editor.read.ai.hasPreview()

Reports whether an insert-mode preview rollback point is currently active.

Returnsboolean

    Returns true when preview rollback state exists.

editor.update.ai.undo()

Undoes the latest batch marked by editor.update.ai.markBatch(). If an insert-mode preview is active, it cancels that preview first.

Registry Chat Preview

The copied ai-menu item includes AIChatEditor. It creates the preview editor, deserializes response Markdown, and publishes previewValue to the AI Chat plugin beside the UI that consumes it.

'use client';
 
import { AIChatPlugin, AIPlugin } from '@platejs/ai/react';
import { CommentPlugin } from '@platejs/comment/react';
import { SuggestionPlugin } from '@platejs/suggestion/react';
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 { ElementApi, isHotkey, NodeApi } from 'platejs';
import {
  useEditorPlugin,
  useEditorRuntimeState,
  usePlateEditor,
  useEditorSelector,
  useFocusedLast,
  useHotkeys,
  usePluginStore,
  type PlateEditor,
  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';
 
export function AIChatEditor({ content }: { content: string }) {
  const aiEditor = usePlateEditor({
    plugins: BaseEditorKit,
  });
  const { store } = useEditorPlugin(AIChatPlugin);
  const document = React.useMemo(
    () => aiEditor.api.markdown.deserialize(content),
    [aiEditor, content]
  );
 
  useEditorRuntimeState(aiEditor, (state) => state.children());
 
  React.useEffect(() => {
    aiEditor.update({ history: 'skip' }).value.replace(document);
    store.set({ previewValue: aiEditor.read.children() });
  }, [aiEditor, document, store]);
 
  return <EditorStatic variant="aiChat" editor={aiEditor} />;
}
 
export function AIMenu() {
  const editor = useEditor();
  const { api, read } = useEditorPlugin(AIChatPlugin);
  const mode = usePluginStore(AIChatPlugin, 'mode');
  const toolName = usePluginStore(AIChatPlugin, 'toolName');
 
  const streaming = usePluginStore(AIChatPlugin, 'streaming');
  const isSelecting = useEditorSelector(
    (innerEditor) =>
      innerEditor.read.selection.nodes().length > 0 ||
      innerEditor.read.selection.isExpanded()
  );
  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 lastAssistantMessage = usePluginStore(
    AIChatPlugin,
    'lastAssistantMessage'
  );
 
  const messages = chat?.messages;
  const status = chat?.status ?? 'ready';
  const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(
    null
  );
 
  const content = lastAssistantMessage?.parts.find(
    (part) => part.type === 'text'
  )?.text;
 
  React.useEffect(() => {
    if (!streaming) return undefined;
 
    const anchorEntry = read.node({ anchor: true });
    if (!anchorEntry) return undefined;
 
    const anchorDom = editor.api.dom.resolveDOMNode(anchorEntry[0]);
    if (!anchorDom) return undefined;
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(anchorDom);
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [editor, read, streaming]);
 
  const setOpen = (innerOpen: boolean) => {
    if (innerOpen) {
      api.show();
    } else {
      api.hide();
    }
  };
 
  React.useEffect(() => {
    if (!chatOpen) {
      const animationFrame = window.requestAnimationFrame(() => {
        setAnchorElement(null);
        setInput('');
      });
 
      return () => {
        window.cancelAnimationFrame(animationFrame);
      };
    }
 
    let nextAnchor: HTMLElement | null = null;
    const selectedBlock = editor.read.nodes.blocks().at(-1);
 
    if (selectedBlock) {
      if (!ElementApi.isElement(selectedBlock[0])) return undefined;
 
      nextAnchor = editor.api.dom.resolveDOMNode(selectedBlock[0]);
    } else if (editor.read.selection.isCollapsed()) {
      const ancestorEntry = editor.read.nodes.block();
 
      if (!ancestorEntry) return undefined;
 
      const [ancestor] = ancestorEntry;
 
      if (
        !editor.read.selection.isAtBlockEnd() &&
        ElementApi.isElement(ancestor) &&
        !editor.read.nodes.isEmpty(ancestor)
      ) {
        editor.update.selection.setNodes([ancestorEntry[1]]);
      }
 
      nextAnchor = editor.api.dom.resolveDOMNode(ancestor);
    } else if (editor.read.selection.isExpanded()) {
      const block = editor.read((state) => state.nodes.blocks()).at(-1);
      nextAnchor = block ? editor.api.dom.resolveDOMNode(block[0]) : null;
    }
 
    if (!nextAnchor) return undefined;
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(nextAnchor);
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [chatOpen, editor]);
 
  useHotkeys('esc', () => {
    api.stop();
  });
 
  const isLoading = status === 'streaming' || status === 'submitted';
 
  React.useEffect(() => {
    if (toolName !== 'edit' || mode !== 'chat' || isLoading) return undefined;
 
    let anchorNode = editor
      .plugin(SuggestionPlugin)
      .read.nodes({ transient: true })
      .at(-1);
 
    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);
    };
  }, [editor, isLoading, mode, toolName]);
 
  if (isLoading && mode === 'insert') return null;
 
  if (toolName === 'comment') return null;
 
  if (toolName === 'edit' && mode === 'chat' && isLoading) return null;
 
  if (!anchorElement) return null;
 
  return (
    <FloatingPopover open={open} onOpenChange={setOpen} modal={false}>
      <FloatingPopoverAnchor element={anchorElement} />
 
      <FloatingPopoverContent
        className="border-none bg-transparent p-0 shadow-none ring-0"
        style={{
          width: anchorElement?.offsetWidth,
        }}
        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' &&
            isSelecting &&
            content &&
            toolName === 'generate' && <AIChatEditor content={content} />}
 
          {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-plate-focus
              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 }) => {
      const { mode, toolName } = editor.plugin(AIChatPlugin).store.get();
 
      if (mode === 'chat' && toolName === 'generate') {
        editor.plugin(AIChatPlugin).update.replaceSelection();
        return;
      }
 
      editor.plugin(AIChatPlugin).update.accept();
      editor.update((tx) => {
        const end = tx.points.end([]);
 
        if (!end) return;
 
        tx.selection.set({ anchor: end, focus: end });
      });
      editor.api.dom.focus({ retries: 5 });
    },
  },
  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`
          : 'Continue writing AFTER <Block> ONLY ONE SENTENCE. DONT REPEAT THE TEXT.',
        toolName: 'generate',
      });
    },
  },
  discard: {
    icon: <X />,
    label: 'Discard',
    shortcut: 'Escape',
    value: 'discard',
    onSelect: ({ editor }) => {
      editor.plugin(AIPlugin).update.undo();
      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).update.insertBelow({ format: 'none' });
    },
  },
  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).update.replaceSelection();
    },
  },
  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: PlateEditor;
      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 messages = usePluginStore(AIChatPlugin, 'chat')?.messages;
  const isSelecting = useEditorSelector(
    (innerEditor2) =>
      innerEditor2.read.selection.nodes().length > 0 ||
      innerEditor2.read.selection.isExpanded()
  );
 
  const menuState: EditorChatState =
    (messages?.length ?? 0) > 0
      ? isSelecting
        ? 'selectionSuggestion'
        : 'cursorSuggestion'
      : isSelecting
        ? 'selectionCommand'
        : 'cursorCommand';
  const menuGroups = menuStateItems[menuState];
 
  React.useEffect(() => {
    const firstItem = menuStateItems[menuState][0]?.items[0];
 
    if (firstItem) {
      setValue(firstItem.value);
    }
  }, [menuState, 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 editor = useEditor();
 
  const toolName = usePluginStore(AIChatPlugin, 'toolName');
  const chat = usePluginStore(AIChatPlugin, 'chat');
  const mode = usePluginStore(AIChatPlugin, 'mode');
 
  const status = chat?.status ?? 'ready';
 
  const { api } = useEditorPlugin(AIChatPlugin);
 
  const isLoading = status === 'streaming' || status === 'submitted';
 
  const handleComments = (type: 'accept' | 'reject') => {
    if (type === 'accept') {
      editor.plugin(CommentPlugin).update.clearTransient();
    }
 
    if (type === 'reject') {
      editor.plugin(CommentPlugin).update.unsetMark({ transient: true });
    }
 
    api.hide();
  };
 
  useHotkeys('esc', () => {
    api.stop();
  });
 
  if (
    isLoading &&
    (mode === 'insert' ||
      toolName === 'comment' ||
      (toolName === 'edit' && mode === 'chat'))
  ) {
    return (
      <div
        className={cn(
          '-translate-x-1/2 absolute bottom-4 left-1/2 z-20 flex 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"
          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 === 'ready') {
    return (
      <div
        className={cn(
          '-translate-x-1/2 absolute bottom-4 left-1/2 z-50 flex flex-col items-center gap-0 rounded-xl border border-border/50 bg-popover p-1 text-muted-foreground text-sm shadow-xl backdrop-blur-sm',
          'p-3'
        )}
      >
        {/* Header with controls */}
        <div className="flex w-full items-center justify-between gap-3">
          <div className="flex items-center gap-5">
            <Button
              size="sm"
              disabled={isLoading}
              onClick={() => {
                handleComments('accept');
              }}
            >
              Accept
            </Button>
 
            <Button
              size="sm"
              disabled={isLoading}
              onClick={() => {
                handleComments('reject');
              }}
            >
              Reject
            </Button>
          </div>
        </div>
      </div>
    );
  }
 
  return null;
}
'use client';
 
import { AIChatPlugin, AIPlugin } from '@platejs/ai/react';
import { CommentPlugin } from '@platejs/comment/react';
import { SuggestionPlugin } from '@platejs/suggestion/react';
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'














































































































































































































































































































































































































































































































































































































































































































































































useChatChunk

Streams chat responses chunk-by-chunk and gives you full control over insertion.

Parameters

    Handle each streamed chunk.

    Called when streaming finishes.

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.update.insertChunk(chunk, { autoScroll: true });
const aiChat = editor.plugin(AIChatPlugin);
 
const prompt = aiChat.read.prompt({ prompt: 'Improve this' });
aiChat.update.insertChunk(chunk, { autoScroll: true });
NamespaceMethods
ai.apifindTextRangeInBlock
aiChat.apideserializeChunk, deserializeInlineChunk, hide, reload, reset, show, stop, submit
aiChat.readcommentRange, insertStart, markdown, node, prompt, resolvePlaceholders, serializeChunk
aiChat.store.getlastAssistantMessage
aiChat.updateaccept, acceptSuggestions, applySuggestions, applyTableCellSuggestion, insert, insertBelow, insertChunk, rejectSuggestions, remove, replaceSelection, set

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 Plite Range or null.

Customization

Adding Custom AI Commands

'use client';
 
import { AIChatPlugin, AIPlugin } from '@platejs/ai/react';
import { CommentPlugin } from '@platejs/comment/react';
import { SuggestionPlugin } from '@platejs/suggestion/react';
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 { ElementApi, isHotkey, NodeApi } from 'platejs';
import {
  useEditorPlugin,
  useEditorRuntimeState,
  usePlateEditor,
  useEditorSelector,
  useFocusedLast,
  useHotkeys,
  usePluginStore,
  type PlateEditor,
  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';
 
export function AIChatEditor({ content }: { content: string }) {
  const aiEditor = usePlateEditor({
    plugins: BaseEditorKit,
  });
  const { store } = useEditorPlugin(AIChatPlugin);
  const document = React.useMemo(
    () => aiEditor.api.markdown.deserialize(content),
    [aiEditor, content]
  );
 
  useEditorRuntimeState(aiEditor, (state) => state.children());
 
  React.useEffect(() => {
    aiEditor.update({ history: 'skip' }).value.replace(document);
    store.set({ previewValue: aiEditor.read.children() });
  }, [aiEditor, document, store]);
 
  return <EditorStatic variant="aiChat" editor={aiEditor} />;
}
 
export function AIMenu() {
  const editor = useEditor();
  const { api, read } = useEditorPlugin(AIChatPlugin);
  const mode = usePluginStore(AIChatPlugin, 'mode');
  const toolName = usePluginStore(AIChatPlugin, 'toolName');
 
  const streaming = usePluginStore(AIChatPlugin, 'streaming');
  const isSelecting = useEditorSelector(
    (innerEditor) =>
      innerEditor.read.selection.nodes().length > 0 ||
      innerEditor.read.selection.isExpanded()
  );
  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 lastAssistantMessage = usePluginStore(
    AIChatPlugin,
    'lastAssistantMessage'
  );
 
  const messages = chat?.messages;
  const status = chat?.status ?? 'ready';
  const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(
    null
  );
 
  const content = lastAssistantMessage?.parts.find(
    (part) => part.type === 'text'
  )?.text;
 
  React.useEffect(() => {
    if (!streaming) return undefined;
 
    const anchorEntry = read.node({ anchor: true });
    if (!anchorEntry) return undefined;
 
    const anchorDom = editor.api.dom.resolveDOMNode(anchorEntry[0]);
    if (!anchorDom) return undefined;
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(anchorDom);
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [editor, read, streaming]);
 
  const setOpen = (innerOpen: boolean) => {
    if (innerOpen) {
      api.show();
    } else {
      api.hide();
    }
  };
 
  React.useEffect(() => {
    if (!chatOpen) {
      const animationFrame = window.requestAnimationFrame(() => {
        setAnchorElement(null);
        setInput('');
      });
 
      return () => {
        window.cancelAnimationFrame(animationFrame);
      };
    }
 
    let nextAnchor: HTMLElement | null = null;
    const selectedBlock = editor.read.nodes.blocks().at(-1);
 
    if (selectedBlock) {
      if (!ElementApi.isElement(selectedBlock[0])) return undefined;
 
      nextAnchor = editor.api.dom.resolveDOMNode(selectedBlock[0]);
    } else if (editor.read.selection.isCollapsed()) {
      const ancestorEntry = editor.read.nodes.block();
 
      if (!ancestorEntry) return undefined;
 
      const [ancestor] = ancestorEntry;
 
      if (
        !editor.read.selection.isAtBlockEnd() &&
        ElementApi.isElement(ancestor) &&
        !editor.read.nodes.isEmpty(ancestor)
      ) {
        editor.update.selection.setNodes([ancestorEntry[1]]);
      }
 
      nextAnchor = editor.api.dom.resolveDOMNode(ancestor);
    } else if (editor.read.selection.isExpanded()) {
      const block = editor.read((state) => state.nodes.blocks()).at(-1);
      nextAnchor = block ? editor.api.dom.resolveDOMNode(block[0]) : null;
    }
 
    if (!nextAnchor) return undefined;
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(nextAnchor);
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [chatOpen, editor]);
 
  useHotkeys('esc', () => {
    api.stop();
  });
 
  const isLoading = status === 'streaming' || status === 'submitted';
 
  React.useEffect(() => {
    if (toolName !== 'edit' || mode !== 'chat' || isLoading) return undefined;
 
    let anchorNode = editor
      .plugin(SuggestionPlugin)
      .read.nodes({ transient: true })
      .at(-1);
 
    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);
    };
  }, [editor, isLoading, mode, toolName]);
 
  if (isLoading && mode === 'insert') return null;
 
  if (toolName === 'comment') return null;
 
  if (toolName === 'edit' && mode === 'chat' && isLoading) return null;
 
  if (!anchorElement) return null;
 
  return (
    <FloatingPopover open={open} onOpenChange={setOpen} modal={false}>
      <FloatingPopoverAnchor element={anchorElement} />
 
      <FloatingPopoverContent
        className="border-none bg-transparent p-0 shadow-none ring-0"
        style={{
          width: anchorElement?.offsetWidth,
        }}
        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' &&
            isSelecting &&
            content &&
            toolName === 'generate' && <AIChatEditor content={content} />}
 
          {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-plate-focus
              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 }) => {
      const { mode, toolName } = editor.plugin(AIChatPlugin).store.get();
 
      if (mode === 'chat' && toolName === 'generate') {
        editor.plugin(AIChatPlugin).update.replaceSelection();
        return;
      }
 
      editor.plugin(AIChatPlugin).update.accept();
      editor.update((tx) => {
        const end = tx.points.end([]);
 
        if (!end) return;
 
        tx.selection.set({ anchor: end, focus: end });
      });
      editor.api.dom.focus({ retries: 5 });
    },
  },
  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`
          : 'Continue writing AFTER <Block> ONLY ONE SENTENCE. DONT REPEAT THE TEXT.',
        toolName: 'generate',
      });
    },
  },
  discard: {
    icon: <X />,
    label: 'Discard',
    shortcut: 'Escape',
    value: 'discard',
    onSelect: ({ editor }) => {
      editor.plugin(AIPlugin).update.undo();
      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).update.insertBelow({ format: 'none' });
    },
  },
  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).update.replaceSelection();
    },
  },
  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: PlateEditor;
      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 messages = usePluginStore(AIChatPlugin, 'chat')?.messages;
  const isSelecting = useEditorSelector(
    (innerEditor2) =>
      innerEditor2.read.selection.nodes().length > 0 ||
      innerEditor2.read.selection.isExpanded()
  );
 
  const menuState: EditorChatState =
    (messages?.length ?? 0) > 0
      ? isSelecting
        ? 'selectionSuggestion'
        : 'cursorSuggestion'
      : isSelecting
        ? 'selectionCommand'
        : 'cursorCommand';
  const menuGroups = menuStateItems[menuState];
 
  React.useEffect(() => {
    const firstItem = menuStateItems[menuState][0]?.items[0];
 
    if (firstItem) {
      setValue(firstItem.value);
    }
  }, [menuState, 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 editor = useEditor();
 
  const toolName = usePluginStore(AIChatPlugin, 'toolName');
  const chat = usePluginStore(AIChatPlugin, 'chat');
  const mode = usePluginStore(AIChatPlugin, 'mode');
 
  const status = chat?.status ?? 'ready';
 
  const { api } = useEditorPlugin(AIChatPlugin);
 
  const isLoading = status === 'streaming' || status === 'submitted';
 
  const handleComments = (type: 'accept' | 'reject') => {
    if (type === 'accept') {
      editor.plugin(CommentPlugin).update.clearTransient();
    }
 
    if (type === 'reject') {
      editor.plugin(CommentPlugin).update.unsetMark({ transient: true });
    }
 
    api.hide();
  };
 
  useHotkeys('esc', () => {
    api.stop();
  });
 
  if (
    isLoading &&
    (mode === 'insert' ||
      toolName === 'comment' ||
      (toolName === 'edit' && mode === 'chat'))
  ) {
    return (
      <div
        className={cn(
          '-translate-x-1/2 absolute bottom-4 left-1/2 z-20 flex 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"
          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 === 'ready') {
    return (
      <div
        className={cn(
          '-translate-x-1/2 absolute bottom-4 left-1/2 z-50 flex flex-col items-center gap-0 rounded-xl border border-border/50 bg-popover p-1 text-muted-foreground text-sm shadow-xl backdrop-blur-sm',
          'p-3'
        )}
      >
        {/* Header with controls */}
        <div className="flex w-full items-center justify-between gap-3">
          <div className="flex items-center gap-5">
            <Button
              size="sm"
              disabled={isLoading}
              onClick={() => {
                handleComments('accept');
              }}
            >
              Accept
            </Button>
 
            <Button
              size="sm"
              disabled={isLoading}
              onClick={() => {
                handleComments('reject');
              }}
            >
              Reject
            </Button>
          </div>
        </div>
      </div>
    );
  }
 
  return null;
}
'use client';
 
import { AIChatPlugin, AIPlugin } from '@platejs/ai/react';
import { CommentPlugin } from '@platejs/comment/react';
import { SuggestionPlugin } from '@platejs/suggestion/react';
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'














































































































































































































































































































































































































































































































































































































































































































































































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 { PLUGINS } from 'platejs';
import { BookIcon } from 'lucide-react';
 
export const aiChatItems = {
  generateTOC: {
    icon: <BookIcon />,
    label: 'Generate table of contents',
    value: 'generateTOC',
    onSelect: ({ editor }) => {
      const headingTypes = [PLUGINS.heading, PLUGINS.heading, PLUGINS.heading]
        .map((name) => editor.plugin(name))
        .flatMap((plugin) =>
          plugin.installed ? [plugin.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 { PLUGINS } from 'platejs';
import { BookIcon } from 'lucide-react';
 
export const aiChatItems = {
  generateTOC: {
    icon: <BookIcon />,
    label: 'Generate table of contents',
    value: 'generateTOC',
    onSelect: ({ editor }) => {
      const headingTypes = [PLUGINS.heading, PLUGINS.heading, PLUGINS.heading]
        .map((name) => editor.plugin(name))
        .flatMap



















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. For example, 'edit' enables diff-based suggestions, while 'comment' can map feedback through aiChat.read.commentRange.

from
'./cursor-overlay'
;
import { AIChatTransportPlugin, useChat } from './use-chat';
export function AILeaf(props: PlateTextProps<typeof AIPlugin>) {
const streaming = usePluginStore(AIChatPlugin, 'streaming');
const streamingLeaf = props.editor
.plugin(AIChatPlugin)
.read.node({ streaming: true });
const isLast = streamingLeaf?.[0] === props.text;
return (
<PlateText
className={cn(
'border-b-2 border-b-purple-100 bg-purple-50 text-purple-800',
'transition-all duration-200 ease-in-out',
isLast &&
streaming &&
'after:ml-1.5 after:inline-block after:h-3 after:w-3 after:rounded-full after:bg-primary after:align-middle after:content-[""]'
)}
{...props}
/>
);
}
export function AIAnchorElement(props: PlateElementProps<typeof AIChatPlugin>) {
return (
<PlateElement {...props}>
<div className="h-[0.1px]" />
</PlateElement>
);
}
export const aiChatPlugin = AIChatTransportPlugin.extend({
render: {
afterContainer: AILoadingBar,
afterEditable: AIMenu,
},
shortcuts: { show: { keys: 'mod+j' } },
useHooks: ({ api, editor, read, store }) => {
useChat();
const mode = usePluginStore(AIChatPlugin, 'mode');
const toolName = usePluginStore(AIChatPlugin, 'toolName');
useChatChunk({
onChunk: ({ chunk, isFirst, nodes, text: content }) => {
if (isFirst && mode === 'insert') {
const selection = editor.read.selection();
if (!selection) return;
const { path, startBlock, startInEmptyParagraph } =
read.insertStart();
editor.update.ai.beginPreview({
originalBlocks:
startInEmptyParagraph &&
startBlock &&
ElementApi.isElement(startBlock)
? [cloneDeep(startBlock)]
: [],
});
editor.update({ history: 'skip' }).nodes.insert(
{
children: [{ text: '' }],
type: editor.plugin(PLUGINS.aiChat).schema.type,
},
{
at: PathApi.next(path),
}
);
store.set({ streaming: true });
}
if (mode === 'insert' && nodes.length > 0) {
if (!store.get('streaming')) return;
editor.plugin(AIChatPlugin).update.insertChunk(chunk, {
autoScroll: true,
textProps: {
[editor.plugin(PLUGINS.ai).schema.key]: true,
},
});
}
if (toolName === 'edit' && mode === 'chat') {
editor
.plugin(AIChatPlugin)
.update.applySuggestions(content, { split: isFirst });
}
},
onFinish: () => {
api.stop();
},
});
},
}).configure({ component: AIAnchorElement });
export const AIKit = [
...CursorOverlayKit,
AIPlugin.configure({ component: AILeaf }),
aiChatPlugin,
];
plugins: [
// ...otherPlugins,
...AIKit,
],
});
from
'next/server'
;
import { createBaseEditor, nanoid } from 'platejs';
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 {
buildEditTableMultiCellPrompt,
getChooseToolPrompt,
getCommentPrompt,
getEditPrompt,
getGeneratePrompt,
} from './prompt';
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 = createBaseEditor({
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 AIChatRequestContext,
  type AIChatRequestRefs,
  resolveAIChatRequestContext,
} from '@platejs/ai';
import type { MarkdownEditor } from '@platejs/markdown';
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 { createBaseEditor, nanoid } from 'platejs';
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 {
  buildEditTableMultiCellPrompt,
  getChooseToolPrompt,
  getCommentPrompt,
  getEditPrompt,
  getGeneratePrompt,
} from './prompt';
 
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 = createBaseEditor({
    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 { AIChatPlugin, AIPlugin } from '@platejs/ai/react';
import { MarkdownPlugin } from '@platejs/markdown';
export const editor = createPlateEditor({
plugins: [
MarkdownPlugin,
AIPlugin,
AIChatPlugin, // extended in the next step
],
});
^
\s
?$
/
,
},
useHooks: ({ editor, read, store, update }) => {
const mode = usePluginStore(AIChatPlugin, 'mode');
const toolName = usePluginStore(AIChatPlugin, 'toolName');
useChatChunk({
onChunk: ({ chunk, isFirst, nodes, text: content }) => {
if (isFirst && mode === 'insert') {
const selection = editor.read.selection();
if (!selection) return;
const { startBlock, startInEmptyParagraph } =
read.insertStart();
editor.update.ai.beginPreview({
originalBlocks:
startInEmptyParagraph &&
startBlock &&
ElementApi.isElement(startBlock)
? [cloneDeep(startBlock)]
: [],
});
editor.update({ history: 'skip' }, (tx) => {
tx.nodes.insert(
{
children: [{ text: '' }],
type: editor.plugin(PLUGINS.aiChat).schema.type,
},
{
at: PathApi.next(selection.focus.path.slice(0, 1)),
}
);
});
store.set({ streaming: true });
}
if (mode === 'insert' && nodes.length > 0) {
if (!store.get('streaming')) return;
update.insertChunk(chunk, {
autoScroll: true,
textProps: {
[editor.plugin(PLUGINS.ai).schema.key]: true,
},
});
}
if (toolName === 'edit' && mode === 'chat') {
update.applySuggestions(content, { split: isFirst });
}
},
onFinish: () => {
store.set({
_blockChunks: '',
_blockPath: null,
_mdxName: null,
streaming: false,
});
},
});
},
});
import cloneDeep from 'lodash/cloneDeep';
import {
  AIChatPlugin,
  AIPlugin,
  useChatChunk,
} from '@platejs/ai/react';
import { ElementApi, PathApi, PLUGINS } from 'platejs';
import { usePluginStore } from 'platejs/react';
 
export const aiChatPlugin = AIChatPlugin.extend({
  initialState: {
    chatOptions: {
      api: '/api/ai/command',
      body: {
        model: 'openai/gpt-4o-mini',
      },
    },
    trigger: ' ',
    triggerPreviousCharPattern: /^\s?$/,
  },
  useHooks: ({ editor, read, store, update }) => {
    const mode = usePluginStore(AIChatPlugin, 'mode');
    const toolName = usePluginStore(AIChatPlugin, 'toolName');
 
    useChatChunk({
      onChunk: ({ chunk, isFirst, nodes, text: content }) => {
        if (isFirst && mode === 'insert') {
          const selection = editor.read.selection();
 
          if (!selection) return;
 
          const { startBlock, startInEmptyParagraph } =
            read.insertStart();
 
          editor.update.ai.beginPreview({
            originalBlocks:
              startInEmptyParagraph &&
              startBlock &&
              ElementApi.isElement(startBlock)
                ? [cloneDeep(startBlock)]
                : [],
          });
 
          editor.update({ history: 'skip' }, (tx) => {
            tx.nodes.insert(
              {
                children: [{ text: '' }],
                type: editor.plugin(PLUGINS.aiChat).schema.type,
              },
              {
                at: PathApi.next(selection.focus.path.slice(0, 1)),
              }
            );
          });
          store.set({ streaming: true });
        }
 
        if (mode === 'insert' && nodes.length > 0) {
          if (!store.get('streaming')) return;
 
          update.insertChunk(chunk, {
            autoScroll: true,
            textProps: {
              [editor.plugin(PLUGINS.ai).schema.key]: true,
            },
          });
        }
 
        if (toolName === 'edit' && mode === 'chat') {
          update.applySuggestions(content, { split: isFirst });
        }
      },
      onFinish: () => {
        store.set({
          _blockChunks: '',
          _blockPath: null,
          _mdxName: null,
          streaming: false,
        });
      },
    });
  },
});
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: convertToCoreMessages(messages),
model: gateway(model ?? 'openai/gpt-4o-mini'),
system: ctx.toolName === 'edit' ? 'You are an editor that rewrites user text.' : undefined,
});
return result.toDataStreamResponse();
}
app/api/ai/command/route.ts
import { createGateway } from '@ai-sdk/gateway';
import { convertToCoreMessages, streamText } from 'ai';
import { createBaseEditor } 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 = createBaseEditor({
    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: convertToCoreMessages(messages),
    model: gateway(model ?? 'openai/gpt-4o-mini'),
    system: ctx.toolName === 'edit' ? 'You are an editor that rewrites user text.' : undefined,
  });
 
  return result.toDataStreamResponse();
}
(AIChatPlugin);
const chat = useChat<ChatMessage>({
id: 'editor',
api: '/api/ai/command',
transport: new DefaultChatTransport(),
onData(data) {
if (data.type === 'data-toolName') {
store.set({ toolName: data.data });
}
},
});
useEffect(() => {
store.set({ chat: createAIChatAdapter(chat) });
}, [chat, store]);
return chat;
};
import { useEffect } from 'react';
 
import { type UIMessage, DefaultChatTransport } from 'ai';
import { useChat } from '@ai-sdk/react';
import { AIChatPlugin, createAIChatAdapter } from '@platejs/ai/react';
import { useEditorPlugin } from 'platejs/react';
 
type ChatMessage = UIMessage<{}, { toolName: 'comment' | 'edit' | 'generate'; comment?: unknown }>;
 
export const useEditorAIChat = () => {
  const { store } = useEditorPlugin(AIChatPlugin);
 
  const chat = useChat<ChatMessage>({
    id: 'editor',
    api: '/api/ai/command',
    transport: new DefaultChatTransport(),
    onData(data) {
      if (data.type === 'data-toolName') {
        store.set({ toolName: data.data });
      }
    },
  });
 
  useEffect(() => {
    store.set({ chat: createAIChatAdapter(chat) });
  }, [chat, store]);
 
  return chat;
};
from
'lucide-react'
;
import { ElementApi, isHotkey, NodeApi } from 'platejs';
import {
useEditorPlugin,
useEditorRuntimeState,
usePlateEditor,
useEditorSelector,
useFocusedLast,
useHotkeys,
usePluginStore,
type PlateEditor,
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';
export function AIChatEditor({ content }: { content: string }) {
const aiEditor = usePlateEditor({
plugins: BaseEditorKit,
});
const { store } = useEditorPlugin(AIChatPlugin);
const document = React.useMemo(
() => aiEditor.api.markdown.deserialize(content),
[aiEditor, content]
);
useEditorRuntimeState(aiEditor, (state) => state.children());
React.useEffect(() => {
aiEditor.update({ history: 'skip' }).value.replace(document);
store.set({ previewValue: aiEditor.read.children() });
}, [aiEditor, document, store]);
return <EditorStatic variant="aiChat" editor={aiEditor} />;
}
export function AIMenu() {
const editor = useEditor();
const { api, read } = useEditorPlugin(AIChatPlugin);
const mode = usePluginStore(AIChatPlugin, 'mode');
const toolName = usePluginStore(AIChatPlugin, 'toolName');
const streaming = usePluginStore(AIChatPlugin, 'streaming');
const isSelecting = useEditorSelector(
(innerEditor) =>
innerEditor.read.selection.nodes().length > 0 ||
innerEditor.read.selection.isExpanded()
);
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 lastAssistantMessage = usePluginStore(
AIChatPlugin,
'lastAssistantMessage'
);
const messages = chat?.messages;
const status = chat?.status ?? 'ready';
const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(
null
);
const content = lastAssistantMessage?.parts.find(
(part) => part.type === 'text'
)?.text;
React.useEffect(() => {
if (!streaming) return undefined;
const anchorEntry = read.node({ anchor: true });
if (!anchorEntry) return undefined;
const anchorDom = editor.api.dom.resolveDOMNode(anchorEntry[0]);
if (!anchorDom) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(anchorDom);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [editor, read, streaming]);
const setOpen = (innerOpen: boolean) => {
if (innerOpen) {
api.show();
} else {
api.hide();
}
};
React.useEffect(() => {
if (!chatOpen) {
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(null);
setInput('');
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}
let nextAnchor: HTMLElement | null = null;
const selectedBlock = editor.read.nodes.blocks().at(-1);
if (selectedBlock) {
if (!ElementApi.isElement(selectedBlock[0])) return undefined;
nextAnchor = editor.api.dom.resolveDOMNode(selectedBlock[0]);
} else if (editor.read.selection.isCollapsed()) {
const ancestorEntry = editor.read.nodes.block();
if (!ancestorEntry) return undefined;
const [ancestor] = ancestorEntry;
if (
!editor.read.selection.isAtBlockEnd() &&
ElementApi.isElement(ancestor) &&
!editor.read.nodes.isEmpty(ancestor)
) {
editor.update.selection.setNodes([ancestorEntry[1]]);
}
nextAnchor = editor.api.dom.resolveDOMNode(ancestor);
} else if (editor.read.selection.isExpanded()) {
const block = editor.read((state) => state.nodes.blocks()).at(-1);
nextAnchor = block ? editor.api.dom.resolveDOMNode(block[0]) : null;
}
if (!nextAnchor) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(nextAnchor);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [chatOpen, editor]);
useHotkeys('esc', () => {
api.stop();
});
const isLoading = status === 'streaming' || status === 'submitted';
React.useEffect(() => {
if (toolName !== 'edit' || mode !== 'chat' || isLoading) return undefined;
let anchorNode = editor
.plugin(SuggestionPlugin)
.read.nodes({ transient: true })
.at(-1);
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);
};
}, [editor, isLoading, mode, toolName]);
if (isLoading && mode === 'insert') return null;
if (toolName === 'comment') return null;
if (toolName === 'edit' && mode === 'chat' && isLoading) return null;
if (!anchorElement) return null;
return (
<FloatingPopover open={open} onOpenChange={setOpen} modal={false}>
<FloatingPopoverAnchor element={anchorElement} />
<FloatingPopoverContent
className="border-none bg-transparent p-0 shadow-none ring-0"
style={{
width: anchorElement?.offsetWidth,
}}
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' &&
isSelecting &&
content &&
toolName === 'generate' && <AIChatEditor content={content} />}
{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-plate-focus
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 }) => {
const { mode, toolName } = editor.plugin(AIChatPlugin).store.get();
if (mode === 'chat' && toolName === 'generate') {
editor.plugin(AIChatPlugin).update.replaceSelection();
return;
}
editor.plugin(AIChatPlugin).update.accept();
editor.update((tx) => {
const end = tx.points.end([]);
if (!end) return;
tx.selection.set({ anchor: end, focus: end });
});
editor.api.dom.focus({ retries: 5 });
},
},
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`
: 'Continue writing AFTER <Block> ONLY ONE SENTENCE. DONT REPEAT THE TEXT.',
toolName: 'generate',
});
},
},
discard: {
icon: <X />,
label: 'Discard',
shortcut: 'Escape',
value: 'discard',
onSelect: ({ editor }) => {
editor.plugin(AIPlugin).update.undo();
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).update.insertBelow({ format: 'none' });
},
},
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).update.replaceSelection();
},
},
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: PlateEditor;
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 messages = usePluginStore(AIChatPlugin, 'chat')?.messages;
const isSelecting = useEditorSelector(
(innerEditor2) =>
innerEditor2.read.selection.nodes().length > 0 ||
innerEditor2.read.selection.isExpanded()
);
const menuState: EditorChatState =
(messages?.length ?? 0) > 0
? isSelecting
? 'selectionSuggestion'
: 'cursorSuggestion'
: isSelecting
? 'selectionCommand'
: 'cursorCommand';
const menuGroups = menuStateItems[menuState];
React.useEffect(() => {
const firstItem = menuStateItems[menuState][0]?.items[0];
if (firstItem) {
setValue(firstItem.value);
}
}, [menuState, 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 editor = useEditor();
const toolName = usePluginStore(AIChatPlugin, 'toolName');
const chat = usePluginStore(AIChatPlugin, 'chat');
const mode = usePluginStore(AIChatPlugin, 'mode');
const status = chat?.status ?? 'ready';
const { api } = useEditorPlugin(AIChatPlugin);
const isLoading = status === 'streaming' || status === 'submitted';
const handleComments = (type: 'accept' | 'reject') => {
if (type === 'accept') {
editor.plugin(CommentPlugin).update.clearTransient();
}
if (type === 'reject') {
editor.plugin(CommentPlugin).update.unsetMark({ transient: true });
}
api.hide();
};
useHotkeys('esc', () => {
api.stop();
});
if (
isLoading &&
(mode === 'insert' ||
toolName === 'comment' ||
(toolName === 'edit' && mode === 'chat'))
) {
return (
<div
className={cn(
'-translate-x-1/2 absolute bottom-4 left-1/2 z-20 flex 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"
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 === 'ready') {
return (
<div
className={cn(
'-translate-x-1/2 absolute bottom-4 left-1/2 z-50 flex flex-col items-center gap-0 rounded-xl border border-border/50 bg-popover p-1 text-muted-foreground text-sm shadow-xl backdrop-blur-sm',
'p-3'
)}
>
{/* Header with controls */}
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-5">
<Button
size="sm"
disabled={isLoading}
onClick={() => {
handleComments('accept');
}}
>
Accept
</Button>
<Button
size="sm"
disabled={isLoading}
onClick={() => {
handleComments('reject');
}}
>
Reject
</Button>
</div>
</div>
</div>
);
}
return null;
}
'use client';
 
import { AIChatPlugin, AIPlugin } from '@platejs/ai/react';
import { CommentPlugin } from '@platejs/comment/react';
import { SuggestionPlugin } from '@platejs/suggestion/react';
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 { ElementApi, isHotkey, NodeApi } from 'platejs';
import {
  useEditorPlugin,
  useEditorRuntimeState,
  usePlateEditor,
  useEditorSelector,
  useFocusedLast,
  useHotkeys,
  usePluginStore,
  type PlateEditor,
  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';
 
export function AIChatEditor({ content }: { content: string }) {
  const aiEditor = usePlateEditor({
    plugins: BaseEditorKit,
  });
  const { store } = useEditorPlugin(AIChatPlugin);
  const document = React.useMemo(
    () => aiEditor.api.markdown.deserialize(content),
    [aiEditor, content]
  );
 
  useEditorRuntimeState(aiEditor, (state) => state.children());
 
  React.useEffect(() => {
    aiEditor.update({ history: 'skip' }).value.replace(document);
    store.set({ previewValue: aiEditor.read.children() });
  }, [aiEditor, document, store]);
 
  return <EditorStatic variant="aiChat" editor={aiEditor} />;
}
 
export function AIMenu() {
  const editor = useEditor();
  const { api, read } = useEditorPlugin(AIChatPlugin);
  const mode = usePluginStore(AIChatPlugin, 'mode');
  const toolName = usePluginStore(AIChatPlugin, 'toolName');
 
  const streaming = usePluginStore(AIChatPlugin, 'streaming');
  const isSelecting = useEditorSelector(
    (innerEditor) =>
      innerEditor.read.selection.nodes().length > 0 ||
      innerEditor.read.selection.isExpanded()
  );
  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 lastAssistantMessage = usePluginStore(
    AIChatPlugin,
    'lastAssistantMessage'
  );
 
  const messages = chat?.messages;
  const status = chat?.status ?? 'ready';
  const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(
    null
  );
 
  const content = lastAssistantMessage?.parts.find(
    (part) => part.type === 'text'
  )?.text;
 
  React.useEffect(() => {
    if (!streaming) return undefined;
 
    const anchorEntry = read.node({ anchor: true });
    if (!anchorEntry) return undefined;
 
    const anchorDom = editor.api.dom.resolveDOMNode(anchorEntry[0]);
    if (!anchorDom) return undefined;
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(anchorDom);
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [editor, read, streaming]);
 
  const setOpen = (innerOpen: boolean) => {
    if (innerOpen) {
      api.show();
    } else {
      api.hide();
    }
  };
 
  React.useEffect(() => {
    if (!chatOpen) {
      const animationFrame = window.requestAnimationFrame(() => {
        setAnchorElement(null);
        setInput('');
      });
 
      return () => {
        window.cancelAnimationFrame(animationFrame);
      };
    }
 
    let nextAnchor: HTMLElement | null = null;
    const selectedBlock = editor.read.nodes.blocks().at(-1);
 
    if (selectedBlock) {
      if (!ElementApi.isElement(selectedBlock[0])) return undefined;
 
      nextAnchor = editor.api.dom.resolveDOMNode(selectedBlock[0]);
    } else if (editor.read.selection.isCollapsed()) {
      const ancestorEntry = editor.read.nodes.block();
 
      if (!ancestorEntry) return undefined;
 
      const [ancestor] = ancestorEntry;
 
      if (
        !editor.read.selection.isAtBlockEnd() &&
        ElementApi.isElement(ancestor) &&
        !editor.read.nodes.isEmpty(ancestor)
      ) {
        editor.update.selection.setNodes([ancestorEntry[1]]);
      }
 
      nextAnchor = editor.api.dom.resolveDOMNode(ancestor);
    } else if (editor.read.selection.isExpanded()) {
      const block = editor.read((state) => state.nodes.blocks()).at(-1);
      nextAnchor = block ? editor.api.dom.resolveDOMNode(block[0]) : null;
    }
 
    if (!nextAnchor) return undefined;
    const animationFrame = window.requestAnimationFrame(() => {
      setAnchorElement(nextAnchor);
    });
 
    return () => {
      window.cancelAnimationFrame(animationFrame);
    };
  }, [chatOpen, editor]);
 
  useHotkeys('esc', () => {
    api.stop();
  });
 
  const isLoading = status === 'streaming' || status === 'submitted';
 
  React.useEffect(() => {
    if (toolName !== 'edit' || mode !== 'chat' || isLoading) return undefined;
 
    let anchorNode = editor
      .plugin(SuggestionPlugin)
      .read.nodes({ transient: true })
      .at(-1);
 
    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);
    };
  }, [editor, isLoading, mode, toolName]);
 
  if (isLoading && mode === 'insert') return null;
 
  if (toolName === 'comment') return null;
 
  if (toolName === 'edit' && mode === 'chat' && isLoading) return null;
 
  if (!anchorElement) return null;
 
  return (
    <FloatingPopover open={open} onOpenChange={setOpen} modal={false}>
      <FloatingPopoverAnchor element={anchorElement} />
 
      <FloatingPopoverContent
        className="border-none bg-transparent p-0 shadow-none ring-0"
        style={{
          width: anchorElement?.offsetWidth,
        }}
        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' &&
            isSelecting &&
            content &&
            toolName === 'generate' && <AIChatEditor content={content} />}
 
          {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-plate-focus
              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 }) => {
      const { mode, toolName } = editor.plugin(AIChatPlugin).store.get();
 
      if (mode === 'chat' && toolName === 'generate') {
        editor.plugin(AIChatPlugin).update.replaceSelection();
        return;
      }
 
      editor.plugin(AIChatPlugin).update.accept();
      editor.update((tx) => {
        const end = tx.points.end([]);
 
        if (!end) return;
 
        tx.selection.set({ anchor: end, focus: end });
      });
      editor.api.dom.focus({ retries: 5 });
    },
  },
  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`
          : 'Continue writing AFTER <Block> ONLY ONE SENTENCE. DONT REPEAT THE TEXT.',
        toolName: 'generate',
      });
    },
  },
  discard: {
    icon: <X />,
    label: 'Discard',
    shortcut: 'Escape',
    value: 'discard',
    onSelect: ({ editor }) => {
      editor.plugin(AIPlugin).update.undo();
      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).update.insertBelow({ format: 'none' });
    },
  },
  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).update.replaceSelection();
    },
  },
  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: PlateEditor;
      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 messages = usePluginStore(AIChatPlugin, 'chat')?.messages;
  const isSelecting = useEditorSelector(
    (innerEditor2) =>
      innerEditor2.read.selection.nodes().length > 0 ||
      innerEditor2.read.selection.isExpanded()
  );
 
  const menuState: EditorChatState =
    (messages?.length ?? 0) > 0
      ? isSelecting
        ? 'selectionSuggestion'
        : 'cursorSuggestion'
      : isSelecting
        ? 'selectionCommand'
        : 'cursorCommand';
  const menuGroups = menuStateItems[menuState];
 
  React.useEffect(() => {
    const firstItem = menuStateItems[menuState][0]?.items[0];
 
    if (firstItem) {
      setValue(firstItem.value);
    }
  }, [menuState, 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 editor = useEditor();
 
  const toolName = usePluginStore(AIChatPlugin, 'toolName');
  const chat = usePluginStore(AIChatPlugin, 'chat');
  const mode = usePluginStore(AIChatPlugin, 'mode');
 
  const status = chat?.status ?? 'ready';
 
  const { api } = useEditorPlugin(AIChatPlugin);
 
  const isLoading = status === 'streaming' || status === 'submitted';
 
  const handleComments = (type: 'accept' | 'reject') => {
    if (type === 'accept') {
      editor.plugin(CommentPlugin).update.clearTransient();
    }
 
    if (type === 'reject') {
      editor.plugin(CommentPlugin).update.unsetMark({ transient: true });
    }
 
    api.hide();
  };
 
  useHotkeys('esc', () => {
    api.stop();
  });
 
  if (
    isLoading &&
    (mode === 'insert' ||
      toolName === 'comment' ||
      (toolName === 'edit' && mode === 'chat'))
  ) {
    return (
      <div
        className={cn(
          '-translate-x-1/2 absolute bottom-4 left-1/2 z-20 flex 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"
          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 === 'ready') {
    return (
      <div
        className={cn(
          '-translate-x-1/2 absolute bottom-4 left-1/2 z-50 flex flex-col items-center gap-0 rounded-xl border border-border/50 bg-popover p-1 text-muted-foreground text-sm shadow-xl backdrop-blur-sm',
          'p-3'
        )}
      >
        {/* Header with controls */}
        <div className="flex w-full items-center justify-between gap-3">
          <div className="flex items-center gap-5">
            <Button
              size="sm"
              disabled={isLoading}
              onClick={() => {
                handleComments('accept');
              }}
            >
              Accept
            </Button>
 
            <Button
              size="sm"
              disabled={isLoading}
              onClick={() => {
                handleComments('reject');
              }}
            >
              Reject
            </Button>
          </div>
        </div>
      </div>
    );
  }
 
  return null;
}
'generate'
,
});
;
import { ElementApi, isHotkey, NodeApi } from 'platejs';
import {
useEditorPlugin,
useEditorRuntimeState,
usePlateEditor,
useEditorSelector,
useFocusedLast,
useHotkeys,
usePluginStore,
type PlateEditor,
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';
export function AIChatEditor({ content }: { content: string }) {
const aiEditor = usePlateEditor({
plugins: BaseEditorKit,
});
const { store } = useEditorPlugin(AIChatPlugin);
const document = React.useMemo(
() => aiEditor.api.markdown.deserialize(content),
[aiEditor, content]
);
useEditorRuntimeState(aiEditor, (state) => state.children());
React.useEffect(() => {
aiEditor.update({ history: 'skip' }).value.replace(document);
store.set({ previewValue: aiEditor.read.children() });
}, [aiEditor, document, store]);
return <EditorStatic variant="aiChat" editor={aiEditor} />;
}
export function AIMenu() {
const editor = useEditor();
const { api, read } = useEditorPlugin(AIChatPlugin);
const mode = usePluginStore(AIChatPlugin, 'mode');
const toolName = usePluginStore(AIChatPlugin, 'toolName');
const streaming = usePluginStore(AIChatPlugin, 'streaming');
const isSelecting = useEditorSelector(
(innerEditor) =>
innerEditor.read.selection.nodes().length > 0 ||
innerEditor.read.selection.isExpanded()
);
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 lastAssistantMessage = usePluginStore(
AIChatPlugin,
'lastAssistantMessage'
);
const messages = chat?.messages;
const status = chat?.status ?? 'ready';
const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(
null
);
const content = lastAssistantMessage?.parts.find(
(part) => part.type === 'text'
)?.text;
React.useEffect(() => {
if (!streaming) return undefined;
const anchorEntry = read.node({ anchor: true });
if (!anchorEntry) return undefined;
const anchorDom = editor.api.dom.resolveDOMNode(anchorEntry[0]);
if (!anchorDom) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(anchorDom);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [editor, read, streaming]);
const setOpen = (innerOpen: boolean) => {
if (innerOpen) {
api.show();
} else {
api.hide();
}
};
React.useEffect(() => {
if (!chatOpen) {
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(null);
setInput('');
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}
let nextAnchor: HTMLElement | null = null;
const selectedBlock = editor.read.nodes.blocks().at(-1);
if (selectedBlock) {
if (!ElementApi.isElement(selectedBlock[0])) return undefined;
nextAnchor = editor.api.dom.resolveDOMNode(selectedBlock[0]);
} else if (editor.read.selection.isCollapsed()) {
const ancestorEntry = editor.read.nodes.block();
if (!ancestorEntry) return undefined;
const [ancestor] = ancestorEntry;
if (
!editor.read.selection.isAtBlockEnd() &&
ElementApi.isElement(ancestor) &&
!editor.read.nodes.isEmpty(ancestor)
) {
editor.update.selection.setNodes([ancestorEntry[1]]);
}
nextAnchor = editor.api.dom.resolveDOMNode(ancestor);
} else if (editor.read.selection.isExpanded()) {
const block = editor.read((state) => state.nodes.blocks()).at(-1);
nextAnchor = block ? editor.api.dom.resolveDOMNode(block[0]) : null;
}
if (!nextAnchor) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(nextAnchor);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [chatOpen, editor]);
useHotkeys('esc', () => {
api.stop();
});
const isLoading = status === 'streaming' || status === 'submitted';
React.useEffect(() => {
if (toolName !== 'edit' || mode !== 'chat' || isLoading) return undefined;
let anchorNode = editor
.plugin(SuggestionPlugin)
.read.nodes({ transient: true })
.at(-1);
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);
};
}, [editor, isLoading, mode, toolName]);
if (isLoading && mode === 'insert') return null;
if (toolName === 'comment') return null;
if (toolName === 'edit' && mode === 'chat' && isLoading) return null;
if (!anchorElement) return null;
return (
<FloatingPopover open={open} onOpenChange={setOpen} modal={false}>
<FloatingPopoverAnchor element={anchorElement} />
<FloatingPopoverContent
className="border-none bg-transparent p-0 shadow-none ring-0"
style={{
width: anchorElement?.offsetWidth,
}}
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' &&
isSelecting &&
content &&
toolName === 'generate' && <AIChatEditor content={content} />}
{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-plate-focus
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 }) => {
const { mode, toolName } = editor.plugin(AIChatPlugin).store.get();
if (mode === 'chat' && toolName === 'generate') {
editor.plugin(AIChatPlugin).update.replaceSelection();
return;
}
editor.plugin(AIChatPlugin).update.accept();
editor.update((tx) => {
const end = tx.points.end([]);
if (!end) return;
tx.selection.set({ anchor: end, focus: end });
});
editor.api.dom.focus({ retries: 5 });
},
},
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`
: 'Continue writing AFTER <Block> ONLY ONE SENTENCE. DONT REPEAT THE TEXT.',
toolName: 'generate',
});
},
},
discard: {
icon: <X />,
label: 'Discard',
shortcut: 'Escape',
value: 'discard',
onSelect: ({ editor }) => {
editor.plugin(AIPlugin).update.undo();
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).update.insertBelow({ format: 'none' });
},
},
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).update.replaceSelection();
},
},
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: PlateEditor;
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 messages = usePluginStore(AIChatPlugin, 'chat')?.messages;
const isSelecting = useEditorSelector(
(innerEditor2) =>
innerEditor2.read.selection.nodes().length > 0 ||
innerEditor2.read.selection.isExpanded()
);
const menuState: EditorChatState =
(messages?.length ?? 0) > 0
? isSelecting
? 'selectionSuggestion'
: 'cursorSuggestion'
: isSelecting
? 'selectionCommand'
: 'cursorCommand';
const menuGroups = menuStateItems[menuState];
React.useEffect(() => {
const firstItem = menuStateItems[menuState][0]?.items[0];
if (firstItem) {
setValue(firstItem.value);
}
}, [menuState, 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 editor = useEditor();
const toolName = usePluginStore(AIChatPlugin, 'toolName');
const chat = usePluginStore(AIChatPlugin, 'chat');
const mode = usePluginStore(AIChatPlugin, 'mode');
const status = chat?.status ?? 'ready';
const { api } = useEditorPlugin(AIChatPlugin);
const isLoading = status === 'streaming' || status === 'submitted';
const handleComments = (type: 'accept' | 'reject') => {
if (type === 'accept') {
editor.plugin(CommentPlugin).update.clearTransient();
}
if (type === 'reject') {
editor.plugin(CommentPlugin).update.unsetMark({ transient: true });
}
api.hide();
};
useHotkeys('esc', () => {
api.stop();
});
if (
isLoading &&
(mode === 'insert' ||
toolName === 'comment' ||
(toolName === 'edit' && mode === 'chat'))
) {
return (
<div
className={cn(
'-translate-x-1/2 absolute bottom-4 left-1/2 z-20 flex 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"
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 === 'ready') {
return (
<div
className={cn(
'-translate-x-1/2 absolute bottom-4 left-1/2 z-50 flex flex-col items-center gap-0 rounded-xl border border-border/50 bg-popover p-1 text-muted-foreground text-sm shadow-xl backdrop-blur-sm',
'p-3'
)}
>
{/* Header with controls */}
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-5">
<Button
size="sm"
disabled={isLoading}
onClick={() => {
handleComments('accept');
}}
>
Accept
</Button>
<Button
size="sm"
disabled={isLoading}
onClick={() => {
handleComments('reject');
}}
>
Reject
</Button>
</div>
</div>
</div>
);
}
return null;
}
;
import { ElementApi, isHotkey, NodeApi } from 'platejs';
import {
useEditorPlugin,
useEditorRuntimeState,
usePlateEditor,
useEditorSelector,
useFocusedLast,
useHotkeys,
usePluginStore,
type PlateEditor,
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';
export function AIChatEditor({ content }: { content: string }) {
const aiEditor = usePlateEditor({
plugins: BaseEditorKit,
});
const { store } = useEditorPlugin(AIChatPlugin);
const document = React.useMemo(
() => aiEditor.api.markdown.deserialize(content),
[aiEditor, content]
);
useEditorRuntimeState(aiEditor, (state) => state.children());
React.useEffect(() => {
aiEditor.update({ history: 'skip' }).value.replace(document);
store.set({ previewValue: aiEditor.read.children() });
}, [aiEditor, document, store]);
return <EditorStatic variant="aiChat" editor={aiEditor} />;
}
export function AIMenu() {
const editor = useEditor();
const { api, read } = useEditorPlugin(AIChatPlugin);
const mode = usePluginStore(AIChatPlugin, 'mode');
const toolName = usePluginStore(AIChatPlugin, 'toolName');
const streaming = usePluginStore(AIChatPlugin, 'streaming');
const isSelecting = useEditorSelector(
(innerEditor) =>
innerEditor.read.selection.nodes().length > 0 ||
innerEditor.read.selection.isExpanded()
);
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 lastAssistantMessage = usePluginStore(
AIChatPlugin,
'lastAssistantMessage'
);
const messages = chat?.messages;
const status = chat?.status ?? 'ready';
const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(
null
);
const content = lastAssistantMessage?.parts.find(
(part) => part.type === 'text'
)?.text;
React.useEffect(() => {
if (!streaming) return undefined;
const anchorEntry = read.node({ anchor: true });
if (!anchorEntry) return undefined;
const anchorDom = editor.api.dom.resolveDOMNode(anchorEntry[0]);
if (!anchorDom) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(anchorDom);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [editor, read, streaming]);
const setOpen = (innerOpen: boolean) => {
if (innerOpen) {
api.show();
} else {
api.hide();
}
};
React.useEffect(() => {
if (!chatOpen) {
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(null);
setInput('');
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}
let nextAnchor: HTMLElement | null = null;
const selectedBlock = editor.read.nodes.blocks().at(-1);
if (selectedBlock) {
if (!ElementApi.isElement(selectedBlock[0])) return undefined;
nextAnchor = editor.api.dom.resolveDOMNode(selectedBlock[0]);
} else if (editor.read.selection.isCollapsed()) {
const ancestorEntry = editor.read.nodes.block();
if (!ancestorEntry) return undefined;
const [ancestor] = ancestorEntry;
if (
!editor.read.selection.isAtBlockEnd() &&
ElementApi.isElement(ancestor) &&
!editor.read.nodes.isEmpty(ancestor)
) {
editor.update.selection.setNodes([ancestorEntry[1]]);
}
nextAnchor = editor.api.dom.resolveDOMNode(ancestor);
} else if (editor.read.selection.isExpanded()) {
const block = editor.read((state) => state.nodes.blocks()).at(-1);
nextAnchor = block ? editor.api.dom.resolveDOMNode(block[0]) : null;
}
if (!nextAnchor) return undefined;
const animationFrame = window.requestAnimationFrame(() => {
setAnchorElement(nextAnchor);
});
return () => {
window.cancelAnimationFrame(animationFrame);
};
}, [chatOpen, editor]);
useHotkeys('esc', () => {
api.stop();
});
const isLoading = status === 'streaming' || status === 'submitted';
React.useEffect(() => {
if (toolName !== 'edit' || mode !== 'chat' || isLoading) return undefined;
let anchorNode = editor
.plugin(SuggestionPlugin)
.read.nodes({ transient: true })
.at(-1);
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);
};
}, [editor, isLoading, mode, toolName]);
if (isLoading && mode === 'insert') return null;
if (toolName === 'comment') return null;
if (toolName === 'edit' && mode === 'chat' && isLoading) return null;
if (!anchorElement) return null;
return (
<FloatingPopover open={open} onOpenChange={setOpen} modal={false}>
<FloatingPopoverAnchor element={anchorElement} />
<FloatingPopoverContent
className="border-none bg-transparent p-0 shadow-none ring-0"
style={{
width: anchorElement?.offsetWidth,
}}
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' &&
isSelecting &&
content &&
toolName === 'generate' && <AIChatEditor content={content} />}
{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-plate-focus
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 }) => {
const { mode, toolName } = editor.plugin(AIChatPlugin).store.get();
if (mode === 'chat' && toolName === 'generate') {
editor.plugin(AIChatPlugin).update.replaceSelection();
return;
}
editor.plugin(AIChatPlugin).update.accept();
editor.update((tx) => {
const end = tx.points.end([]);
if (!end) return;
tx.selection.set({ anchor: end, focus: end });
});
editor.api.dom.focus({ retries: 5 });
},
},
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`
: 'Continue writing AFTER <Block> ONLY ONE SENTENCE. DONT REPEAT THE TEXT.',
toolName: 'generate',
});
},
},
discard: {
icon: <X />,
label: 'Discard',
shortcut: 'Escape',
value: 'discard',
onSelect: ({ editor }) => {
editor.plugin(AIPlugin).update.undo();
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).update.insertBelow({ format: 'none' });
},
},
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).update.replaceSelection();
},
},
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: PlateEditor;
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 messages = usePluginStore(AIChatPlugin, 'chat')?.messages;
const isSelecting = useEditorSelector(
(innerEditor2) =>
innerEditor2.read.selection.nodes().length > 0 ||
innerEditor2.read.selection.isExpanded()
);
const menuState: EditorChatState =
(messages?.length ?? 0) > 0
? isSelecting
? 'selectionSuggestion'
: 'cursorSuggestion'
: isSelecting
? 'selectionCommand'
: 'cursorCommand';
const menuGroups = menuStateItems[menuState];
React.useEffect(() => {
const firstItem = menuStateItems[menuState][0]?.items[0];
if (firstItem) {
setValue(firstItem.value);
}
}, [menuState, 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 editor = useEditor();
const toolName = usePluginStore(AIChatPlugin, 'toolName');
const chat = usePluginStore(AIChatPlugin, 'chat');
const mode = usePluginStore(AIChatPlugin, 'mode');
const status = chat?.status ?? 'ready';
const { api } = useEditorPlugin(AIChatPlugin);
const isLoading = status === 'streaming' || status === 'submitted';
const handleComments = (type: 'accept' | 'reject') => {
if (type === 'accept') {
editor.plugin(CommentPlugin).update.clearTransient();
}
if (type === 'reject') {
editor.plugin(CommentPlugin).update.unsetMark({ transient: true });
}
api.hide();
};
useHotkeys('esc', () => {
api.stop();
});
if (
isLoading &&
(mode === 'insert' ||
toolName === 'comment' ||
(toolName === 'edit' && mode === 'chat'))
) {
return (
<div
className={cn(
'-translate-x-1/2 absolute bottom-4 left-1/2 z-20 flex 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"
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 === 'ready') {
return (
<div
className={cn(
'-translate-x-1/2 absolute bottom-4 left-1/2 z-50 flex flex-col items-center gap-0 rounded-xl border border-border/50 bg-popover p-1 text-muted-foreground text-sm shadow-xl backdrop-blur-sm',
'p-3'
)}
>
{/* Header with controls */}
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-5">
<Button
size="sm"
disabled={isLoading}
onClick={() => {
handleComments('accept');
}}
>
Accept
</Button>
<Button
size="sm"
disabled={isLoading}
onClick={() => {
handleComments('reject');
}}
>
Reject
</Button>
</div>
</div>
</div>
);
}
return null;
}
((
plugin
)
=>
plugin.installed ? [plugin.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',
});
},
},
};