editor.update.aiChat.insertChunk.editor.update.ai.editor.update.aiChat.replaceSelection and editor.update.aiChat.insertBelow.@ai-sdk/react so editor.plugin(AIChatPlugin).api.submit can stream responses from Vercel AI SDK helpers.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.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({
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 }
Set your AI Gateway key locally (replace with your provider secret if you are not using a gateway):
AI_GATEWAY_API_KEY="your-api-key"AI_GATEWAY_API_KEY="your-api-key"@platejs/suggestion is optional but required for diff-based edit suggestions.
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';
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 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.
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
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).chatOptions.body; everything you add is passed verbatim in the JSON payload and can be read before calling createGateway.useChat and useChatChunk can process tokens incrementally.useChatBridge 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:
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',
});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.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.
| Key | Description |
|---|---|
| Space | Open the AI menu in an empty block (cursor mode) |
| Cmd + J | Show the AI menu (set via shortcuts.show) |
| Escape | Hide the AI menu and stop 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.
Combobox menu with free-form prompt input
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.
Main plugin that powers the AI menu, chat state, and transforms.
' './^\s?$/.false to cancel opening in specific contexts.createAIChatAdapter(useChat(...)) so API calls can access chat state and controls.'insert'.false.false.Submits a prompt to your model provider. When mode is omitted it defaults to 'insert' for a collapsed cursor and 'chat' otherwise.
Clears chat state, removes AI nodes, and optionally undoes the last AI batch.
Retrieves the first AI node that matches the specified criteria.
Replays the last prompt using the stored chat adapter, restoring the original selection or block selection before resubmitting.
Stops streaming and calls chat.stop.
Opens the AI menu, clears previous chat messages, and resets tool state.
Closes the AI menu, optionally undoing the last AI batch and refocusing the editor.
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.
Inserts the stored chat preview below the current selection or block selection.
Replaces the current selection or block selection with the stored chat preview.
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: [] });Inserts nodes tagged with the AI mark at the current selection (or options.target).
Clears the AI mark from matching nodes.
Removes text nodes that are marked as AI-generated.
Captures the rollback slice and selection for insert-mode AI preview. Call it once before writing the first unsaved preview chunk.
Commits the active preview as one fresh undoable batch, strips preview-only markers, and clears preview bookkeeping.
Restores the rollback point for the active preview and clears preview bookkeeping.
Clears preview bookkeeping without restoring content. Use it when the previewed content should stay in place.
Reports whether an insert-mode preview rollback point is currently active.
Undoes the latest batch marked by editor.update.ai.markBatch(). If an
insert-mode preview is active, it cancels that preview first.
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'
Streams chat responses chunk-by-chunk and gives you full control over insertion.
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 });| Namespace | Methods |
|---|---|
ai.api | findTextRangeInBlock |
aiChat.api | deserializeChunk, deserializeInlineChunk, hide, reload, reset, show, stop, submit |
aiChat.read | commentRange, insertStart, markdown, node, prompt, resolvePlaceholders, serializeChunk |
aiChat.store.get | lastAssistantMessage |
aiChat.update | accept, acceptSuggestions, applySuggestions, applyTableCellSuggestion, insert, insertBelow, insertChunk, rejectSuggestions, remove, replaceSelection, set |
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.
'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.
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',
});
},
},
};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.
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 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,
});
},
});
},
});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();
}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;
};'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;
}