'use client'; import * as React from 'react'; import { AIChatPlugin, AIPlugin, useEditorChat, useLastAssistantMessage } from '@platejs/ai/react'; import { BlockSelectionPlugin, useIsSelecting } from '@platejs/selection/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 { Album, CircleQuestion, ClipboardCheck, Check2, ArrowUpLeft, Feather, Underwear, ObjRemove, AddBelow, Loader, MediaPause, Pen2, FaceGrin2, WandSparkle, Xmark, BookOpen, Plus, Minus } from '@/components/ui/icons'; import { type NodeEntry, type SlateEditor, isHotkey, NodeApi } from 'platejs'; import { useEditorPlugin, useHotkeys, usePluginOption } from 'platejs/react'; import { type PlateEditor, useEditorRef } from 'platejs/react'; import { Button } from '@/components/ui/buttons'; import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'; import { PopoverBase, PopoverAnchor, PopoverContent } from '@/components/ui/popover'; import { cn } from '@/lib/utils'; import { AIChatEditor } from './AIChatEditor'; export function AIMenu() { const { api, editor } = useEditorPlugin(AIChatPlugin); const open = usePluginOption(AIChatPlugin, 'open'); const mode = usePluginOption(AIChatPlugin, 'mode'); const streaming = usePluginOption(AIChatPlugin, 'streaming'); const isSelecting = useIsSelecting(); const [value, setValue] = React.useState(''); const chat = { input: '', messages: [], setInput: () => {}, status: 'idle' }; const { input, messages, setInput, status } = chat; const [anchorElement, setAnchorElement] = React.useState(null); const content = useLastAssistantMessage()?.content; React.useEffect(() => { if (streaming) { const anchor = api.aiChat.node({ anchor: true }); setTimeout(() => { const anchorDom = editor.api.toDOMNode(anchor![0])!; setAnchorElement(anchorDom); }, 0); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [streaming]); 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: NodeEntry[]) => { // show(editor.api.toDOMNode(blocks.at(-1)![0])!); // }, // onOpenChange: (open) => { // if (!open) { // setAnchorElement(null); // setInput(''); // } // }, // onOpenCursor: () => { // const [ancestor] = editor.api.block({ highest: true })!; // if (!editor.api.isAt({ end: true }) && !editor.api.isEmpty(ancestor)) { // editor.getApi(BlockSelectionPlugin).blockSelection.set(ancestor.id as string); // } // show(editor.api.toDOMNode(ancestor)!); // }, // onOpenSelection: () => { // show(editor.api.toDOMNode(editor.api.blocks().at(-1)![0])!); // } // }); // useHotkeys('esc', () => { // api.aiChat.stop(); // // remove when you implement the route /api/ai/command // chat._abortFakeStream(); // }); const isLoading = status === 'streaming' || status === 'submitted'; if (isLoading && mode === 'insert') { return null; } return ( { e.preventDefault(); api.aiChat.hide(); }} align="center" side="bottom"> {mode === 'chat' && isSelecting && content && } {isLoading ? (
{messages.length > 1 ? 'Editing...' : 'Thinking...'}
) : ( { 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..." data-plate-focus autoFocus /> )} {!isLoading && ( )}
); } type EditorChatState = | 'cursorCommand' | 'cursorSuggestion' | 'selectionCommand' | 'selectionSuggestion'; const aiChatItems = { accept: { icon: , label: 'Accept', value: 'accept', onSelect: ({ editor }) => { editor.getTransforms(AIChatPlugin).aiChat.accept(); editor.tf.focus({ edge: 'end' }); } }, continueWrite: { icon: , label: 'Continue writing', value: 'continueWrite', onSelect: ({ editor }) => { const ancestorNode = editor.api.block({ highest: true }); if (!ancestorNode) return; const isEmpty = NodeApi.string(ancestorNode[0]).trim().length === 0; void editor.getApi(AIChatPlugin).aiChat.submit({ mode: 'insert', prompt: isEmpty ? ` {editor} Start writing a new paragraph AFTER ONLY ONE SENTENCE` : 'Continue writing AFTER ONLY ONE SENTENCE. DONT REPEAT THE TEXT.' }); } }, discard: { icon: , label: 'Discard', shortcut: 'Escape', value: 'discard', onSelect: ({ editor }) => { editor.getTransforms(AIPlugin).ai.undo(); editor.getApi(AIChatPlugin).aiChat.hide(); } }, emojify: { icon: , label: 'Emojify', value: 'emojify', onSelect: ({ editor }) => { void editor.getApi(AIChatPlugin).aiChat.submit({ prompt: 'Emojify' }); } }, explain: { icon: , label: 'Explain', value: 'explain', onSelect: ({ editor }) => { void editor.getApi(AIChatPlugin).aiChat.submit({ prompt: { default: 'Explain {editor}', selecting: 'Explain' } }); } }, fixSpelling: { icon: , label: 'Fix spelling & grammar', value: 'fixSpelling', onSelect: ({ editor }) => { void editor.getApi(AIChatPlugin).aiChat.submit({ prompt: 'Fix spelling and grammar' }); } }, generateMarkdownSample: { icon: , label: 'Generate Markdown sample', value: 'generateMarkdownSample', onSelect: ({ editor }) => { void editor.getApi(AIChatPlugin).aiChat.submit({ prompt: 'Generate a markdown sample' }); } }, generateMdxSample: { icon: , label: 'Generate MDX sample', value: 'generateMdxSample', onSelect: ({ editor }) => { void editor.getApi(AIChatPlugin).aiChat.submit({ prompt: 'Generate a mdx sample' }); } }, improveWriting: { icon: , label: 'Improve writing', value: 'improveWriting', onSelect: ({ editor }) => { void editor.getApi(AIChatPlugin).aiChat.submit({ prompt: 'Improve the writing' }); } }, insertBelow: { icon: , label: 'Insert below', value: 'insertBelow', onSelect: ({ aiEditor, editor }) => { void editor.getTransforms(AIChatPlugin).aiChat.insertBelow(aiEditor); } }, makeLonger: { icon: , label: 'Make longer', value: 'makeLonger', onSelect: ({ editor }) => { void editor.getApi(AIChatPlugin).aiChat.submit({ prompt: 'Make longer' }); } }, makeShorter: { icon: , label: 'Make shorter', value: 'makeShorter', onSelect: ({ editor }) => { void editor.getApi(AIChatPlugin).aiChat.submit({ prompt: 'Make shorter' }); } }, replace: { icon: , label: 'Replace selection', value: 'replace', onSelect: ({ aiEditor, editor }) => { void editor.getTransforms(AIChatPlugin).aiChat.replaceSelection(aiEditor); } }, simplifyLanguage: { icon: , label: 'Simplify language', value: 'simplifyLanguage', onSelect: ({ editor }) => { void editor.getApi(AIChatPlugin).aiChat.submit({ prompt: 'Simplify the language' }); } }, summarize: { icon: , label: 'Add a summary', value: 'summarize', onSelect: ({ editor }) => { void editor.getApi(AIChatPlugin).aiChat.submit({ mode: 'insert', prompt: { default: 'Summarize {editor}', selecting: 'Summarize' } }); } }, tryAgain: { icon: , 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: SlateEditor; editor: PlateEditor }) => void; } >; const menuStateItems: Record< EditorChatState, { items: (typeof aiChatItems)[keyof typeof aiChatItems][]; heading?: string; }[] > = { cursorCommand: [ { items: [ aiChatItems.generateMdxSample, aiChatItems.generateMarkdownSample, aiChatItems.continueWrite, aiChatItems.summarize, aiChatItems.explain ] } ], cursorSuggestion: [ { items: [aiChatItems.accept, aiChatItems.discard, aiChatItems.tryAgain] } ], selectionCommand: [ { items: [ aiChatItems.improveWriting, aiChatItems.emojify, aiChatItems.makeLonger, aiChatItems.makeShorter, aiChatItems.fixSpelling, aiChatItems.simplifyLanguage ] } ], selectionSuggestion: [ { items: [ aiChatItems.replace, aiChatItems.insertBelow, aiChatItems.discard, aiChatItems.tryAgain ] } ] }; export const AIMenuItems = ({ setValue }: { setValue: (value: string) => void }) => { const editor = useEditorRef(); const { messages } = usePluginOption(AIChatPlugin, 'chat'); const aiEditor = usePluginOption(AIChatPlugin, 'aiEditor')!; const isSelecting = useIsSelecting(); const menuState = React.useMemo(() => { if (messages && messages.length > 0) { return isSelecting ? 'selectionSuggestion' : 'cursorSuggestion'; } return isSelecting ? 'selectionCommand' : 'cursorCommand'; }, [isSelecting, messages]); const menuGroups = React.useMemo(() => { const items = menuStateItems[menuState]; return items; }, [menuState]); React.useEffect(() => { if (menuGroups.length > 0 && menuGroups[0].items.length > 0) { setValue(menuGroups[0].items[0].value); } }, [menuGroups, setValue]); return ( <> {menuGroups.map((group, index) => ( {group.items.map((menuItem) => ( { menuItem.onSelect?.({ aiEditor, editor: editor }); }}> {menuItem.icon} {menuItem.label} ))} ))} ); }; export function AILoadingBar() { const chat = usePluginOption(AIChatPlugin, 'chat'); const mode = usePluginOption(AIChatPlugin, 'mode'); const { status } = chat; const { api } = useEditorPlugin(AIChatPlugin); const isLoading = status === 'streaming' || status === 'submitted'; const visible = isLoading && mode === 'insert'; if (!visible) return null; return (
{status === 'submitted' ? 'Thinking...' : 'Writing...'}
); }