diff --git a/apps/web/src/components/ui/report/EditorStatic.tsx b/apps/web/src/components/ui/report/EditorStatic.tsx new file mode 100644 index 000000000..8fc85a6bf --- /dev/null +++ b/apps/web/src/components/ui/report/EditorStatic.tsx @@ -0,0 +1,49 @@ +import * as React from 'react'; + +import type { VariantProps } from 'class-variance-authority'; + +import { cva } from 'class-variance-authority'; +import { type PlateStaticProps, PlateStatic } from 'platejs'; + +import { cn } from '@/lib/utils'; + +export const editorVariants = cva( + cn( + 'group/editor', + 'relative w-full cursor-text overflow-x-hidden break-words whitespace-pre-wrap select-text', + 'rounded-md ring-offset-background focus-visible:outline-none', + 'placeholder:text-muted-foreground/80 **:data-slate-placeholder:top-[auto_!important] **:data-slate-placeholder:text-muted-foreground/80 **:data-slate-placeholder:opacity-100!', + '[&_strong]:font-bold' + ), + { + defaultVariants: { + variant: 'none' + }, + variants: { + disabled: { + true: 'cursor-not-allowed opacity-50' + }, + focused: { + true: 'ring-2 ring-ring ring-offset-2' + }, + variant: { + ai: 'w-full px-0 text-base md:text-sm', + aiChat: + 'max-h-[min(70vh,320px)] w-full max-w-[700px] overflow-y-auto px-5 py-3 text-base md:text-sm', + default: 'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]', + demo: 'size-full px-16 pt-4 pb-72 text-base sm:px-[max(64px,calc(50%-350px))]', + fullWidth: 'size-full px-16 pt-4 pb-72 text-base sm:px-24', + none: '', + select: 'px-3 py-2 text-base data-readonly:w-fit' + } + } + } +); + +export function EditorStatic({ + className, + variant, + ...props +}: PlateStaticProps & VariantProps) { + return ; +} diff --git a/apps/web/src/components/ui/report/editor-base-kit.tsx b/apps/web/src/components/ui/report/editor-base-kit.tsx new file mode 100644 index 000000000..007094a68 --- /dev/null +++ b/apps/web/src/components/ui/report/editor-base-kit.tsx @@ -0,0 +1,41 @@ +import { AlignKit } from './plugins/align-kit'; +import { BasicBlocksKit } from './plugins/basic-blocks-kit'; +import { BasicMarksKit } from './plugins/basic-markd-kit'; +import { CalloutKit } from './plugins/callout-kit'; +import { CodeBlockKit } from './plugins/code-block-kit'; +import { ColumnKit } from './plugins/column-kit'; +import { CommentKit } from './plugins/comment-kit'; +import { DateKit } from './plugins/date-kit'; +import { FontKit } from './plugins/font-kit'; +import { LineHeightKit } from './plugins/line-height-kit'; +import { LinkKit } from './plugins/link-kit'; +import { ListKit } from './plugins/list-kit'; +import { MarkdownKit } from './plugins/markdown-kit'; +import { MathKit } from './plugins/math-kit'; +import { MediaKit } from './plugins/media-kit'; +import { SuggestionKit } from './plugins/suggestion-kit'; +import { TableKit } from './plugins/table-kit'; +import { TocKit } from './plugins/toc-kit'; +import { ToggleKit } from './plugins/toggle-kit'; + +export const BaseEditorKit = [ + ...BasicBlocksKit, + ...CodeBlockKit, + ...TableKit, + ...ToggleKit, + ...TocKit, + ...MediaKit, + ...CalloutKit, + ...ColumnKit, + ...MathKit, + ...DateKit, + ...LinkKit, + ...BasicMarksKit, + ...FontKit, + ...ListKit, + ...AlignKit, + ...LineHeightKit, + ...CommentKit, + ...SuggestionKit, + ...MarkdownKit +]; diff --git a/apps/web/src/components/ui/report/elements/AIChatEditor.tsx b/apps/web/src/components/ui/report/elements/AIChatEditor.tsx new file mode 100644 index 000000000..7a4ebcdbf --- /dev/null +++ b/apps/web/src/components/ui/report/elements/AIChatEditor.tsx @@ -0,0 +1,19 @@ +'use client'; + +import * as React from 'react'; + +import { useAIChatEditor } from '@platejs/ai/react'; +import { usePlateEditor } from 'platejs/react'; + +import { BaseEditorKit } from '../editor-base-kit'; +import { EditorStatic } from '../EditorStatic'; + +export const AIChatEditor = React.memo(function AIChatEditor({ content }: { content: string }) { + const aiEditor = usePlateEditor({ + plugins: BaseEditorKit + }); + + useAIChatEditor(aiEditor, content); + + return ; +}); diff --git a/apps/web/src/components/ui/report/elements/AIMenu.tsx b/apps/web/src/components/ui/report/elements/AIMenu.tsx new file mode 100644 index 000000000..ba048bc29 --- /dev/null +++ b/apps/web/src/components/ui/report/elements/AIMenu.tsx @@ -0,0 +1,529 @@ +'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...'} + +
+ ); +} diff --git a/apps/web/src/components/ui/report/elements/AINode.tsx b/apps/web/src/components/ui/report/elements/AINode.tsx new file mode 100644 index 000000000..42a54458f --- /dev/null +++ b/apps/web/src/components/ui/report/elements/AINode.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { AIChatPlugin } from '@platejs/ai/react'; +import { + type PlateElementProps, + type PlateTextProps, + PlateElement, + PlateText, + usePluginOption +} from 'platejs/react'; + +import { cn } from '@/lib/utils'; + +export function AILeaf(props: PlateTextProps) { + const streaming = usePluginOption(AIChatPlugin, 'streaming'); + const streamingLeaf = props.editor.getApi(AIChatPlugin).aiChat.node({ streaming: true }); + + const isLast = streamingLeaf?.[0] === props.text; + + return ( + + ); +} + +export function AIAnchorElement(props: PlateElementProps) { + return ( + +
+ + ); +} diff --git a/apps/web/src/components/ui/report/plugins/ai-kit.tsx b/apps/web/src/components/ui/report/plugins/ai-kit.tsx new file mode 100644 index 000000000..6accc5e2a --- /dev/null +++ b/apps/web/src/components/ui/report/plugins/ai-kit.tsx @@ -0,0 +1,171 @@ +'use client'; + +import type { AIChatPluginConfig } from '@platejs/ai/react'; + +import { streamInsertChunk, withAIBatch } from '@platejs/ai'; +import { AIChatPlugin, AIPlugin, useChatChunk } from '@platejs/ai/react'; +import { KEYS, PathApi } from 'platejs'; +import { usePluginOption } from 'platejs/react'; + +import { AILoadingBar, AIMenu } from '../elements/AIMenu'; +import { AIAnchorElement, AILeaf } from '../elements/AINode'; + +import { CursorOverlayKit } from './cursor-overlay-kit'; +import { MarkdownKit } from './markdown-kit'; + +export const aiChatPlugin = AIChatPlugin.extend({ + options: { + chatOptions: {}, + promptTemplate: ({ isBlockSelecting, isSelecting }) => { + return isBlockSelecting + ? PROMPT_TEMPLATES.userBlockSelecting + : isSelecting + ? PROMPT_TEMPLATES.userSelecting + : PROMPT_TEMPLATES.userDefault; + }, + systemTemplate: ({ isBlockSelecting, isSelecting }) => { + return isBlockSelecting + ? PROMPT_TEMPLATES.systemBlockSelecting + : isSelecting + ? PROMPT_TEMPLATES.systemSelecting + : PROMPT_TEMPLATES.systemDefault; + } + }, + render: { + afterContainer: AILoadingBar, + afterEditable: AIMenu, + node: AIAnchorElement + }, + shortcuts: { show: { keys: 'mod+j' } }, + useHooks: ({ editor, getOption }) => { + const mode = usePluginOption({ key: KEYS.aiChat } as AIChatPluginConfig, 'mode'); + + useChatChunk({ + onChunk: ({ chunk, isFirst, nodes }) => { + if (isFirst && mode == 'insert') { + editor.tf.withoutSaving(() => { + editor.tf.insertNodes( + { + children: [{ text: '' }], + type: KEYS.aiChat + }, + { + at: PathApi.next(editor.selection!.focus.path.slice(0, 1)) + } + ); + }); + editor.setOption(AIChatPlugin, 'streaming', true); + } + + if (mode === 'insert' && nodes.length > 0) { + withAIBatch( + editor, + () => { + if (!getOption('streaming')) return; + editor.tf.withScrolling(() => { + streamInsertChunk(editor, chunk, { + textProps: { + ai: true + } + }); + }); + }, + { split: isFirst } + ); + } + }, + onFinish: () => { + editor.setOption(AIChatPlugin, 'streaming', false); + editor.setOption(AIChatPlugin, '_blockChunks', ''); + editor.setOption(AIChatPlugin, '_blockPath', null); + } + }); + } +}); + +export const AIKit = [ + ...CursorOverlayKit, + ...MarkdownKit, + AIPlugin.withComponent(AILeaf), + aiChatPlugin +]; + +const systemCommon = `\ +You are an advanced AI-powered note-taking assistant, designed to enhance productivity and creativity in note management. +Respond directly to user prompts with clear, concise, and relevant content. Maintain a neutral, helpful tone. + +Rules: +- is the entire note the user is working on. +- is a reminder of how you should reply to INSTRUCTIONS. It does not apply to questions. +- Anything else is the user prompt. +- Your response should be tailored to the user's prompt, providing precise assistance to optimize note management. +- For INSTRUCTIONS: Follow the exactly. Provide ONLY the content to be inserted or replaced. No explanations or comments. +- For QUESTIONS: Provide a helpful and concise answer. You may include brief explanations if necessary. +- CRITICAL: DO NOT remove or modify the following custom MDX tags: , , , , , , , , , , , , ,