Skip to content

Commit

Permalink
ai
Browse files Browse the repository at this point in the history
  • Loading branch information
zbeyens committed Oct 25, 2024
1 parent e57ae39 commit 6b9894f
Show file tree
Hide file tree
Showing 28 changed files with 293 additions and 180 deletions.
58 changes: 11 additions & 47 deletions apps/www/content/docs/ai.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,13 @@ docs:

<ComponentPreview name="playground-demo" id="ai" />

<ComponentPreviewPro name="pro-iframe-demo" id="pro-ai" component="ai" />

<PackageInfo>

## Features

- AI-powered menu with predefined commands
- Combobox menu with predefined commands:
- Generate: continue writing, add summary, explain
- Edit: improve writing, make it longer or shorter, fix spelling & grammar, simplify language
- Three trigger modes:
- Cursor mode: trigger at block end
- Selection mode: trigger with selected text
Expand Down Expand Up @@ -187,54 +187,21 @@ const plugins = [
];
```

- [AIMenu](https://pro.platejs.org/docs/components/ai-menu) (Plus)
- [AIMenu](/docs/components/ai-menu)

The [SelectionOverlayPlugin](https://pro.platejs.org/docs/components/cursor-overlay):

- Maintains selection highlight when editor loses focus
- Essential for AI menu and other external input interactions
- Prevents double selection with `data-plate-prevent-overlay` attribute

<Image
src="/ai-selection.png"
alt="AI selection overlay"
width={1920}
height={1080}
className="w-full"
/>

### AI SDK

This plugin is depending on the [ai](https://npmjs.com/package/ai) package:

- Setup a [route handler](https://sdk.vercel.ai/docs/getting-started/nextjs-app-router#create-a-route-handler) using [streamText](https://sdk.vercel.ai/docs/ai-sdk-core/generating-text#streamtext).
- Wire up [useChat](https://sdk.vercel.ai/docs/reference/ai-sdk-ui/use-chat) in your [AI menu](#ai-menu) component.

### AI Menu

Work in progress.

{/* ## AI Menu Items */}

{/* Before learning how to create custom commands, you need to know that the ai plugin will provide three modes for opening the ai menu: */}

{/* 1. Cursor mode: open the AI menu at the end of a block. */}
{/* 2. Selection mode: select some texts and open the AI menu. */}
{/* 3. Block selection mode: select some blocks and open the AI menu. */}
{/* 4. After the AI completes the first generation, we don't close the AI menu but instead modify the commands. We call it **Suggestion** mode. */}

{/* Due to the special nature of Suggestion mode: whether Cursor mode or Selection mode ends, it will switch to Suggestion mode. */}
{/* To distinguish between these two sets of commands, we need to maintain four different menus in total. */}
- Wire up [useChat](https://sdk.vercel.ai/docs/reference/ai-sdk-ui/use-chat) in your [AI menu](/docs/components/ai-menu) component.

{/* The following is all the commands you can see in the `ai-menu-items.tsx` file. */}
{/* menuState (draft doc) */}

{/* - Show when you open the ai menu by `space` at the end of a block. */}
{/* - Show when you open the ai menu by `space` and then complete the first generation. */}
{/* - Show when you open the ai menu with selected text. */}
{/* - Show when you open the ai menu with selected blocks, then complete the first generation. */}

{/* If you want to modify the AI Menu style, you should check the [menu](/docs/components/menu) component docs. \ \ */}

## Keyboard Shortcuts

Expand All @@ -252,22 +219,19 @@ Work in progress.

### Plate UI

Work in progress.
Refer to the preview above.

### Plate Plus

Refer to the preview above.

- AI chat powered by AI SDK (OpenAI) with history persistence.
- Combobox menu with:
- Predefined commands
- Free-form prompt input
- Multiple ways to trigger:
- Combobox menu with free-form prompt input
- Additional trigger methods:
- Block menu button
- Slash command menu
- Keyboard shortcuts
- Beautifully crafted UI

<ComponentPreviewPro name="pro-iframe-demo" id="pro-ai" component="ai" />


## Plugins

### AIPlugin
Expand Down
46 changes: 45 additions & 1 deletion apps/www/public/r/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,49 @@
"registryDependencies": [],
"type": "registry:ui"
},
{
"dependencies": [
"@udecode/plate-ai",
"@udecode/plate-markdown",
"@udecode/plate-selection"
],
"files": [
{
"path": "plate-ui/ai-menu.tsx",
"type": "registry:ui"
},
{
"path": "plate-ui/ai-chat-editor.tsx",
"type": "registry:ui"
},
{
"path": "plate-ui/ai-menu-items.tsx",
"type": "registry:ui"
}
],
"name": "ai-menu",
"registryDependencies": [
"button",
"menu",
"textarea",
"editor"
],
"type": "registry:ui"
},
{
"dependencies": [],
"files": [
{
"path": "plate-ui/ai-toolbar-button.tsx",
"type": "registry:ui"
}
],
"name": "ai-toolbar-button",
"registryDependencies": [
"toolbar"
],
"type": "registry:ui"
},
{
"dependencies": [
"@udecode/plate-selection"
Expand Down Expand Up @@ -530,7 +573,8 @@
},
{
"dependencies": [
"@radix-ui/react-dialog"
"@radix-ui/react-dialog",
"@radix-ui/react-visually-hidden"
],
"files": [
{
Expand Down
35 changes: 35 additions & 0 deletions apps/www/public/r/styles/default/ai-menu.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"dependencies": [
"@udecode/plate-ai",
"@udecode/plate-markdown",
"@udecode/plate-selection"
],
"files": [
{
"content": "import * as React from 'react';\n\nimport { faker } from '@faker-js/faker';\nimport { AIChatPlugin, useEditorChat } from '@udecode/plate-ai/react';\nimport {\n type TElement,\n type TNodeEntry,\n getAncestorNode,\n getBlocks,\n isElementEmpty,\n isSelectionAtBlockEnd,\n} from '@udecode/plate-common';\nimport {\n type PlateEditor,\n toDOMNode,\n useEditorPlugin,\n useHotkeys,\n} from '@udecode/plate-common/react';\nimport {\n BlockSelectionPlugin,\n useIsSelecting,\n} from '@udecode/plate-selection/react';\nimport { useChat } from 'ai/react';\nimport { Loader2Icon } from 'lucide-react';\n\nimport { AIChatEditor } from './ai-chat-editor';\nimport { AIMenuItems } from './ai-menu-items';\nimport { Command, CommandList, InputCommand } from './command';\nimport { Popover, PopoverAnchor, PopoverContent } from './popover';\n\nexport function AIMenu() {\n const { api, editor, useOption } = useEditorPlugin(AIChatPlugin);\n const open = useOption('open');\n const mode = useOption('mode');\n const isSelecting = useIsSelecting();\n\n const aiEditorRef = React.useRef<PlateEditor | null>(null);\n const [value, setValue] = React.useState('');\n\n const chat = useChat({\n id: 'editor',\n // API to be implemented\n api: '/api/ai',\n // Mock the API response. Remove it when you implement the route /api/ai\n fetch: async () => {\n await new Promise((resolve) => setTimeout(resolve, 400));\n \n const stream = fakeStreamText();\n\n return new Response(stream, {\n headers: {\n Connection: 'keep-alive',\n 'Content-Type': 'text/plain',\n },\n });\n },\n });\n\n const { input, isLoading, messages, setInput } = chat;\n const [anchorElement, setAnchorElement] = React.useState<HTMLElement | null>(\n null\n );\n\n const setOpen = (open: boolean) => {\n if (open) {\n api.aiChat.show();\n } else {\n api.aiChat.hide();\n }\n };\n\n const show = (anchorElement: HTMLElement) => {\n setAnchorElement(anchorElement);\n setOpen(true);\n };\n\n useEditorChat({\n chat,\n onOpenBlockSelection: (blocks: TNodeEntry[]) => {\n show(toDOMNode(editor, blocks.at(-1)![0])!);\n },\n onOpenChange: (open) => {\n if (!open) {\n setAnchorElement(null);\n setInput('');\n }\n },\n onOpenCursor: () => {\n const ancestor = getAncestorNode(editor)?.[0] as TElement;\n\n if (!isSelectionAtBlockEnd(editor) && !isElementEmpty(editor, ancestor)) {\n editor\n .getApi(BlockSelectionPlugin)\n .blockSelection.addSelectedRow(ancestor.id as string);\n }\n\n show(toDOMNode(editor, ancestor)!);\n },\n onOpenSelection: () => {\n show(toDOMNode(editor, getBlocks(editor).at(-1)![0])!);\n },\n });\n\n useHotkeys(\n 'meta+j',\n () => {\n api.aiChat.show();\n },\n { enableOnContentEditable: true, enableOnFormTags: true }\n );\n\n useHotkeys('escape', () => {\n if (isLoading) {\n api.aiChat.stop();\n } else {\n api.aiChat.hide();\n }\n });\n\n return (\n <Popover open={open} onOpenChange={setOpen} modal={false}>\n <PopoverAnchor virtualRef={{ current: anchorElement }} />\n\n <PopoverContent\n className=\"border-none bg-transparent p-0 shadow-none\"\n style={{\n width: anchorElement?.offsetWidth,\n }}\n align=\"center\"\n avoidCollisions={false}\n side=\"bottom\"\n >\n <Command\n className=\"w-full rounded-lg border shadow-md\"\n value={value}\n onValueChange={setValue}\n >\n {mode === 'chat' && isSelecting && messages.length > 0 && (\n <AIChatEditor aiEditorRef={aiEditorRef} />\n )}\n\n {isLoading ? (\n <div className=\"flex grow select-none items-center gap-2 p-2 text-sm text-muted-foreground\">\n <Loader2Icon className=\"size-4 animate-spin\" />\n {messages.length > 1 ? 'Editing...' : 'Thinking...'}\n </div>\n ) : (\n <InputCommand\n variant=\"ghost\"\n className=\"rounded-none border-b border-solid border-border [&_svg]:hidden\"\n value={input}\n onValueChange={setInput}\n placeholder=\"Ask AI anything...\"\n autoFocus\n />\n )}\n\n {!isLoading && (\n <CommandList>\n <AIMenuItems aiEditorRef={aiEditorRef} setValue={setValue} />\n </CommandList>\n )}\n </Command>\n </PopoverContent>\n </Popover>\n );\n}\n\n// Used for testing. Remove it after implementing useChat api.\nconst fakeStreamText = ({\n chunkCount = 10,\n streamProtocol = 'data',\n}: {\n chunkCount?: number;\n streamProtocol?: 'data' | 'text';\n} = {}) => {\n const chunks = Array.from({ length: chunkCount }, () => ({\n delay: faker.number.int({ max: 150, min: 50 }),\n texts: faker.lorem.words({ max: 3, min: 1 }) + ' ',\n }));\n const encoder = new TextEncoder();\n\n return new ReadableStream({\n async start(controller) {\n for (const chunk of chunks) {\n await new Promise((resolve) => setTimeout(resolve, chunk.delay));\n\n if (streamProtocol === 'text') {\n controller.enqueue(encoder.encode(chunk.texts));\n } else {\n controller.enqueue(\n encoder.encode(`0:${JSON.stringify(chunk.texts)}\\n`)\n );\n }\n }\n\n if (streamProtocol === 'data') {\n controller.enqueue(\n `d:{\"finishReason\":\"stop\",\"usage\":{\"promptTokens\":0,\"completionTokens\":${chunks.length}}}\\n`\n );\n }\n\n controller.close();\n },\n });\n};\n",
"path": "plate-ui/ai-menu.tsx",
"target": "components/plate-ui/ai-menu.tsx",
"type": "registry:ui"
},
{
"content": "'use client';\n\nimport React, { memo } from 'react';\n\nimport { AIChatPlugin, useLastAssistantMessage } from '@udecode/plate-ai/react';\nimport {\n type PlateEditor,\n Plate,\n useEditorPlugin,\n} from '@udecode/plate-common/react';\nimport { deserializeMd } from '@udecode/plate-markdown';\n\nimport { Editor } from './editor';\n\nexport const AIChatEditor = memo(\n ({\n aiEditorRef,\n }: {\n aiEditorRef: React.MutableRefObject<PlateEditor | null>;\n }) => {\n const { getOptions } = useEditorPlugin(AIChatPlugin);\n const lastAssistantMessage = useLastAssistantMessage();\n const content = lastAssistantMessage?.content ?? '';\n\n const aiEditor = React.useMemo(() => {\n const editor = getOptions().createAIEditor();\n\n const fragment = deserializeMd(editor, content);\n editor.children =\n fragment.length > 0 ? fragment : editor.api.create.value();\n\n return editor;\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n React.useEffect(() => {\n if (aiEditor && content) {\n aiEditorRef.current = aiEditor;\n\n setTimeout(() => {\n aiEditor.tf.setValue(deserializeMd(aiEditor, content));\n }, 0);\n }\n }, [aiEditor, aiEditorRef, content]);\n\n if (!content) return null;\n\n return (\n <Plate editor={aiEditor}>\n <Editor variant=\"aiChat\" readOnly />\n </Plate>\n );\n }\n);\n",
"path": "plate-ui/ai-chat-editor.tsx",
"target": "components/plate-ui/ai-chat-editor.tsx",
"type": "registry:ui"
},
{
"content": "import { useEffect, useMemo } from 'react';\n\nimport { AIChatPlugin, AIPlugin } from '@udecode/plate-ai/react';\nimport {\n getAncestorNode,\n getEndPoint,\n getNodeString,\n} from '@udecode/plate-common';\nimport {\n type PlateEditor,\n focusEditor,\n useEditorPlugin,\n} from '@udecode/plate-common/react';\nimport { useIsSelecting } from '@udecode/plate-selection/react';\nimport {\n Album,\n BadgeHelp,\n Check,\n CornerUpLeft,\n FeatherIcon,\n ListEnd,\n ListMinus,\n ListPlus,\n PenLine,\n Wand,\n X,\n} from 'lucide-react';\n\nimport { CommandGroup, CommandItem } from './command';\n\nexport type EditorChatState =\n | 'cursorCommand'\n | 'cursorSuggestion'\n | 'selectionCommand'\n | 'selectionSuggestion';\n\nexport const aiChatItems = {\n accept: {\n icon: <Check />,\n label: 'Accept',\n value: 'accept',\n onSelect: ({ editor }) => {\n editor.getTransforms(AIChatPlugin).aiChat.accept();\n focusEditor(editor, getEndPoint(editor, editor.selection!));\n },\n },\n continueWrite: {\n icon: <PenLine />,\n label: 'Continue writing',\n value: 'continueWrite',\n onSelect: ({ editor }) => {\n const ancestorNode = getAncestorNode(editor);\n const isEmpty = getNodeString(ancestorNode![0]).trim().length === 0;\n\n void editor.getApi(AIChatPlugin).aiChat.submit({\n mode: 'insert',\n prompt: isEmpty\n ? `<Document>\n{editor}\n</Document>\nStart writing a new paragraph AFTER <Document> ONLY ONE SENTENCE`\n : 'Continue writing at the end of <Block> ONLY ONE SENTENCE',\n });\n },\n },\n discard: {\n icon: <X />,\n label: 'Discard',\n shortcut: 'Escape',\n value: 'discard',\n onSelect: ({ editor }) => {\n editor.getTransforms(AIPlugin).ai.undo();\n editor.getApi(AIChatPlugin).aiChat.hide();\n },\n },\n explain: {\n icon: <BadgeHelp className=\"size-4\" />,\n label: 'Explain',\n value: 'explain',\n onSelect: ({ editor }) => {\n void editor.getApi(AIChatPlugin).aiChat.submit({\n prompt: {\n default: 'Explain {editor}',\n selecting: 'Explain',\n },\n });\n },\n },\n fixSpelling: {\n icon: <Check />,\n label: 'Fix spelling & grammar',\n value: 'fixSpelling',\n onSelect: ({ editor }) => {\n void editor.getApi(AIChatPlugin).aiChat.submit({\n prompt: 'Fix spelling and grammar',\n });\n },\n },\n improveWriting: {\n icon: <Wand />,\n label: 'Improve writing',\n value: 'improveWriting',\n onSelect: ({ editor }) => {\n void editor.getApi(AIChatPlugin).aiChat.submit({\n prompt: 'Improve the writing',\n });\n },\n },\n insertBelow: {\n icon: <ListEnd />,\n label: 'Insert below',\n value: 'insertBelow',\n onSelect: ({ aiEditor, editor }) => {\n void editor.getTransforms(AIChatPlugin).aiChat.insertBelow(aiEditor);\n },\n },\n makeLonger: {\n icon: <ListPlus />,\n label: 'Make longer',\n value: 'makeLonger',\n onSelect: ({ editor }) => {\n void editor.getApi(AIChatPlugin).aiChat.submit({\n prompt: 'Make longer',\n });\n },\n },\n makeShorter: {\n icon: <ListMinus />,\n label: 'Make shorter',\n value: 'makeShorter',\n onSelect: ({ editor }) => {\n void editor.getApi(AIChatPlugin).aiChat.submit({\n prompt: 'Make shorter',\n });\n },\n },\n replace: {\n icon: <Check />,\n label: 'Replace selection',\n value: 'replace',\n onSelect: ({ aiEditor, editor }) => {\n void editor.getTransforms(AIChatPlugin).aiChat.replaceSelection(aiEditor);\n },\n },\n simplifyLanguage: {\n icon: <FeatherIcon />,\n label: 'Simplify language',\n value: 'simplifyLanguage',\n onSelect: ({ editor }) => {\n void editor.getApi(AIChatPlugin).aiChat.submit({\n prompt: 'Simplify the language',\n });\n },\n },\n summarize: {\n icon: <Album className=\"size-4\" />,\n label: 'Add a summary',\n value: 'summarize',\n onSelect: ({ editor }) => {\n void editor.getApi(AIChatPlugin).aiChat.submit({\n mode: 'insert',\n prompt: {\n default: 'Summarize {editor}',\n selecting: 'Summarize',\n },\n });\n },\n },\n tryAgain: {\n icon: <CornerUpLeft />,\n label: 'Try again',\n value: 'tryAgain',\n onSelect: ({ editor }) => {\n void editor.getApi(AIChatPlugin).aiChat.reload();\n },\n },\n} satisfies Record<\n string,\n {\n icon: React.ReactNode;\n label: string;\n value: string;\n component?: React.ComponentType<{ menuState: EditorChatState }>;\n filterItems?: boolean;\n items?: { label: string; value: string }[];\n shortcut?: string;\n onSelect?: ({\n aiEditor,\n editor,\n }: {\n aiEditor: PlateEditor;\n editor: PlateEditor;\n }) => void;\n }\n>;\n\nconst menuStateItems: Record<\n EditorChatState,\n {\n items: (typeof aiChatItems)[keyof typeof aiChatItems][];\n heading?: string;\n }[]\n> = {\n cursorCommand: [\n {\n items: [\n aiChatItems.continueWrite,\n aiChatItems.summarize,\n aiChatItems.explain,\n ],\n },\n ],\n cursorSuggestion: [\n {\n items: [aiChatItems.accept, aiChatItems.discard, aiChatItems.tryAgain],\n },\n ],\n selectionCommand: [\n {\n items: [\n aiChatItems.improveWriting,\n aiChatItems.makeLonger,\n aiChatItems.makeShorter,\n aiChatItems.fixSpelling,\n aiChatItems.simplifyLanguage,\n ],\n },\n ],\n selectionSuggestion: [\n {\n items: [\n aiChatItems.replace,\n aiChatItems.insertBelow,\n aiChatItems.discard,\n aiChatItems.tryAgain,\n ],\n },\n ],\n};\n\nexport const AIMenuItems = ({\n aiEditorRef,\n setValue,\n}: {\n aiEditorRef: React.MutableRefObject<PlateEditor | null>;\n setValue: (value: string) => void;\n}) => {\n const { editor, useOption } = useEditorPlugin(AIChatPlugin);\n const { messages } = useOption('chat');\n const isSelecting = useIsSelecting();\n\n const menuState = useMemo(() => {\n if (messages && messages.length > 0) {\n return isSelecting ? 'selectionSuggestion' : 'cursorSuggestion';\n }\n\n return isSelecting ? 'selectionCommand' : 'cursorCommand';\n }, [isSelecting, messages]);\n\n const menuGroups = useMemo(() => {\n const items = menuStateItems[menuState];\n\n return items;\n }, [menuState]);\n\n useEffect(() => {\n if (menuGroups.length > 0 && menuGroups[0].items.length > 0) {\n setValue(menuGroups[0].items[0].value);\n }\n }, [menuGroups, setValue]);\n\n return (\n <>\n {menuGroups.map((group, index) => (\n <CommandGroup key={index} heading={group.heading}>\n {group.items.map((menuItem) => (\n <CommandItem\n key={menuItem.value}\n className=\"gap-2 [&_svg]:size-4 [&_svg]:text-muted-foreground\"\n value={menuItem.value}\n onSelect={() => {\n menuItem.onSelect?.({\n aiEditor: aiEditorRef.current!,\n editor: editor,\n });\n }}\n >\n {menuItem.icon}\n <span>{menuItem.label}</span>\n </CommandItem>\n ))}\n </CommandGroup>\n ))}\n </>\n );\n};\n",
"path": "plate-ui/ai-menu-items.tsx",
"target": "components/plate-ui/ai-menu-items.tsx",
"type": "registry:ui"
}
],
"name": "ai-menu",
"registryDependencies": [
"button",
"menu",
"textarea",
"editor"
],
"type": "registry:ui"
}
Loading

0 comments on commit 6b9894f

Please sign in to comment.