-
-
Notifications
You must be signed in to change notification settings - Fork 675
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
28 changed files
with
293 additions
and
180 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} |
Oops, something went wrong.