enzostvs's picture
enzostvs HF Staff
switch to action
09515ea
"use client";
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useState, useRef, useMemo } 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 } 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";
export function AskAI({
html,
setHtml,
onScrollToBottom,
isAiWorking,
setisAiWorking,
isEditableModeEnabled = false,
selectedElement,
setSelectedElement,
setIsEditableModeEnabled,
onNewPrompt,
onSuccess,
}: {
html: string;
setHtml: (html: string) => void;
onScrollToBottom: () => void;
isAiWorking: boolean;
onNewPrompt: (prompt: string) => void;
htmlHistory?: HtmlHistory[];
setisAiWorking: React.Dispatch<React.SetStateAction<boolean>>;
onSuccess: (h: string, p: string, n?: number[][]) => void;
isEditableModeEnabled: boolean;
setIsEditableModeEnabled: React.Dispatch<React.SetStateAction<boolean>>;
selectedElement?: HTMLElement | null;
setSelectedElement: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
}) {
const refThink = useRef<HTMLDivElement | null>(null);
const audio = useRef<HTMLAudioElement | null>(null);
const [open, setOpen] = useState(false);
const [prompt, setPrompt] = useState("");
const [hasAsked, setHasAsked] = useState(false);
const [previousPrompt, setPreviousPrompt] = 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 [think, setThink] = useState<string | undefined>(undefined);
const [openThink, setOpenThink] = useState(false);
const [isThinking, setIsThinking] = useState(true);
const [controller, setController] = useState<AbortController | null>(null);
const [isFollowUp, setIsFollowUp] = useState(true);
const callAi = async (redesignMarkdown?: string) => {
if (isAiWorking) return;
if (!redesignMarkdown && !prompt.trim()) return;
setisAiWorking(true);
setProviderError("");
setThink("");
setOpenThink(false);
setIsThinking(true);
let contentResponse = "";
let thinkResponse = "";
let lastRenderTime = 0;
const abortController = new AbortController();
setController(abortController);
try {
onNewPrompt(prompt);
if (isFollowUp && !redesignMarkdown && !isSameHtml) {
const selectedElementHtml = selectedElement
? selectedElement.outerHTML
: "";
const request = await fetch("/api/ask-ai", {
method: "PUT",
body: JSON.stringify({
prompt,
provider,
previousPrompt,
model,
html,
selectedElementHtml,
}),
headers: {
"Content-Type": "application/json",
"x-forwarded-for": window.location.hostname,
},
signal: abortController.signal,
});
if (request && request.body) {
const res = await request.json();
if (!request.ok) {
if (res.openLogin) {
setOpen(true);
} else if (res.openSelectProvider) {
setOpenProvider(true);
setProviderError(res.message);
} else if (res.openProModal) {
setOpenProModal(true);
} else {
toast.error(res.message);
}
setisAiWorking(false);
return;
}
setHtml(res.html);
toast.success("AI responded successfully");
setPreviousPrompt(prompt);
setPrompt("");
setisAiWorking(false);
onSuccess(res.html, prompt, res.updatedLines);
if (audio.current) audio.current.play();
}
} else {
const request = await fetch("/api/ask-ai", {
method: "POST",
body: JSON.stringify({
prompt,
provider,
model,
html: isSameHtml ? "" : html,
redesignMarkdown,
}),
headers: {
"Content-Type": "application/json",
"x-forwarded-for": window.location.hostname,
},
signal: abortController.signal,
});
if (request && request.body) {
const reader = request.body.getReader();
const decoder = new TextDecoder("utf-8");
const selectedModel = MODELS.find(
(m: { value: string }) => m.value === model
);
let contentThink: string | undefined = undefined;
const read = async () => {
const { done, value } = await reader.read();
if (done) {
const isJson =
contentResponse.trim().startsWith("{") &&
contentResponse.trim().endsWith("}");
const jsonResponse = isJson ? JSON.parse(contentResponse) : null;
if (jsonResponse && !jsonResponse.ok) {
if (jsonResponse.openLogin) {
setOpen(true);
} else if (jsonResponse.openSelectProvider) {
setOpenProvider(true);
setProviderError(jsonResponse.message);
} else if (jsonResponse.openProModal) {
setOpenProModal(true);
} else {
toast.error(jsonResponse.message);
}
setisAiWorking(false);
return;
}
toast.success("AI responded successfully");
setPreviousPrompt(prompt);
setPrompt("");
setisAiWorking(false);
setHasAsked(true);
setModel(MODELS[0].value);
if (audio.current) audio.current.play();
// Now we have the complete HTML including </html>, so set it to be sure
const finalDoc = contentResponse.match(
/<!DOCTYPE html>[\s\S]*<\/html>/
)?.[0];
if (finalDoc) {
setHtml(finalDoc);
}
onSuccess(finalDoc ?? contentResponse, prompt);
return;
}
const chunk = decoder.decode(value, { stream: true });
thinkResponse += chunk;
if (selectedModel?.isThinker) {
const thinkMatch = thinkResponse.match(/<think>[\s\S]*/)?.[0];
if (thinkMatch && !thinkResponse?.includes("</think>")) {
if ((contentThink?.length ?? 0) < 3) {
setOpenThink(true);
}
setThink(thinkMatch.replace("<think>", "").trim());
contentThink += chunk;
return read();
}
}
contentResponse += chunk;
const newHtml = contentResponse.match(
/<!DOCTYPE html>[\s\S]*/
)?.[0];
if (newHtml) {
setIsThinking(false);
let partialDoc = newHtml;
if (
partialDoc.includes("<head>") &&
!partialDoc.includes("</head>")
) {
partialDoc += "\n</head>";
}
if (
partialDoc.includes("<body") &&
!partialDoc.includes("</body>")
) {
partialDoc += "\n</body>";
}
if (!partialDoc.includes("</html>")) {
partialDoc += "\n</html>";
}
// Throttle the re-renders to avoid flashing/flicker
const now = Date.now();
if (now - lastRenderTime > 300) {
setHtml(partialDoc);
lastRenderTime = now;
}
if (partialDoc.length > 200) {
onScrollToBottom();
}
}
read();
};
read();
}
}
} catch (error: any) {
setisAiWorking(false);
toast.error(error.message);
if (error.openLogin) {
setOpen(true);
}
}
};
const stopController = () => {
if (controller) {
controller.abort();
setController(null);
setisAiWorking(false);
setThink("");
setOpenThink(false);
setIsThinking(false);
}
};
useUpdateEffect(() => {
if (refThink.current) {
refThink.current.scrollTop = refThink.current.scrollHeight;
}
}, [think]);
useUpdateEffect(() => {
if (!isThinking) {
setOpenThink(false);
}
}, [isThinking]);
const isSameHtml = useMemo(() => {
return isTheSameHtml(html);
}, [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>
)}
{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 && (
<div className="absolute bg-neutral-800 rounded-lg bottom-0 left-4 w-[calc(100%-30px)] h-full z-1 flex items-center justify-between max-lg:text-sm">
<div className="flex items-center justify-start gap-2">
<Loading overlay={false} className="!size-4" />
<p className="text-neutral-400 text-sm">
AI is {isThinking ? "thinking" : "coding"}...{" "}
</p>
</div>
<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>
)}
<input
type="text"
disabled={isAiWorking}
className={classNames(
"w-full bg-transparent text-sm outline-none text-white placeholder:text-neutral-400 p-4",
{
"!pt-2.5": selectedElement && !isAiWorking,
}
)}
placeholder={
selectedElement
? `Ask DeepSite about ${selectedElement.tagName.toLowerCase()}...`
: hasAsked
? "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">
<div className="flex-1 flex items-center justify-start gap-1.5">
<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)} html={html} />
<ProModal
html={html}
open={openProModal}
onClose={() => setOpenProModal(false)}
/>
{!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) {
setModel(MODELS[0].value);
}
setIsFollowUp(e === true);
}}
/>
Diff-Patch Update
</label>
<FollowUpTooltip />
</div>
)}
</div>
<audio ref={audio} id="audio" className="hidden">
<source src="/success.mp3" type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
</div>
);
}