chore pkg and demo
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import { Magic } from "@/ui/icons";
|
||||
import { PauseCircle } from "lucide-react";
|
||||
|
||||
export default function AIGeneratingLoading({ stop }: { stop: () => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-start novel-bg-white shadow-lg rounded-full px-3 py-2 w-16 h-10">
|
||||
<Magic className="novel-w-7 novel-animate-pulse novel-text-purple-500" />
|
||||
<span className="text-sm novel-animate-pulse novel-ml-1 novel-text-slate-500">
|
||||
generating...
|
||||
</span>
|
||||
<PauseCircle
|
||||
onClick={stop}
|
||||
className="novel-h-5 hover:novel-text-stone-500 cursor-pointer novel-ml-6 novel-w-5 novel-text-stone-300"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import LoadingDots from "@/ui/icons/loading-dots";
|
||||
import Magic from "@/ui/icons/magic";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { useCompletion } from "ai/react";
|
||||
import { X, Clipboard, Replace } from "lucide-react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import va from "@vercel/analytics";
|
||||
import { NovelContext } from "../../../provider";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
const AIEditorBubble: React.FC<Props> = ({ editor }: Props) => {
|
||||
const [isShow, setIsShow] = useState(false);
|
||||
|
||||
const { completionApi, plan } = useContext(NovelContext);
|
||||
|
||||
const { completion, setCompletion, isLoading, stop } = useCompletion({
|
||||
id: "novel-edit",
|
||||
api: `${completionApi}/edit`,
|
||||
body: { plan },
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
if (err.message === "You have reached your request limit for the day.") {
|
||||
va.track("Rate Limit Reached");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (completion.length > 0) {
|
||||
setIsShow(true);
|
||||
}
|
||||
}, [completion]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(completion);
|
||||
};
|
||||
|
||||
const handleReplace = () => {
|
||||
if (completion.length > 0) {
|
||||
const { from, to } = editor.state.selection;
|
||||
editor.commands.insertContent(completion, {
|
||||
updateSelection: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return isShow || isLoading ? (
|
||||
<div className="novel-fixed z-[1000] novel-bottom-3 novel-right-3 novel-p-3 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-bottom-1">
|
||||
<div className="novel-w-64 novel-max-h-48 novel-overflow-y-auto">
|
||||
<div className=" novel-flex novel-gap-2 novel-items-center novel-text-slate-500">
|
||||
<Magic className="novel-h-5 novel-animate-pulse novel-w-5 novel-text-purple-500" />
|
||||
{isLoading && (
|
||||
<div className="novel-mr-auto novel-flex novel-items-center">
|
||||
<LoadingDots color="#9e9e9e" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="novel-flex novel-items-center novel-ml-auto gap-2">
|
||||
<button>
|
||||
<Replace
|
||||
onClick={handleReplace}
|
||||
className="novel-w-4 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
|
||||
/>
|
||||
</button>
|
||||
<button>
|
||||
<Clipboard
|
||||
onClick={handleCopy}
|
||||
className="novel-w-4 active:novel-text-green-500 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
|
||||
/>
|
||||
</button>
|
||||
|
||||
<X
|
||||
onClick={() => {
|
||||
setIsShow(false);
|
||||
setCompletion("");
|
||||
}}
|
||||
className="novel-w-4 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{completion.length > 0 && (
|
||||
<div className="novel-text-sm mt-2">{completion}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default AIEditorBubble;
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import {
|
||||
Beef,
|
||||
Book,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
Heading1,
|
||||
LayoutPanelTop,
|
||||
ListMinus,
|
||||
ListPlus,
|
||||
PartyPopper,
|
||||
PauseCircle,
|
||||
Pipette,
|
||||
Repeat,
|
||||
Scissors,
|
||||
Wand,
|
||||
} from "lucide-react";
|
||||
import { FC, useContext, useEffect } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import Magic from "@/ui/icons/magic";
|
||||
import { useCompletion } from "ai/react";
|
||||
import { NovelContext } from "../../../provider";
|
||||
|
||||
interface AISelectorProps {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const AISelector: FC<AISelectorProps> = ({
|
||||
editor,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}) => {
|
||||
const items = [
|
||||
{
|
||||
name: "Improve writing",
|
||||
detail: "Improve writing",
|
||||
icon: Wand,
|
||||
},
|
||||
{
|
||||
name: "Fix spelling & grammar",
|
||||
detail:
|
||||
"Please correct spelling and grammar errors in the following text",
|
||||
icon: CheckCheck,
|
||||
},
|
||||
{
|
||||
name: "Make shorter",
|
||||
detail: "Make shorter",
|
||||
icon: ListMinus,
|
||||
},
|
||||
{
|
||||
name: "Make longer",
|
||||
detail: "Make longer",
|
||||
icon: ListPlus,
|
||||
},
|
||||
{
|
||||
name: "Writing suggestions",
|
||||
detail: "Provide suggestions and improvements for the writing",
|
||||
icon: Beef,
|
||||
},
|
||||
{
|
||||
name: "Enhance vocabulary",
|
||||
detail: "Suggest synonyms and expand vocabulary usage",
|
||||
icon: Book,
|
||||
},
|
||||
{
|
||||
name: "Generate titles",
|
||||
detail: "Automatically generate compelling titles for the content",
|
||||
icon: Heading1,
|
||||
},
|
||||
{
|
||||
name: "Templates & structure",
|
||||
detail:
|
||||
"Offer templates and structure suggestions to improve the writing organization",
|
||||
icon: LayoutPanelTop,
|
||||
},
|
||||
{
|
||||
name: "Fix repetitive",
|
||||
detail: "Identify and fix repetitive words or phrases in the content",
|
||||
icon: Scissors,
|
||||
},
|
||||
{
|
||||
name: "Adjust writing style",
|
||||
detail:
|
||||
"Suggest adjustments to writing style and tone based on the target audience",
|
||||
icon: PartyPopper,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
} else {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const { completionApi, plan } = useContext(NovelContext);
|
||||
|
||||
const { complete, isLoading, stop } = useCompletion({
|
||||
id: "novel-edit",
|
||||
api: `${completionApi}/edit`,
|
||||
body: { plan },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="novel-relative novel-h-full">
|
||||
<div className="novel-flex novel-h-full novel-items-center novel-gap-1 novel-text-sm novel-font-medium novel-text-purple-500 hover:novel-bg-stone-100 active:novel-bg-stone-200">
|
||||
<button
|
||||
className="novel-p-2 novel-flex novel-h-full novel-items-center novel-gap-1"
|
||||
onClick={() => {
|
||||
if (isLoading) {
|
||||
stop();
|
||||
}
|
||||
setIsOpen(!isOpen);
|
||||
}}>
|
||||
<Magic className="novel-h-4 novel-w-4" />
|
||||
<span className="novel-whitespace-nowrap">Ask AI</span>
|
||||
{!isLoading ? (
|
||||
<ChevronDown className="novel-h-4 novel-w-4" />
|
||||
) : (
|
||||
<PauseCircle
|
||||
onClick={stop}
|
||||
className="novel-h-4 hover:novel-text-stone-500 cursor-pointer novel-w-4 novel-text-stone-300"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<Command className="novel-fixed novel-top-full novel-z-[99999] novel-mt-1 novel-w-60 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-p-2 novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-top-1">
|
||||
<Command.List>
|
||||
{items.map((item, index) => (
|
||||
<Command.Item
|
||||
key={index}
|
||||
onSelect={() => {
|
||||
if (!isLoading) {
|
||||
const { from, to } = editor.state.selection;
|
||||
const text = editor.state.doc.textBetween(from, to, " ");
|
||||
complete(`${item.detail}:\n ${text}`);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
className="novel-flex group novel-cursor-pointer novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-gray-600 active:novel-bg-stone-200 aria-selected:novel-bg-stone-100">
|
||||
<div className="novel-flex novel-items-center novel-space-x-2">
|
||||
<item.icon className="novel-h-4 novel-w-4 novel-text-purple-500" />
|
||||
<span>{item.name}</span>
|
||||
</div>
|
||||
{/* <CornerDownLeft className="novel-hidden novel-h-4 novel-w-4 group-hover:novel-block" /> */}
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.List>
|
||||
</Command>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import LoadingDots from "@/ui/icons/loading-dots";
|
||||
import Magic from "@/ui/icons/magic";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { useCompletion } from "ai/react";
|
||||
import { X, Clipboard, Replace } from "lucide-react";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import va from "@vercel/analytics";
|
||||
import { NovelContext } from "../../../provider";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
const AITranslateBubble: React.FC<Props> = ({ editor }: Props) => {
|
||||
const [isShow, setIsShow] = useState(false);
|
||||
|
||||
const { completionApi, plan } = useContext(NovelContext);
|
||||
|
||||
const { completion, setCompletion, isLoading, stop } = useCompletion({
|
||||
id: "novel-translate",
|
||||
api: `${completionApi}/translate`,
|
||||
body: { plan },
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
if (err.message === "You have reached your request limit for the day.") {
|
||||
va.track("Rate Limit Reached");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (completion.length > 0) {
|
||||
setIsShow(true);
|
||||
}
|
||||
}, [completion]);
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(completion);
|
||||
};
|
||||
|
||||
const handleReplace = () => {
|
||||
if (completion.length > 0) {
|
||||
const { from, to } = editor.state.selection;
|
||||
editor.commands.insertContent(completion, {
|
||||
updateSelection: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return isShow || isLoading ? (
|
||||
<div className="novel-fixed z-[1000] novel-bottom-3 novel-right-3 novel-p-3 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-bottom-1">
|
||||
<div className="novel-w-64 novel-max-h-48 novel-overflow-y-auto">
|
||||
<div className=" novel-flex novel-gap-2 novel-items-center novel-text-slate-500">
|
||||
<Magic className="novel-h-5 novel-animate-pulse novel-w-5 novel-text-purple-500" />
|
||||
{isLoading && (
|
||||
<div className="novel-mr-auto novel-flex novel-items-center">
|
||||
<LoadingDots color="#9e9e9e" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="novel-flex novel-items-center novel-ml-auto gap-2">
|
||||
<button>
|
||||
<Replace
|
||||
onClick={handleReplace}
|
||||
className="novel-w-4 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
|
||||
/>
|
||||
</button>
|
||||
<button>
|
||||
<Clipboard
|
||||
onClick={handleCopy}
|
||||
className="novel-w-4 active:novel-text-green-500 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
|
||||
/>
|
||||
</button>
|
||||
|
||||
<X
|
||||
onClick={() => {
|
||||
setIsShow(false);
|
||||
setCompletion("");
|
||||
}}
|
||||
className="novel-w-4 novel-h-4 novel-cursor-pointer hover:novel-text-slate-300 "
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{completion.length > 0 && (
|
||||
<div className="novel-text-sm mt-2">{completion}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default AITranslateBubble;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { Globe2, Languages, PauseCircle } from "lucide-react";
|
||||
import { FC, useContext, useEffect } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { useCompletion } from "ai/react";
|
||||
import { NovelContext } from "../../../provider";
|
||||
|
||||
interface TranslateSelectorProps {
|
||||
editor: Editor;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const TranslateSelector: FC<TranslateSelectorProps> = ({
|
||||
editor,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}) => {
|
||||
const items = [
|
||||
{
|
||||
name: "English",
|
||||
detail: "Translate into English",
|
||||
},
|
||||
{
|
||||
name: "Chinese",
|
||||
detail: "Translate into Chinese",
|
||||
},
|
||||
{
|
||||
name: "Spanish",
|
||||
detail: "Translate into Spanish",
|
||||
},
|
||||
{
|
||||
name: "French",
|
||||
detail: "Translate into French",
|
||||
},
|
||||
{
|
||||
name: "German",
|
||||
detail: "Translate into German",
|
||||
},
|
||||
{
|
||||
name: "Japanese",
|
||||
detail: "Translate into Japanese",
|
||||
},
|
||||
{
|
||||
name: "Russian",
|
||||
detail: "Translate into Russian",
|
||||
},
|
||||
{
|
||||
name: "Korean",
|
||||
detail: "Translate into Korean",
|
||||
},
|
||||
{
|
||||
name: "Arabic",
|
||||
detail: "Translate into Arabic",
|
||||
},
|
||||
{
|
||||
name: "Portuguese",
|
||||
detail: "Translate into Portuguese",
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (["ArrowUp", "ArrowDown", "Enter"].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
} else {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const { completionApi, plan } = useContext(NovelContext);
|
||||
|
||||
const { complete, isLoading, stop } = useCompletion({
|
||||
id: "novel-translate",
|
||||
api: `${completionApi}/translate`,
|
||||
body: { plan },
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="novel-relative novel-h-full">
|
||||
<div className="novel-flex novel-h-full novel-items-center novel-text-sm novel-font-medium hover:novel-bg-stone-100 active:novel-bg-stone-200">
|
||||
{isLoading ? (
|
||||
<button className="p-2">
|
||||
<PauseCircle
|
||||
onClick={stop}
|
||||
className="novel-h-5 hover:novel-text-stone-500 cursor-pointer novel-w-4 novel-text-stone-300"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<button className="p-2">
|
||||
<Languages
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="novel-h-5 novel-text-stone-600 novel-w-4"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<Command className="novel-fixed novel-top-full novel-z-[99999] novel-mt-1 novel-w-28 novel-overflow-hidden novel-rounded novel-border novel-border-stone-200 novel-bg-white novel-p-2 novel-shadow-xl novel-animate-in novel-fade-in novel-slide-in-from-top-1">
|
||||
<Command.List>
|
||||
{items.map((item, index) => (
|
||||
<Command.Item
|
||||
key={index}
|
||||
onSelect={() => {
|
||||
if (!isLoading) {
|
||||
const { from, to } = editor.state.selection;
|
||||
const text = editor.state.doc.textBetween(from, to, " ");
|
||||
complete(`${item.detail}:\n ${text}`);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
className="novel-flex novel-cursor-pointer novel-items-center novel-justify-between novel-rounded-sm novel-px-2 novel-py-1 novel-text-sm novel-text-gray-600 active:novel-bg-stone-200 aria-selected:novel-bg-stone-100">
|
||||
<span>{item.name}</span>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.List>
|
||||
</Command>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user