Components
AI Menu
AI Menu
AI-powered menu for generating and inserting content in the editor.
Installation
npx @udecode/plate-ui@latest add ai-menu -r plate-ui
Examples
import * as React from 'react';
import { AIChatPlugin, useEditorChat } from '@udecode/plate-ai/react';
import {
getAncestorNode,
getBlocks,
isElementEmpty,
isHotkey,
isSelectionAtBlockEnd,
} from '@udecode/plate-common';
import {
toDOMNode,
useEditorPlugin,
useHotkeys,
} from '@udecode/plate-common/react';
import {
BlockSelectionPlugin,
useIsSelecting,
} from '@udecode/plate-selection/react';
import { useChat } from 'ai/react';
import { Loader2Icon } from 'lucide-react';
import { toast } from 'sonner';
import { useOpenAI } from '../openai/openai-context';
import { AIChatEditor } from './ai-chat-editor';
import { AIMenuItems } from './ai-menu-items';
import { Command, CommandList, InputCommand } from './command';
import { Popover, PopoverAnchor, PopoverContent } from './popover';
import type { TElement, TNodeEntry } from '@udecode/plate-common';
import type { PlateEditor } from '@udecode/plate-common/react';
export function AIMenu() {
const { api, editor, useOption } = useEditorPlugin(AIChatPlugin);
const open = useOption('open');
const mode = useOption('mode');
const isSelecting = useIsSelecting();
const aiEditorRef = React.useRef<PlateEditor | null>(null);
const [value, setValue] = React.useState('');
const chat = useChat({
id: 'editor',
api: '/api/ai/command',
body: {
apiKey: useOpenAI().apiKey,
model: useOpenAI().model.value,
},
onError: (error) => {
if (error.message.includes('API key')) {
toast.error('OpenAI API key required');
} else {
toast.error('Invalid OpenAI API key');
}
api.aiChat.hide();
},
});
const { input, isLoading, messages, setInput } = chat;
const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(
null
);
const setOpen = (open: boolean) => {
if (open) {
api.aiChat.show();
} else {
api.aiChat.hide();
}
};
const show = (anchorElement: HTMLElement) => {
setAnchorElement(anchorElement);
setOpen(true);
};
useEditorChat({
chat,
onOpenBlockSelection: (blocks: TNodeEntry[]) => {
show(toDOMNode(editor, blocks.at(-1)![0])!);
},
onOpenChange: (open) => {
if (!open) {
setAnchorElement(null);
setInput('');
}
},
onOpenCursor: () => {
const ancestor = getAncestorNode(editor)?.[0] as TElement;
if (!isSelectionAtBlockEnd(editor) && !isElementEmpty(editor, ancestor)) {
editor
.getApi(BlockSelectionPlugin)
.blockSelection.addSelectedRow(ancestor.id as string);
}
show(toDOMNode(editor, ancestor)!);
},
onOpenSelection: () => {
show(toDOMNode(editor, getBlocks(editor).at(-1)![0])!);
},
});
useHotkeys(
'meta+j',
() => {
api.aiChat.show();
},
{ enableOnContentEditable: true, enableOnFormTags: true }
);
return (
<Popover open={open} onOpenChange={setOpen} modal={false}>
<PopoverAnchor virtualRef={{ current: anchorElement }} />
<PopoverContent
className="border-none bg-transparent p-0 shadow-none"
style={{
width: anchorElement?.offsetWidth,
}}
onEscapeKeyDown={(e) => {
e.preventDefault();
if (isLoading) {
api.aiChat.stop();
} else {
api.aiChat.hide();
}
}}
align="center"
avoidCollisions={false}
side="bottom"
>
<Command
className="w-full rounded-lg border shadow-md"
value={value}
onValueChange={setValue}
>
{mode === 'chat' && isSelecting && messages.length > 0 && (
<AIChatEditor aiEditorRef={aiEditorRef} />
)}
{isLoading ? (
<div className="flex grow select-none items-center gap-2 p-2 text-sm text-muted-foreground">
<Loader2Icon className="size-4 animate-spin" />
{messages.length > 1 ? 'Editing...' : 'Thinking...'}
</div>
) : (
<InputCommand
variant="ghost"
className="rounded-none border-b border-solid border-border [&_svg]:hidden"
value={input}
onKeyDown={(e) => {
if (isHotkey('backspace')(e) && input.length === 0) {
e.preventDefault();
api.aiChat.hide();
}
if (isHotkey('enter')(e) && !e.shiftKey && !value) {
e.preventDefault();
void api.aiChat.submit();
}
}}
onValueChange={setInput}
placeholder="Ask AI anything..."
autoFocus
/>
)}
{!isLoading && (
<CommandList>
<AIMenuItems aiEditorRef={aiEditorRef} setValue={setValue} />
</CommandList>
)}
</Command>
</PopoverContent>
</Popover>
);
}
import { useEffect, useMemo } from 'react';
import { AIChatPlugin, AIPlugin } from '@udecode/plate-ai/react';
import {
getAncestorNode,
getEndPoint,
getNodeString,
} from '@udecode/plate-common';
import { focusEditor, useEditorPlugin } from '@udecode/plate-common/react';
import { useIsSelecting } from '@udecode/plate-selection/react';
import {
Album,
BadgeHelp,
Check,
CornerUpLeft,
FeatherIcon,
ListEnd,
ListMinus,
ListPlus,
PenLine,
Wand,
X,
} from 'lucide-react';
import { CommandGroup, CommandItem } from './command';
import type { PlateEditor } from '@udecode/plate-common/react';
export type EditorChatState =
| 'cursorCommand'
| 'cursorSuggestion'
| 'selectionCommand'
| 'selectionSuggestion';
export const aiChatItems = {
accept: {
icon: <Check />,
label: 'Accept',
value: 'accept',
onSelect: ({ editor }) => {
editor.getTransforms(AIChatPlugin).aiChat.accept();
focusEditor(editor, getEndPoint(editor, editor.selection!));
},
},
continueWrite: {
icon: <PenLine />,
label: 'Continue writing',
value: 'continueWrite',
onSelect: ({ editor }) => {
const ancestorNode = getAncestorNode(editor);
const isEmpty = getNodeString(ancestorNode![0]).trim().length === 0;
void editor.getApi(AIChatPlugin).aiChat.submit({
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.',
});
},
},
discard: {
icon: <X />,
label: 'Discard',
shortcut: 'Escape',
value: 'discard',
onSelect: ({ editor }) => {
editor.getTransforms(AIPlugin).ai.undo();
editor.getApi(AIChatPlugin).aiChat.hide();
},
},
explain: {
icon: <BadgeHelp className="size-4" />,
label: 'Explain',
value: 'explain',
onSelect: ({ editor }) => {
void editor.getApi(AIChatPlugin).aiChat.submit({
prompt: {
default: 'Explain {editor}',
selecting: 'Explain',
},
});
},
},
fixSpelling: {
icon: <Check />,
label: 'Fix spelling & grammar',
value: 'fixSpelling',
onSelect: ({ editor }) => {
void editor.getApi(AIChatPlugin).aiChat.submit({
prompt: 'Fix spelling and grammar',
});
},
},
improveWriting: {
icon: <Wand />,
label: 'Improve writing',
value: 'improveWriting',
onSelect: ({ editor }) => {
void editor.getApi(AIChatPlugin).aiChat.submit({
prompt: 'Improve the writing',
});
},
},
insertBelow: {
icon: <ListEnd />,
label: 'Insert below',
value: 'insertBelow',
onSelect: ({ aiEditor, editor }) => {
void editor.getTransforms(AIChatPlugin).aiChat.insertBelow(aiEditor);
},
},
makeLonger: {
icon: <ListPlus />,
label: 'Make longer',
value: 'makeLonger',
onSelect: ({ editor }) => {
void editor.getApi(AIChatPlugin).aiChat.submit({
prompt: 'Make longer',
});
},
},
makeShorter: {
icon: <ListMinus />,
label: 'Make shorter',
value: 'makeShorter',
onSelect: ({ editor }) => {
void editor.getApi(AIChatPlugin).aiChat.submit({
prompt: 'Make shorter',
});
},
},
replace: {
icon: <Check />,
label: 'Replace selection',
value: 'replace',
onSelect: ({ aiEditor, editor }) => {
void editor.getTransforms(AIChatPlugin).aiChat.replaceSelection(aiEditor);
},
},
simplifyLanguage: {
icon: <FeatherIcon />,
label: 'Simplify language',
value: 'simplifyLanguage',
onSelect: ({ editor }) => {
void editor.getApi(AIChatPlugin).aiChat.submit({
prompt: 'Simplify the language',
});
},
},
summarize: {
icon: <Album className="size-4" />,
label: 'Add a summary',
value: 'summarize',
onSelect: ({ editor }) => {
void editor.getApi(AIChatPlugin).aiChat.submit({
mode: 'insert',
prompt: {
default: 'Summarize {editor}',
selecting: 'Summarize',
},
});
},
},
tryAgain: {
icon: <CornerUpLeft />,
label: 'Try again',
value: 'tryAgain',
onSelect: ({ editor }) => {
void editor.getApi(AIChatPlugin).aiChat.reload();
},
},
} satisfies Record<
string,
{
icon: React.ReactNode;
label: string;
value: string;
component?: React.ComponentType<{ menuState: EditorChatState }>;
filterItems?: boolean;
items?: { label: string; value: string }[];
shortcut?: string;
onSelect?: ({
aiEditor,
editor,
}: {
aiEditor: PlateEditor;
editor: PlateEditor;
}) => void;
}
>;
const menuStateItems: Record<
EditorChatState,
{
items: (typeof aiChatItems)[keyof typeof aiChatItems][];
heading?: string;
}[]
> = {
cursorCommand: [
{
items: [
aiChatItems.continueWrite,
aiChatItems.summarize,
aiChatItems.explain,
],
},
],
cursorSuggestion: [
{
items: [aiChatItems.accept, aiChatItems.discard, aiChatItems.tryAgain],
},
],
selectionCommand: [
{
items: [
aiChatItems.improveWriting,
aiChatItems.makeLonger,
aiChatItems.makeShorter,
aiChatItems.fixSpelling,
aiChatItems.simplifyLanguage,
],
},
],
selectionSuggestion: [
{
items: [
aiChatItems.replace,
aiChatItems.insertBelow,
aiChatItems.discard,
aiChatItems.tryAgain,
],
},
],
};
export const AIMenuItems = ({
aiEditorRef,
setValue,
}: {
aiEditorRef: React.MutableRefObject<PlateEditor | null>;
setValue: (value: string) => void;
}) => {
const { editor, useOption } = useEditorPlugin(AIChatPlugin);
const { messages } = useOption('chat');
const isSelecting = useIsSelecting();
const menuState = useMemo(() => {
if (messages && messages.length > 0) {
return isSelecting ? 'selectionSuggestion' : 'cursorSuggestion';
}
return isSelecting ? 'selectionCommand' : 'cursorCommand';
}, [isSelecting, messages]);
const menuGroups = useMemo(() => {
const items = menuStateItems[menuState];
return items;
}, [menuState]);
useEffect(() => {
if (menuGroups.length > 0 && menuGroups[0].items.length > 0) {
setValue(menuGroups[0].items[0].value);
}
}, [menuGroups, setValue]);
return (
<>
{menuGroups.map((group, index) => (
<CommandGroup key={index} heading={group.heading}>
{group.items.map((menuItem) => (
<CommandItem
key={menuItem.value}
className="gap-2 [&_svg]:size-4 [&_svg]:text-muted-foreground"
value={menuItem.value}
onSelect={() => {
menuItem.onSelect?.({
aiEditor: aiEditorRef.current!,
editor: editor,
});
}}
>
{menuItem.icon}
<span>{menuItem.label}</span>
</CommandItem>
))}
</CommandGroup>
))}
</>
);
};
'use client';
import React, { memo } from 'react';
import { AIChatPlugin, useLastAssistantMessage } from '@udecode/plate-ai/react';
import { Plate, useEditorPlugin } from '@udecode/plate-common/react';
import { deserializeMd } from '@udecode/plate-markdown';
import { Editor } from './editor';
import type { PlateEditor } from '@udecode/plate-common/react';
export const AIChatEditor = memo(
({
aiEditorRef,
}: {
aiEditorRef: React.MutableRefObject<PlateEditor | null>;
}) => {
const { getOptions } = useEditorPlugin(AIChatPlugin);
const lastAssistantMessage = useLastAssistantMessage();
const content = lastAssistantMessage?.content ?? '';
const aiEditor = React.useMemo(() => {
const editor = getOptions().createAIEditor();
const fragment = deserializeMd(editor, content);
editor.children =
fragment.length > 0 ? fragment : editor.api.create.value();
return editor;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
React.useEffect(() => {
if (aiEditor && content) {
aiEditorRef.current = aiEditor;
setTimeout(() => {
aiEditor.tf.setValue(deserializeMd(aiEditor, content));
}, 0);
}
}, [aiEditor, aiEditorRef, content]);
if (!content) return null;
return (
<Plate editor={aiEditor}>
<Editor variant="aiChat" readOnly />
</Plate>
);
}
);
Plate Plus
In Plate Plus, we provides more advanced styles and complete backend setup
All of the backend setup is available in Potion template.
Build your editor even faster
Complete, deployable AI-powered template with backend.
All components included.
Customizable and extensible.
Get all-accessCustomizable and extensible.