From a0b6e25a89a70580077ddbc28be15c48c62d2531 Mon Sep 17 00:00:00 2001 From: Felix Feng Date: Mon, 30 Sep 2024 21:26:57 +0800 Subject: [PATCH] useAI --- .../default/example/playground-demo.tsx | 2 +- .../src/registry/default/plate-ui/ai-menu.tsx | 179 +++------------- .../src/registry/default/plate-ui/menu.tsx | 3 +- packages/ai/package.json | 1 + packages/ai/src/react/ai/AIPlugin.ts | 3 +- packages/ai/src/react/ai/hook/index.ts | 5 + packages/ai/src/react/ai/hook/useAI.ts | 194 +++++++++++------- packages/ai/src/react/ai/index.ts | 3 + packages/ai/src/react/ai/types.ts | 15 ++ packages/menu/src/lib/Ariakit.ts | 2 + yarn.lock | 3 +- 11 files changed, 188 insertions(+), 222 deletions(-) create mode 100644 packages/ai/src/react/ai/hook/index.ts create mode 100644 packages/ai/src/react/ai/types.ts diff --git a/apps/www/src/registry/default/example/playground-demo.tsx b/apps/www/src/registry/default/example/playground-demo.tsx index e42c8baaf2..4e12491422 100644 --- a/apps/www/src/registry/default/example/playground-demo.tsx +++ b/apps/www/src/registry/default/example/playground-demo.tsx @@ -278,7 +278,7 @@ export const usePlaygroundEditor = (id: any = '', scrollSelector?: string) => { options: { areaOptions: { behaviour: { - startThreshold: 20, + startThreshold: 10, }, boundaries: `#${scrollSelector}`, container: `#${scrollSelector}`, diff --git a/apps/www/src/registry/default/plate-ui/ai-menu.tsx b/apps/www/src/registry/default/plate-ui/ai-menu.tsx index 249e2f6848..9df70d7515 100644 --- a/apps/www/src/registry/default/plate-ui/ai-menu.tsx +++ b/apps/www/src/registry/default/plate-ui/ai-menu.tsx @@ -1,29 +1,10 @@ /* eslint-disable tailwindcss/no-custom-classname */ 'use client'; -import React, { - type KeyboardEvent, - memo, - startTransition, - useCallback, - useEffect, - useMemo, - useState, -} from 'react'; - -import type { actionGroup } from '@udecode/plate-menu'; +import React, { memo, useMemo } from 'react'; import { cn } from '@udecode/cn'; -import { - AIPlugin, - getContent, - streamInsertText, - streamInsertTextSelection, -} from '@udecode/plate-ai/react'; -import { useEditorPlugin } from '@udecode/plate-core/react'; -import { Ariakit, filterAndBuildMenuTree } from '@udecode/plate-menu'; -import { focusEditor } from '@udecode/slate-react'; -import isHotkey from 'is-hotkey'; +import { useAI } from '@udecode/plate-ai/react'; import { Icons } from '@/components/icons'; @@ -47,138 +28,46 @@ import { Menu, comboboxVariants, renderSearchMenuItems } from './menu'; // eslint-disable-next-line react/display-name export const AIMenu = memo(({ children }: React.PropsWithChildren) => { - const { api, editor, setOption, setOptions, useOption } = - useEditorPlugin(AIPlugin); - - const isOpen = useOption('isOpen', editor.id); - const action = useOption('action'); - const aiState = useOption('aiState'); - const menuType = useOption('menuType'); - const setAction = (action: actionGroup) => setOption('action', action); - - const { aiEditor } = editor.useOptions(AIPlugin); - - // init - const menu = Ariakit.useMenuStore(); - useEffect(() => { - setOptions({ - store: menu, - }); - // eslint-disable-next`-line react-hooks/exhaustive-deps - }, [isOpen, menu, setOptions]); - - const [values, setValues] = useState(defaultValues); - const [searchValue, setSearchValue] = useState(''); - - const streamInsert = useCallback(async () => { - if (!aiEditor) return; - if (menuType === 'selection') { - const content = getContent(editor, aiEditor); - - await streamInsertTextSelection(editor, aiEditor, { - prompt: `user prompt is ${searchValue} the content is ${content}`, - }); - } else if (menuType === 'space') { - await streamInsertText(editor, { - prompt: searchValue, - }); - } - }, [aiEditor, editor, menuType, searchValue]); - - const onInputKeyDown = async (e: KeyboardEvent) => { - if (isHotkey('backspace')(e) && searchValue.length === 0) { - e.preventDefault(); - api.ai.hide(); - focusEditor(editor); - } - if (isHotkey('enter')(e)) await streamInsert(); - }; - - const onCloseMenu = useCallback(() => { - // close menu if ai is not generating - if (aiState === 'idle' || aiState === 'done') { - api.ai.hide(); - focusEditor(editor); - } - // abort if ai is generating - if (aiState === 'generating' || aiState === 'requesting') { - api.ai.abort(); - } - }, [aiState, api.ai, editor]); - - // close on escape - useEffect(() => { - const keydown = (e: any) => { - if (!isOpen || !isHotkey('escape')(e)) return; - - onCloseMenu(); - }; - - document.addEventListener('keydown', keydown); - - return () => { - document.removeEventListener('keydown', keydown); - }; - }, [aiState, api.ai, editor, isOpen, onCloseMenu]); - - // block editor while generating - // const setReadOnly = usePlateStore().set.readOnly(); - useEffect(() => { - if (aiState === 'generating') { - // setReadOnly(true); - } - if (aiState === 'done') { - // setReadOnly(false); - setSearchValue(''); - } - }, [aiState, setSearchValue]); + const { + CurrentItems, + action, + aiEditor, + aiState, + comboboxProps, + menuProps, + menuType, + searchItems, + submitButtonProps, + onCloseMenu, + } = useAI({ + aiActions: { + CursorCommandsActions, + CursorSuggestionActions, + SelectionCommandsActions, + SelectionSuggestionActions, + }, + aiCommands: { + CursorCommands, + CursorSuggestions, + SelectionCommands, + SelectionSuggestions, + }, + defaultValues, + }); useActionHandler(action, aiEditor!); - const [CurrentItems, CurrentActions] = React.useMemo(() => { - if (aiState === 'done') { - if (menuType === 'selection') - return [SelectionSuggestions, SelectionSuggestionActions]; - - return [CursorSuggestions, CursorSuggestionActions]; - } - if (menuType === 'selection') - return [SelectionCommands, SelectionCommandsActions]; - - return [CursorCommands, CursorCommandsActions]; - }, [aiState, menuType]); - /** IME */ - const [isComposing, setIsComposing] = useState(false); - - const searchItems = useMemo(() => { - return isComposing - ? [] - : filterAndBuildMenuTree(Object.values(CurrentActions), searchValue); - }, [CurrentActions, isComposing, searchValue]); return ( <> { - return editor.getApi(AIPlugin).ai.hide(); - }} - onValueChange={(value) => startTransition(() => setSearchValue(value))} - onValuesChange={(values: typeof defaultValues) => { - setValues(values); - }} + {...menuProps} combobox={ setSearchValue(e.target.value)} - onCompositionEnd={() => setIsComposing(false)} - onCompositionStart={() => setIsComposing(true)} - onKeyDown={onInputKeyDown} + {...comboboxProps} placeholder={ aiState === 'done' ? 'Tell AI what todo next' @@ -199,10 +88,7 @@ export const AIMenu = memo(({ children }: React.PropsWithChildren) => { size="icon" variant="ghost" className="ml-2" - disabled={searchValue.trim().length === 0} - onClick={async () => { - await streamInsert(); - }} + {...submitButtonProps} > @@ -233,9 +119,6 @@ export const AIMenu = memo(({ children }: React.PropsWithChildren) => { > } - setAction={setAction} - store={menu} - values={values} > {renderSearchMenuItems(searchItems, { hiddenOnEmpty: true }) ?? ( diff --git a/apps/www/src/registry/default/plate-ui/menu.tsx b/apps/www/src/registry/default/plate-ui/menu.tsx index 583b69bf22..0cd42c8ee7 100644 --- a/apps/www/src/registry/default/plate-ui/menu.tsx +++ b/apps/www/src/registry/default/plate-ui/menu.tsx @@ -71,7 +71,7 @@ const comboboxListVariants = cva('rounded-sm', { type variant = 'ai' | 'default'; type StyledMenuProps = MenuProps & { - variant: variant; + variant?: variant; }; export const Menu = React.forwardRef( @@ -326,6 +326,7 @@ export function renderSearchMenuItems( ) { if (!matches) return null; if (matches.length === 0) { + // eslint-disable-next-line react/jsx-no-useless-fragment if (options?.hiddenOnEmpty) return <>; return
No results
; diff --git a/packages/ai/package.json b/packages/ai/package.json index 23fee2aa90..862c2962f0 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -52,6 +52,7 @@ "dependencies": { "@udecode/plate-combobox": "38.0.1", "@udecode/plate-markdown": "38.0.1", + "@udecode/plate-menu": "38.0.1", "@udecode/plate-selection": "38.0.11", "lodash": "^4.17.21" }, diff --git a/packages/ai/src/react/ai/AIPlugin.ts b/packages/ai/src/react/ai/AIPlugin.ts index aaf45a58c2..5c3674f10d 100644 --- a/packages/ai/src/react/ai/AIPlugin.ts +++ b/packages/ai/src/react/ai/AIPlugin.ts @@ -1,4 +1,5 @@ import type { ExtendConfig } from '@udecode/plate-core'; +import type { AriakitTypes } from '@udecode/plate-menu'; import type { NodeEntry, Path } from 'slate'; import { @@ -53,7 +54,7 @@ export type AIPluginConfig = ExtendConfig< lastWorkPath: Path | null; menuType: 'selection' | 'space' | null; openEditorId: string | null; - store: any | null; + store: AriakitTypes.MenuStore | null; } & ExposeOptions & AIApi & AISelectors, diff --git a/packages/ai/src/react/ai/hook/index.ts b/packages/ai/src/react/ai/hook/index.ts new file mode 100644 index 0000000000..c2ddddf1da --- /dev/null +++ b/packages/ai/src/react/ai/hook/index.ts @@ -0,0 +1,5 @@ +/** + * @file Automatically generated by barrelsby. + */ + +export * from './useAI'; diff --git a/packages/ai/src/react/ai/hook/useAI.ts b/packages/ai/src/react/ai/hook/useAI.ts index 8c91b04555..fc30728da7 100644 --- a/packages/ai/src/react/ai/hook/useAI.ts +++ b/packages/ai/src/react/ai/hook/useAI.ts @@ -1,42 +1,54 @@ import React, { type KeyboardEvent, + startTransition, useCallback, useEffect, + useMemo, useState, } from 'react'; import { isHotkey } from '@udecode/plate-common'; import { focusEditor, useEditorPlugin } from '@udecode/plate-common/react'; +import { + type Action, + Ariakit, + filterAndBuildMenuTree, +} from '@udecode/plate-menu'; + +import type { AIActions, AICommands } from '../types'; import { type AIActionGroup, AIPlugin } from '../AIPlugin'; import { streamInsertText, streamInsertTextSelection } from '../stream'; import { getContent } from '../utils'; interface UseAIStateProps { - CursorCommands: () => JSX.Element; - CursorCommandsActions: any; - CursorSuggestionActions: any; - CursorSuggestions: () => JSX.Element; - SelectionCommands: () => JSX.Element; - SelectionCommandsActions: any; - SelectionSuggestionActions: any; - SelectionSuggestions: () => JSX.Element; + aiActions: AIActions; + aiCommands: AICommands; + defaultValues: Record; - menu: any; } -export const useAIState = ({ - CursorCommands, - CursorCommandsActions, - CursorSuggestionActions, - CursorSuggestions, - SelectionCommands, - SelectionCommandsActions, - SelectionSuggestionActions, - SelectionSuggestions, +export type AICommandsAction = Record; + +export const useAI = ({ + aiActions, + aiCommands, defaultValues, - menu, }: UseAIStateProps) => { + const { + CursorCommandsActions, + CursorSuggestionActions, + SelectionCommandsActions, + SelectionSuggestionActions, + } = aiActions; + + const { + CursorCommands, + CursorSuggestions, + SelectionCommands, + SelectionSuggestions, + } = aiCommands; + const { api, editor, setOption, setOptions, useOption } = useEditorPlugin(AIPlugin); @@ -44,10 +56,12 @@ export const useAIState = ({ const action = useOption('action'); const aiState = useOption('aiState'); const menuType = useOption('menuType'); + // eslint-disable-next-line react-hooks/exhaustive-deps const setAction = (action: AIActionGroup) => setOption('action', action); const { aiEditor } = editor.useOptions(AIPlugin); + const menu = Ariakit.useMenuStore(); useEffect(() => { setOptions({ store: menu, @@ -73,52 +87,7 @@ export const useAIState = ({ } }, [aiEditor, editor, menuType, searchValue]); - return { - CursorCommands, - CursorCommandsActions, - CursorSuggestionActions, - CursorSuggestions, - SelectionCommands, - SelectionCommandsActions, - SelectionSuggestionActions, - SelectionSuggestions, - action, - aiState, - api, - editor, - menuType, - searchValue, - setAction, - setSearchValue, - setValues, - streamInsert, - values, - }; -}; - -export const useAI = (props: ReturnType) => { - const { - CursorCommands, - CursorCommandsActions, - CursorSuggestionActions, - CursorSuggestions, - SelectionCommands, - SelectionCommandsActions, - SelectionSuggestionActions, - SelectionSuggestions, - action, - aiState, - api, - editor, - menuType, - searchValue, - setAction, - setSearchValue, - setValues, - streamInsert, - values, - } = props; - + // eslint-disable-next-line react-hooks/exhaustive-deps const onInputKeyDown = async (e: KeyboardEvent) => { if (isHotkey('backspace')(e) && searchValue.length === 0) { e.preventDefault(); @@ -127,6 +96,35 @@ export const useAI = (props: ReturnType) => { } if (isHotkey('enter')(e)) await streamInsert(); }; + + // TODO: move to API + const onCloseMenu = useCallback(() => { + // close menu if ai is not generating + if (aiState === 'idle' || aiState === 'done') { + api.ai.hide(); + focusEditor(editor); + } + // abort if ai is generating + if (aiState === 'generating' || aiState === 'requesting') { + api.ai.abort(); + } + }, [aiState, api.ai, editor]); + + // close on escape + useEffect(() => { + const keydown = (e: any) => { + if (!isOpen || !isHotkey('escape')(e)) return; + + onCloseMenu(); + }; + + document.addEventListener('keydown', keydown); + + return () => { + document.removeEventListener('keydown', keydown); + }; + }, [aiState, api.ai, editor, isOpen, onCloseMenu]); + const [CurrentItems, CurrentActions] = React.useMemo(() => { if (aiState === 'done') { if (menuType === 'selection') @@ -138,14 +136,70 @@ export const useAI = (props: ReturnType) => { return [SelectionCommands, SelectionCommandsActions]; return [CursorCommands, CursorCommandsActions]; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [aiState, menuType]); /** IME */ const [isComposing, setIsComposing] = useState(false); - // const searchItems = useMemo(() => { - // return isComposing - // ? [] - // : filterAndBuildMenuTree(Object.values(CurrentActions), searchValue); - // }, [CurrentActions, isComposing, searchValue]); + const searchItems = useMemo(() => { + return isComposing + ? [] + : filterAndBuildMenuTree(Object.values(CurrentActions), searchValue); + }, [CurrentActions, isComposing, searchValue]); + + /** Props */ + + const menuProps = useMemo(() => { + return { + flip: false, + loading: aiState === 'generating' || aiState === 'requesting', + open: isOpen, + setAction: setAction, + store: menu, + values: values, + onClickOutside: () => { + return editor.getApi(AIPlugin).ai.hide(); + }, + onValueChange: (value: string) => + startTransition(() => setSearchValue(value)), + onValuesChange: (values: typeof defaultValues) => { + setValues(values); + }, + }; + }, [aiState, editor, isOpen, menu, setAction, values]); + + const comboboxProps = useMemo(() => { + return { + id: '__potion_ai_menu_searchRef', + value: searchValue, + onChange: (e: React.ChangeEvent) => + setSearchValue(e.target.value), + onCompositionEnd: () => setIsComposing(false), + onCompositionStart: () => setIsComposing(true), + onKeyDown: onInputKeyDown, + }; + }, [onInputKeyDown, searchValue]); + + const submitButtonProps = useMemo(() => { + return { + disabled: searchValue.trim().length === 0, + onClick: async () => { + await streamInsert(); + }, + }; + }, [searchValue, streamInsert]); + + return { + CurrentItems, + action, + aiEditor, + aiState, + comboboxProps, + menuProps, + menuType, + searchItems, + submitButtonProps, + onCloseMenu, + }; }; diff --git a/packages/ai/src/react/ai/index.ts b/packages/ai/src/react/ai/index.ts index 5e38cbf6e9..b5a3f40ad6 100644 --- a/packages/ai/src/react/ai/index.ts +++ b/packages/ai/src/react/ai/index.ts @@ -3,5 +3,8 @@ */ export * from './AIPlugin'; +export * from './types'; +export * from './useAIHook'; +export * from './hook/index'; export * from './stream/index'; export * from './utils/index'; diff --git a/packages/ai/src/react/ai/types.ts b/packages/ai/src/react/ai/types.ts new file mode 100644 index 0000000000..7a7e087bfd --- /dev/null +++ b/packages/ai/src/react/ai/types.ts @@ -0,0 +1,15 @@ +import type { Action } from '@udecode/plate-menu'; + +export interface AIActions { + CursorCommandsActions: Record; + CursorSuggestionActions: Record; + SelectionCommandsActions: Record; + SelectionSuggestionActions: Record; +} + +export interface AICommands { + CursorCommands: React.FC; + CursorSuggestions: React.FC; + SelectionCommands: React.FC; + SelectionSuggestions: React.FC; +} diff --git a/packages/menu/src/lib/Ariakit.ts b/packages/menu/src/lib/Ariakit.ts index f50e6d8880..20cabed108 100644 --- a/packages/menu/src/lib/Ariakit.ts +++ b/packages/menu/src/lib/Ariakit.ts @@ -1 +1,3 @@ export * as Ariakit from '@ariakit/react'; + +export type * as AriakitTypes from '@ariakit/react'; diff --git a/yarn.lock b/yarn.lock index 8b85411ff9..01bee3eaf8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5898,6 +5898,7 @@ __metadata: dependencies: "@udecode/plate-combobox": "npm:38.0.1" "@udecode/plate-markdown": "npm:38.0.1" + "@udecode/plate-menu": "npm:38.0.1" "@udecode/plate-selection": "npm:38.0.11" lodash: "npm:^4.17.21" peerDependencies: @@ -6636,7 +6637,7 @@ __metadata: languageName: unknown linkType: soft -"@udecode/plate-menu@workspace:^, @udecode/plate-menu@workspace:packages/menu": +"@udecode/plate-menu@npm:38.0.1, @udecode/plate-menu@workspace:^, @udecode/plate-menu@workspace:packages/menu": version: 0.0.0-use.local resolution: "@udecode/plate-menu@workspace:packages/menu" dependencies: