enzostvs's picture
enzostvs HF Staff
loading state
33e85d4
raw
history blame
17.2 kB
"use client";
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState, useMemo, useRef } from "react";
import classNames from "classnames";
import { toast } from "sonner";
import { useLocalStorage, useUpdateEffect } from "react-use";
import { ArrowUp, ChevronDown, Crosshair } from "lucide-react";
import { FaStopCircle } from "react-icons/fa";
import ProModal from "@/components/pro-modal";
import { Button } from "@/components/ui/button";
import { MODELS } from "@/lib/providers";
import { HtmlHistory, Page, Project } from "@/types";
// import { InviteFriends } from "@/components/invite-friends";
import { Settings } from "@/components/editor/ask-ai/settings";
import { LoginModal } from "@/components/login-modal";
import { ReImagine } from "@/components/editor/ask-ai/re-imagine";
import Loading from "@/components/loading";
import { Checkbox } from "@/components/ui/checkbox";
import { Tooltip, TooltipTrigger } from "@/components/ui/tooltip";
import { TooltipContent } from "@radix-ui/react-tooltip";
import { SelectedHtmlElement } from "./selected-html-element";
import { FollowUpTooltip } from "./follow-up-tooltip";
import { isTheSameHtml } from "@/lib/compare-html-diff";
import { useCallAi } from "@/hooks/useCallAi";
import { SelectedFiles } from "./selected-files";
import { Uploader } from "./uploader";
export function AskAI({
isNew,
project,
images,
currentPage,
previousPrompts,
onScrollToBottom,
isAiWorking,
setisAiWorking,
isEditableModeEnabled = false,
pages,
htmlHistory,
selectedElement,
setSelectedElement,
selectedFiles,
setSelectedFiles,
setIsEditableModeEnabled,
onNewPrompt,
onSuccess,
setPages,
setCurrentPage,
}: {
project?: Project | null;
currentPage: Page;
images?: string[];
pages: Page[];
onScrollToBottom: () => void;
previousPrompts: string[];
isAiWorking: boolean;
onNewPrompt: (prompt: string) => void;
htmlHistory?: HtmlHistory[];
setisAiWorking: React.Dispatch<React.SetStateAction<boolean>>;
isNew?: boolean;
onSuccess: (page: Page[], p: string, n?: number[][]) => void;
isEditableModeEnabled: boolean;
setIsEditableModeEnabled: React.Dispatch<React.SetStateAction<boolean>>;
selectedElement?: HTMLElement | null;
setSelectedElement: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
selectedFiles: string[];
setSelectedFiles: React.Dispatch<React.SetStateAction<string[]>>;
setPages: React.Dispatch<React.SetStateAction<Page[]>>;
setCurrentPage: React.Dispatch<React.SetStateAction<string>>;
}) {
const refThink = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
const [prompt, setPrompt] = useState("");
const [provider, setProvider] = useLocalStorage("provider", "auto");
const [model, setModel] = useLocalStorage("model", MODELS[0].value);
const [openProvider, setOpenProvider] = useState(false);
const [providerError, setProviderError] = useState("");
const [openProModal, setOpenProModal] = useState(false);
const [openThink, setOpenThink] = useState(false);
const [isThinking, setIsThinking] = useState(true);
const [think, setThink] = useState("");
const [isFollowUp, setIsFollowUp] = useState(true);
const [isUploading, setIsUploading] = useState(false);
const [files, setFiles] = useState<string[]>(images ?? []);
const {
callAiNewProject,
callAiFollowUp,
callAiNewPage,
stopController,
audio: hookAudio,
} = useCallAi({
onNewPrompt,
onSuccess,
onScrollToBottom,
setPages,
setCurrentPage,
currentPage,
pages,
isAiWorking,
setisAiWorking,
});
const selectedModel = useMemo(() => {
return MODELS.find((m: { value: string }) => m.value === model);
}, [model]);
const callAi = async (redesignMarkdown?: string) => {
if (isAiWorking) return;
if (!redesignMarkdown && !prompt.trim()) return;
if (isFollowUp && !redesignMarkdown && !isSameHtml) {
// Use follow-up function for existing projects
const selectedElementHtml = selectedElement
? selectedElement.outerHTML
: "";
const result = await callAiFollowUp(
prompt,
model,
provider,
previousPrompts,
selectedElementHtml,
selectedFiles
);
if (result?.error) {
handleError(result.error, result.message);
return;
}
if (result?.success) {
setPrompt("");
}
} else if (isFollowUp && pages.length > 1 && isSameHtml) {
const result = await callAiNewPage(
prompt,
model,
provider,
currentPage.path,
[
...(previousPrompts ?? []),
...(htmlHistory?.map((h) => h.prompt) ?? []),
]
);
if (result?.error) {
handleError(result.error, result.message);
return;
}
if (result?.success) {
setPrompt("");
}
} else {
const result = await callAiNewProject(
prompt,
model,
provider,
redesignMarkdown,
handleThink,
() => {
setIsThinking(false);
}
);
if (result?.error) {
handleError(result.error, result.message);
return;
}
if (result?.success) {
setPrompt("");
if (selectedModel?.isThinker) {
setModel(MODELS[0].value);
}
}
}
};
const handleThink = (think: string) => {
setThink(think);
setIsThinking(true);
setOpenThink(true);
};
const handleError = (error: string, message?: string) => {
switch (error) {
case "login_required":
setOpen(true);
break;
case "provider_required":
setOpenProvider(true);
setProviderError(message || "");
break;
case "pro_required":
setOpenProModal(true);
break;
case "api_error":
toast.error(message || "An error occurred");
break;
case "network_error":
toast.error(message || "Network error occurred");
break;
default:
toast.error("An unexpected error occurred");
}
};
useUpdateEffect(() => {
if (refThink.current) {
refThink.current.scrollTop = refThink.current.scrollHeight;
}
}, [think]);
useUpdateEffect(() => {
if (!isThinking) {
setOpenThink(false);
}
}, [isThinking]);
const isSameHtml = useMemo(() => {
return isTheSameHtml(currentPage.html);
}, [currentPage.html]);
return (
<div className="px-3">
<div className="relative bg-neutral-800 border border-neutral-700 rounded-2xl ring-[4px] focus-within:ring-neutral-500/30 focus-within:border-neutral-600 ring-transparent z-10 w-full group">
{think && (
<div className="w-full border-b border-neutral-700 relative overflow-hidden">
<header
className="flex items-center justify-between px-5 py-2.5 group hover:bg-neutral-600/20 transition-colors duration-200 cursor-pointer"
onClick={() => {
setOpenThink(!openThink);
}}
>
<p className="text-sm font-medium text-neutral-300 group-hover:text-neutral-200 transition-colors duration-200">
{isThinking ? "DeepSite is thinking..." : "DeepSite's plan"}
</p>
<ChevronDown
className={classNames(
"size-4 text-neutral-400 group-hover:text-neutral-300 transition-all duration-200",
{
"rotate-180": openThink,
}
)}
/>
</header>
<main
ref={refThink}
className={classNames(
"overflow-y-auto transition-all duration-200 ease-in-out",
{
"max-h-[0px]": !openThink,
"min-h-[250px] max-h-[250px] border-t border-neutral-700":
openThink,
}
)}
>
<p className="text-[13px] text-neutral-400 whitespace-pre-line px-5 pb-4 pt-3">
{think}
</p>
</main>
</div>
)}
<SelectedFiles
files={selectedFiles}
isAiWorking={isAiWorking}
onDelete={(file) =>
setSelectedFiles((prev) => prev.filter((f) => f !== file))
}
/>
{selectedElement && (
<div className="px-4 pt-3">
<SelectedHtmlElement
element={selectedElement}
isAiWorking={isAiWorking}
onDelete={() => setSelectedElement(null)}
/>
</div>
)}
<div className="w-full relative flex items-center justify-between">
{(isAiWorking || isUploading) && (
<div className="absolute bg-neutral-800 top-0 left-4 w-[calc(100%-30px)] h-full z-1 flex items-start pt-3.5 justify-between max-lg:text-sm">
<div className="flex items-center justify-start gap-2">
<Loading overlay={false} className="!size-4 opacity-50" />
<p className="text-neutral-400 text-sm">
{isUploading ? (
"Uploading images..."
) : isAiWorking && !isSameHtml ? (
"AI is working..."
) : (
<span className="inline-flex">
{[
"D",
"e",
"e",
"p",
"S",
"i",
"t",
"e",
" ",
"i",
"s",
" ",
"T",
"h",
"i",
"n",
"k",
"i",
"n",
"g",
".",
".",
".",
" ",
"W",
"a",
"i",
"t",
" ",
"a",
" ",
"m",
"o",
"m",
"e",
"n",
"t",
".",
".",
".",
].map((char, index) => (
<span
key={index}
className="bg-gradient-to-r from-neutral-100 to-neutral-300 bg-clip-text text-transparent animate-pulse"
style={{
animationDelay: `${index * 0.1}s`,
animationDuration: "1.3s",
animationIterationCount: "infinite",
}}
>
{char === " " ? "\u00A0" : char}
</span>
))}
</span>
)}
</p>
</div>
{isAiWorking && (
<div
className="text-xs text-neutral-400 px-1 py-0.5 rounded-md border border-neutral-600 flex items-center justify-center gap-1.5 bg-neutral-800 hover:brightness-110 transition-all duration-200 cursor-pointer"
onClick={stopController}
>
<FaStopCircle />
Stop generation
</div>
)}
</div>
)}
<textarea
disabled={isAiWorking}
className={classNames(
"w-full bg-transparent text-sm outline-none text-white placeholder:text-neutral-400 p-4 resize-none",
{
"!pt-2.5": selectedElement && !isAiWorking,
}
)}
placeholder={
selectedElement
? `Ask DeepSite about ${selectedElement.tagName.toLowerCase()}...`
: isFollowUp && (!isSameHtml || pages?.length > 1)
? "Ask DeepSite for edits"
: "Ask DeepSite anything..."
}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
callAi();
}
}}
/>
</div>
<div className="flex items-center justify-between gap-2 px-4 pb-3 mt-2">
<div className="flex-1 flex items-center justify-start gap-1.5">
<Uploader
pages={pages}
onLoading={setIsUploading}
isLoading={isUploading}
onFiles={setFiles}
onSelectFile={(file) => {
if (selectedFiles.includes(file)) {
setSelectedFiles((prev) => prev.filter((f) => f !== file));
} else {
setSelectedFiles((prev) => [...prev, file]);
}
}}
files={files}
selectedFiles={selectedFiles}
project={project}
/>
{isNew && <ReImagine onRedesign={(md) => callAi(md)} />}
{!isSameHtml && (
<Tooltip>
<TooltipTrigger asChild>
<Button
size="xs"
variant={isEditableModeEnabled ? "default" : "outline"}
onClick={() => {
setIsEditableModeEnabled?.(!isEditableModeEnabled);
}}
className={classNames("h-[28px]", {
"!text-neutral-400 hover:!text-neutral-200 !border-neutral-600 !hover:!border-neutral-500":
!isEditableModeEnabled,
})}
>
<Crosshair className="size-4" />
Edit
</Button>
</TooltipTrigger>
<TooltipContent
align="start"
className="bg-neutral-950 text-xs text-neutral-200 py-1 px-2 rounded-md -translate-y-0.5"
>
Select an element on the page to ask DeepSite edit it
directly.
</TooltipContent>
</Tooltip>
)}
{/* <InviteFriends /> */}
</div>
<div className="flex items-center justify-end gap-2">
<Settings
provider={provider as string}
model={model as string}
onChange={setProvider}
onModelChange={setModel}
open={openProvider}
error={providerError}
isFollowUp={!isSameHtml && isFollowUp}
onClose={setOpenProvider}
/>
<Button
size="iconXs"
disabled={isAiWorking || !prompt.trim()}
onClick={() => callAi()}
>
<ArrowUp className="size-4" />
</Button>
</div>
</div>
<LoginModal open={open} onClose={() => setOpen(false)} pages={pages} />
<ProModal
pages={pages}
open={openProModal}
onClose={() => setOpenProModal(false)}
/>
{pages.length === 1 && (
<div className="border border-sky-500/20 bg-sky-500/40 hover:bg-sky-600 transition-all duration-200 text-sky-500 pl-2 pr-4 py-1.5 text-xs rounded-full absolute top-0 -translate-y-[calc(100%+8px)] left-0 max-w-max flex items-center justify-start gap-2">
<span className="rounded-full text-[10px] font-semibold bg-white text-neutral-900 px-1.5 py-0.5">
NEW
</span>
<p className="text-sm text-neutral-100">
DeepSite can now create multiple pages at once. Try it!
</p>
</div>
)}
{!isSameHtml && (
<div className="absolute top-0 right-0 -translate-y-[calc(100%+8px)] select-none text-xs text-neutral-400 flex items-center justify-center gap-2 bg-neutral-800 border border-neutral-700 rounded-md p-1 pr-2.5">
<label
htmlFor="diff-patch-checkbox"
className="flex items-center gap-1.5 cursor-pointer"
>
<Checkbox
id="diff-patch-checkbox"
checked={isFollowUp}
onCheckedChange={(e) => {
if (e === true && !isSameHtml && selectedModel?.isThinker) {
setModel(MODELS[0].value);
}
setIsFollowUp(e === true);
}}
/>
Diff-Patch Update
</label>
<FollowUpTooltip />
</div>
)}
</div>
<audio ref={hookAudio} id="audio" className="hidden">
<source src="/success.mp3" type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</div>
);
}