mirror of https://github.com/buster-so/buster.git
Add more editor elements
This commit is contained in:
parent
9c08da8fad
commit
9ad0130514
|
@ -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<typeof editorVariants>) {
|
||||
return <PlateStatic className={cn(editorVariants({ variant }), className)} {...props} />;
|
||||
}
|
|
@ -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
|
||||
];
|
|
@ -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 <EditorStatic variant="aiChat" editor={aiEditor} />;
|
||||
});
|
|
@ -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<HTMLElement | null>(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 (
|
||||
<PopoverBase open={open} onOpenChange={setOpen} modal={false}>
|
||||
<PopoverAnchor virtualRef={{ current: anchorElement! }} />
|
||||
|
||||
<PopoverContent
|
||||
className="border-none bg-transparent p-0 shadow-none"
|
||||
style={{
|
||||
width: anchorElement?.offsetWidth
|
||||
}}
|
||||
onEscapeKeyDown={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
api.aiChat.hide();
|
||||
}}
|
||||
align="center"
|
||||
side="bottom">
|
||||
<Command
|
||||
className="w-full rounded-lg border shadow-md"
|
||||
value={value}
|
||||
onValueChange={setValue}>
|
||||
{mode === 'chat' && isSelecting && content && <AIChatEditor content={content} />}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex grow items-center gap-2 p-2 text-sm select-none">
|
||||
<div className="size-4 animate-spin">
|
||||
<Loader />
|
||||
</div>
|
||||
{messages.length > 1 ? 'Editing...' : 'Thinking...'}
|
||||
</div>
|
||||
) : (
|
||||
<CommandPrimitive.Input
|
||||
className={cn(
|
||||
'border-input placeholder:text-muted-foreground dark:bg-input/30 flex h-9 w-full min-w-0 bg-transparent px-3 py-1 text-base transition-[color,box-shadow] outline-none md:text-sm',
|
||||
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
|
||||
'border-b focus-visible:ring-transparent'
|
||||
)}
|
||||
value={input}
|
||||
onKeyDown={(e) => {
|
||||
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 && (
|
||||
<CommandList>
|
||||
<AIMenuItems setValue={setValue} />
|
||||
</CommandList>
|
||||
)}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</PopoverBase>
|
||||
);
|
||||
}
|
||||
|
||||
type EditorChatState =
|
||||
| 'cursorCommand'
|
||||
| 'cursorSuggestion'
|
||||
| 'selectionCommand'
|
||||
| 'selectionSuggestion';
|
||||
|
||||
const aiChatItems = {
|
||||
accept: {
|
||||
icon: <Check2 />,
|
||||
label: 'Accept',
|
||||
value: 'accept',
|
||||
onSelect: ({ editor }) => {
|
||||
editor.getTransforms(AIChatPlugin).aiChat.accept();
|
||||
editor.tf.focus({ edge: 'end' });
|
||||
}
|
||||
},
|
||||
continueWrite: {
|
||||
icon: <Pen2 />,
|
||||
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
|
||||
? `<Document>
|
||||
{editor}
|
||||
</Document>
|
||||
Start writing a new paragraph AFTER <Document> ONLY ONE SENTENCE`
|
||||
: 'Continue writing AFTER <Block> ONLY ONE SENTENCE. DONT REPEAT THE TEXT.'
|
||||
});
|
||||
}
|
||||
},
|
||||
discard: {
|
||||
icon: <Xmark />,
|
||||
label: 'Discard',
|
||||
shortcut: 'Escape',
|
||||
value: 'discard',
|
||||
onSelect: ({ editor }) => {
|
||||
editor.getTransforms(AIPlugin).ai.undo();
|
||||
editor.getApi(AIChatPlugin).aiChat.hide();
|
||||
}
|
||||
},
|
||||
emojify: {
|
||||
icon: <FaceGrin2 />,
|
||||
label: 'Emojify',
|
||||
value: 'emojify',
|
||||
onSelect: ({ editor }) => {
|
||||
void editor.getApi(AIChatPlugin).aiChat.submit({
|
||||
prompt: 'Emojify'
|
||||
});
|
||||
}
|
||||
},
|
||||
explain: {
|
||||
icon: <CircleQuestion />,
|
||||
label: 'Explain',
|
||||
value: 'explain',
|
||||
onSelect: ({ editor }) => {
|
||||
void editor.getApi(AIChatPlugin).aiChat.submit({
|
||||
prompt: {
|
||||
default: 'Explain {editor}',
|
||||
selecting: 'Explain'
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
fixSpelling: {
|
||||
icon: <Check2 />,
|
||||
label: 'Fix spelling & grammar',
|
||||
value: 'fixSpelling',
|
||||
onSelect: ({ editor }) => {
|
||||
void editor.getApi(AIChatPlugin).aiChat.submit({
|
||||
prompt: 'Fix spelling and grammar'
|
||||
});
|
||||
}
|
||||
},
|
||||
generateMarkdownSample: {
|
||||
icon: <BookOpen />,
|
||||
label: 'Generate Markdown sample',
|
||||
value: 'generateMarkdownSample',
|
||||
onSelect: ({ editor }) => {
|
||||
void editor.getApi(AIChatPlugin).aiChat.submit({
|
||||
prompt: 'Generate a markdown sample'
|
||||
});
|
||||
}
|
||||
},
|
||||
generateMdxSample: {
|
||||
icon: <BookOpen />,
|
||||
label: 'Generate MDX sample',
|
||||
value: 'generateMdxSample',
|
||||
onSelect: ({ editor }) => {
|
||||
void editor.getApi(AIChatPlugin).aiChat.submit({
|
||||
prompt: 'Generate a mdx sample'
|
||||
});
|
||||
}
|
||||
},
|
||||
improveWriting: {
|
||||
icon: <WandSparkle />,
|
||||
label: 'Improve writing',
|
||||
value: 'improveWriting',
|
||||
onSelect: ({ editor }) => {
|
||||
void editor.getApi(AIChatPlugin).aiChat.submit({
|
||||
prompt: 'Improve the writing'
|
||||
});
|
||||
}
|
||||
},
|
||||
insertBelow: {
|
||||
icon: <AddBelow />,
|
||||
label: 'Insert below',
|
||||
value: 'insertBelow',
|
||||
onSelect: ({ aiEditor, editor }) => {
|
||||
void editor.getTransforms(AIChatPlugin).aiChat.insertBelow(aiEditor);
|
||||
}
|
||||
},
|
||||
makeLonger: {
|
||||
icon: <Plus />,
|
||||
label: 'Make longer',
|
||||
value: 'makeLonger',
|
||||
onSelect: ({ editor }) => {
|
||||
void editor.getApi(AIChatPlugin).aiChat.submit({
|
||||
prompt: 'Make longer'
|
||||
});
|
||||
}
|
||||
},
|
||||
makeShorter: {
|
||||
icon: <Minus />,
|
||||
label: 'Make shorter',
|
||||
value: 'makeShorter',
|
||||
onSelect: ({ editor }) => {
|
||||
void editor.getApi(AIChatPlugin).aiChat.submit({
|
||||
prompt: 'Make shorter'
|
||||
});
|
||||
}
|
||||
},
|
||||
replace: {
|
||||
icon: <Check2 />,
|
||||
label: 'Replace selection',
|
||||
value: 'replace',
|
||||
onSelect: ({ aiEditor, editor }) => {
|
||||
void editor.getTransforms(AIChatPlugin).aiChat.replaceSelection(aiEditor);
|
||||
}
|
||||
},
|
||||
simplifyLanguage: {
|
||||
icon: <Feather />,
|
||||
label: 'Simplify language',
|
||||
value: 'simplifyLanguage',
|
||||
onSelect: ({ editor }) => {
|
||||
void editor.getApi(AIChatPlugin).aiChat.submit({
|
||||
prompt: 'Simplify the language'
|
||||
});
|
||||
}
|
||||
},
|
||||
summarize: {
|
||||
icon: <Album />,
|
||||
label: 'Add a summary',
|
||||
value: 'summarize',
|
||||
onSelect: ({ editor }) => {
|
||||
void editor.getApi(AIChatPlugin).aiChat.submit({
|
||||
mode: 'insert',
|
||||
prompt: {
|
||||
default: 'Summarize {editor}',
|
||||
selecting: 'Summarize'
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
tryAgain: {
|
||||
icon: <ArrowUpLeft />,
|
||||
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) => (
|
||||
<CommandGroup key={index} heading={group.heading}>
|
||||
{group.items.map((menuItem) => (
|
||||
<CommandItem
|
||||
key={menuItem.value}
|
||||
className="[&_svg]:text-muted-foreground"
|
||||
value={menuItem.value}
|
||||
onSelect={() => {
|
||||
menuItem.onSelect?.({
|
||||
aiEditor,
|
||||
editor: editor
|
||||
});
|
||||
}}>
|
||||
{menuItem.icon}
|
||||
<span>{menuItem.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={cn(
|
||||
'border-border bg-muted text-muted-foreground absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 items-center gap-3 rounded-md border px-3 py-1.5 text-sm shadow-md transition-all duration-300'
|
||||
)}>
|
||||
<span className="border-muted-foreground h-4 w-4 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<span>{status === 'submitted' ? 'Thinking...' : 'Writing...'}</span>
|
||||
<Button
|
||||
size="small"
|
||||
variant="ghost"
|
||||
className="flex items-center gap-1 text-xs"
|
||||
onClick={() => api.aiChat.stop()}
|
||||
prefix={<MediaPause />}>
|
||||
Stop
|
||||
<kbd className="bg-border text-muted-foreground ml-1 rounded px-1 font-mono text-[10px] shadow-sm">
|
||||
Esc
|
||||
</kbd>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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 (
|
||||
<PlateText
|
||||
className={cn(
|
||||
'border-b-2 border-b-purple-100 bg-purple-50 text-purple-800',
|
||||
'transition-all duration-200 ease-in-out',
|
||||
isLast &&
|
||||
streaming &&
|
||||
'after:bg-primary after:ml-1.5 after:inline-block after:h-3 after:w-3 after:rounded-full after:align-middle after:content-[""]'
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIAnchorElement(props: PlateElementProps) {
|
||||
return (
|
||||
<PlateElement {...props}>
|
||||
<div className="h-[0.1px]" />
|
||||
</PlateElement>
|
||||
);
|
||||
}
|
|
@ -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:
|
||||
- <Document> is the entire note the user is working on.
|
||||
- <Reminder> 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 <Reminder> 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: <u>, <callout>, <kbd>, <toc>, <sub>, <sup>, <mark>, <del>, <date>, <span>, <column>, <column_group>, <file>, <audio>, <video> in <Selection> unless the user explicitly requests this change.
|
||||
- CRITICAL: Distinguish between INSTRUCTIONS and QUESTIONS. Instructions typically ask you to modify or add content. Questions ask for information or clarification.
|
||||
- CRITICAL: when asked to write in markdown, do not start with \`\`\`markdown.
|
||||
`;
|
||||
|
||||
const systemDefault = `\
|
||||
${systemCommon}
|
||||
- <Block> is the current block of text the user is working on.
|
||||
- Ensure your output can seamlessly fit into the existing <Block> structure.
|
||||
|
||||
<Block>
|
||||
{block}
|
||||
</Block>
|
||||
`;
|
||||
|
||||
const systemSelecting = `\
|
||||
${systemCommon}
|
||||
- <Block> is the block of text containing the user's selection, providing context.
|
||||
- Ensure your output can seamlessly fit into the existing <Block> structure.
|
||||
- <Selection> is the specific text the user has selected in the block and wants to modify or ask about.
|
||||
- Consider the context provided by <Block>, but only modify <Selection>. Your response should be a direct replacement for <Selection>.
|
||||
<Block>
|
||||
{block}
|
||||
</Block>
|
||||
<Selection>
|
||||
{selection}
|
||||
</Selection>
|
||||
`;
|
||||
|
||||
const systemBlockSelecting = `\
|
||||
${systemCommon}
|
||||
- <Selection> represents the full blocks of text the user has selected and wants to modify or ask about.
|
||||
- Your response should be a direct replacement for the entire <Selection>.
|
||||
- Maintain the overall structure and formatting of the selected blocks, unless explicitly instructed otherwise.
|
||||
- CRITICAL: Provide only the content to replace <Selection>. Do not add additional blocks or change the block structure unless specifically requested.
|
||||
<Selection>
|
||||
{block}
|
||||
</Selection>
|
||||
`;
|
||||
|
||||
const userDefault = `<Reminder>
|
||||
CRITICAL: NEVER write <Block>.
|
||||
</Reminder>
|
||||
{prompt}`;
|
||||
const userSelecting = `<Reminder>
|
||||
If this is a question, provide a helpful and concise answer about <Selection>.
|
||||
If this is an instruction, provide ONLY the text to replace <Selection>. No explanations.
|
||||
Ensure it fits seamlessly within <Block>. If <Block> is empty, write ONE random sentence.
|
||||
NEVER write <Block> or <Selection>.
|
||||
</Reminder>
|
||||
{prompt} about <Selection>`;
|
||||
|
||||
const userBlockSelecting = `<Reminder>
|
||||
If this is a question, provide a helpful and concise answer about <Selection>.
|
||||
If this is an instruction, provide ONLY the content to replace the entire <Selection>. No explanations.
|
||||
Maintain the overall structure unless instructed otherwise.
|
||||
NEVER write <Block> or <Selection>.
|
||||
</Reminder>
|
||||
{prompt} about <Selection>`;
|
||||
|
||||
export const PROMPT_TEMPLATES = {
|
||||
systemBlockSelecting,
|
||||
systemDefault,
|
||||
systemSelecting,
|
||||
userBlockSelecting,
|
||||
userDefault,
|
||||
userSelecting
|
||||
};
|
|
@ -2,12 +2,12 @@ import { AnyPluginConfig, TrailingBlockPlugin, type Value } from 'platejs';
|
|||
import {
|
||||
useEditorRef,
|
||||
usePlateEditor,
|
||||
type PlateEditor,
|
||||
type TPlateEditor,
|
||||
type WithPlateOptions
|
||||
} from 'platejs/react';
|
||||
|
||||
// Plugin imports sorted alphabetically for clarity and maintainability
|
||||
import { AIKit } from './plugins/ai-kit';
|
||||
import { AlignKit } from './plugins/align-kit';
|
||||
import { AutoformatKit } from './plugins/autoformat-kit';
|
||||
import { BasicBlocksKit } from './plugins/basic-blocks-kit';
|
||||
|
@ -18,28 +18,30 @@ import { BlockSelectionKit } from './plugins/block-selection-kit';
|
|||
import { CalloutKit } from './plugins/callout-kit';
|
||||
import { CodeBlockKit } from './plugins/code-block-kit';
|
||||
import { ColumnKit } from './plugins/column-kit';
|
||||
import { MyTestPlugin } from './plugins/test-plugin';
|
||||
import { EmojiKit } from './plugins/emoji-kit';
|
||||
import { TableKit } from './plugins/table-kit';
|
||||
import { ToggleKit } from './plugins/toggle-kit';
|
||||
import { TocKit } from './plugins/toc-kit';
|
||||
import { MediaKit } from './plugins/media-kit';
|
||||
import { MathKit } from './plugins/math-kit';
|
||||
import { CommentKit } from './plugins/comment-kit';
|
||||
import { CursorOverlayKit } from './plugins/cursor-overlay-kit';
|
||||
import { DateKit } from './plugins/date-kit';
|
||||
import { LinkKit } from './plugins/link-kit';
|
||||
import { DndKit } from './plugins/dnd-kit-new';
|
||||
import { EmojiKit } from './plugins/emoji-kit';
|
||||
import { ExitBreakKit } from './plugins/exit-break-kit';
|
||||
import { FloatingToolbarKit } from './plugins/floating-toolbar-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 { MyTestPlugin } from './plugins/test-plugin';
|
||||
import { SlashKit } from './plugins/slash-kit';
|
||||
import { SuggestionKit } from './plugins/suggestion-kit';
|
||||
import { CursorOverlayKit } from './plugins/cursor-overlay-kit';
|
||||
import { DndKit } from './plugins/dnd-kit';
|
||||
import { FloatingToolbarKit } from './plugins/floating-toolbar-kit';
|
||||
import { ExitBreakKit } from './plugins/exit-break-kit';
|
||||
import { MarkdownKit } from './plugins/markdown-kit';
|
||||
import { ListKit } from './plugins/list-kit';
|
||||
import { LineHeightKit } from './plugins/line-height-kit';
|
||||
import { TableKit } from './plugins/table-kit';
|
||||
import { TocKit } from './plugins/toc-kit';
|
||||
import { ToggleKit } from './plugins/toggle-kit';
|
||||
|
||||
export const editorPlugins: AnyPluginConfig[] = [
|
||||
// Core functionality (must be first)
|
||||
...AIKit,
|
||||
...BlockSelectionKit, // Required for drag and drop
|
||||
...DndKit, // Drag and drop functionality
|
||||
|
||||
|
|
Loading…
Reference in New Issue