Spaces:
Sleeping
Sleeping
import { useState } from "react"; | |
import { RiSparkling2Fill } from "react-icons/ri"; | |
import { GrSend } from "react-icons/gr"; | |
import classNames from "classnames"; | |
import { toast } from "react-toastify"; | |
// Removed monaco editor import | |
import Login from "../login/login"; | |
import { defaultHTML } from "../../utils/consts"; | |
import SuccessSound from "./../../assets/success.mp3"; | |
function AskAI({ | |
html, // Current full HTML content (used for initial request and context) | |
setHtml, // Used for updates (both full and diff-based) | |
onScrollToBottom, // Used for full updates | |
isAiWorking, | |
setisAiWorking, | |
}: { | |
html: string; | |
setHtml: (html: string) => void; | |
onScrollToBottom: () => void; | |
isAiWorking: boolean; | |
setisAiWorking: React.Dispatch<React.SetStateAction<boolean>>; | |
// Removed editorRef prop | |
}) { | |
const [open, setOpen] = useState(false); | |
const [prompt, setPrompt] = useState(""); | |
const [hasAsked, setHasAsked] = useState(false); | |
const [previousPrompt, setPreviousPrompt] = useState(""); | |
const [streamProgress, setStreamProgress] = useState(0); // Track streaming progress | |
const [responseMode, setResponseMode] = useState<"full" | "diff" | null>(null); // Track response type | |
const audio = new Audio(SuccessSound); | |
audio.volume = 0.5; | |
// Removed client-side diff parsing/applying logic | |
// --- Main AI Call Logic --- | |
const callAi = async () => { | |
if (isAiWorking || !prompt.trim()) return; | |
const originalHtml = html; // Store the HTML state at the start of the request | |
setisAiWorking(true); | |
setStreamProgress(0); // Reset progress indicator | |
setResponseMode(null); // Reset response mode | |
let fullContentResponse = ""; // Used for full HTML mode | |
let accumulatedDiffResponse = ""; // Used for diff mode | |
let lastRenderTime = 0; // For throttling full HTML updates | |
let totalChunksReceived = 0; | |
try { | |
const request = await fetch("/api/ask-ai", { | |
method: "POST", | |
body: JSON.stringify({ | |
prompt, | |
...(html === defaultHTML ? {} : { html }), | |
...(previousPrompt ? { previousPrompt } : {}), | |
}), | |
headers: { | |
"Content-Type": "application/json", | |
}, | |
}); | |
if (request && request.body) { | |
if (!request.ok) { | |
const res = await request.json(); | |
if (res.openLogin) { | |
setOpen(true); | |
} else { | |
// don't show toast if it's a login error | |
toast.error(res.message); | |
} | |
setisAiWorking(false); | |
setStreamProgress(0); | |
return; | |
} | |
const responseType = request.headers.get("X-Response-Type") || "full"; // Default to full if header missing | |
setResponseMode(responseType as "full" | "diff"); | |
console.log(`[AI Response] Type: ${responseType}`); | |
const reader = request.body.getReader(); | |
const decoder = new TextDecoder("utf-8"); | |
console.log("[AI Request] Starting to read response stream"); | |
// Process all chunks until done | |
while (true) { | |
try { | |
const { done, value } = await reader.read(); | |
if (done) { | |
console.log("[AI Response] Stream finished successfully."); | |
console.log("[AI Response] Accumulated full content length:", fullContentResponse.length); | |
console.log("[AI Response] Accumulated diff content length:", accumulatedDiffResponse.length); | |
setStreamProgress(100); // Set progress to 100% when done | |
// Play success animation | |
document.body.classList.add('ai-success'); | |
setTimeout(() => { | |
document.body.classList.remove('ai-success'); | |
}, 1000); | |
// --- Post-stream processing --- | |
if (responseType === 'diff') { | |
// Apply diffs server-side | |
try { | |
console.log("[Diff Apply] Sending original HTML and AI diff response to server..."); | |
const applyRequest = await fetch("/api/apply-diffs", { | |
method: "POST", | |
headers: { "Content-Type": "application/json" }, | |
body: JSON.stringify({ | |
originalHtml: originalHtml, // Send the HTML from the start of the request | |
aiResponseContent: accumulatedDiffResponse, | |
}), | |
}); | |
if (!applyRequest.ok) { | |
const errorData = await applyRequest.json(); | |
throw new Error(errorData.message || `Server failed to apply diffs (status ${applyRequest.status})`); | |
} | |
const patchedHtml = await applyRequest.text(); | |
console.log("[Diff Apply] Received patched HTML from server."); | |
setHtml(patchedHtml); // Update editor with the final result | |
toast.success("AI changes applied"); | |
} catch (applyError: unknown) { | |
console.error("Error applying diffs server-side:", applyError); | |
const errorMessage = applyError instanceof Error | |
? applyError.message | |
: 'Unknown error applying changes'; | |
toast.error(`Failed to apply AI changes: ${errorMessage}`); | |
// Optionally revert to originalHtml? Or leave the editor as is? | |
// setHtml(originalHtml); // Uncomment to revert on failure | |
} | |
} else { | |
// Final update for full HTML mode | |
const finalDoc = fullContentResponse.match(/<!DOCTYPE html>[\s\S]*<\/html>/)?.[0]; | |
if (finalDoc) { | |
console.log("[AI Response] Found complete HTML document"); | |
setHtml(finalDoc); // Ensure final complete HTML is set | |
} else if (fullContentResponse.includes("<html") && fullContentResponse.includes("</html>")) { | |
// Try to match HTML without DOCTYPE | |
const htmlMatch = fullContentResponse.match(/<html[\s\S]*<\/html>/); | |
if (htmlMatch) { | |
console.log("[AI Response] Found HTML without DOCTYPE, adding DOCTYPE"); | |
setHtml(`<!DOCTYPE html>\n${htmlMatch[0]}`); | |
} | |
} else if (fullContentResponse.trim()) { | |
console.warn("[AI Response] Final response doesn't contain proper HTML"); | |
// Search for any HTML-like content | |
if (fullContentResponse.includes("<body") || | |
(fullContentResponse.includes("<div") && fullContentResponse.includes("</div>"))) { | |
console.log("[AI Response] Found partial HTML content, wrapping it"); | |
// Wrap the content in a basic HTML structure | |
const wrappedContent = `<!DOCTYPE html> | |
<html> | |
<head> | |
<title>AI Generated Content</title> | |
<meta charset="utf-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
</head> | |
<body> | |
${fullContentResponse} | |
</body> | |
</html>`; | |
setHtml(wrappedContent); | |
} else { | |
// If it's just text, wrap it in pre tag | |
console.log("[AI Response] No HTML found, creating preview with text content"); | |
const textContent = `<!DOCTYPE html> | |
<html> | |
<head> | |
<title>AI Generated Text</title> | |
<meta charset="utf-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<style> | |
body { font-family: Arial, sans-serif; padding: 20px; } | |
pre { white-space: pre-wrap; word-break: break-word; } | |
</style> | |
</head> | |
<body> | |
<pre>${fullContentResponse}</pre> | |
</body> | |
</html>`; | |
setHtml(textContent); | |
} | |
} | |
} | |
toast.success("AI processing complete"); | |
setPrompt(""); | |
setPreviousPrompt(prompt); | |
setisAiWorking(false); | |
setHasAsked(true); | |
audio.play(); | |
break; // Exit the loop | |
} | |
const chunk = decoder.decode(value, { stream: true }); | |
totalChunksReceived++; | |
// Update progress indicator with better estimation | |
// First 5 chunks are typically slower (setup) | |
let estimatedProgress; | |
if (totalChunksReceived <= 5) { | |
estimatedProgress = Math.floor((totalChunksReceived / 5) * 25); // First 25% | |
} else if (totalChunksReceived <= 15) { | |
estimatedProgress = 25 + Math.floor(((totalChunksReceived - 5) / 10) * 50); // Next 50% | |
} else { | |
estimatedProgress = 75 + Math.floor(((totalChunksReceived - 15) / 10) * 20); // Final 25% | |
} | |
estimatedProgress = Math.min(95, estimatedProgress); | |
setStreamProgress(estimatedProgress); | |
console.log("[AI Stream] Received chunk of length:", chunk.length); | |
if (chunk.length > 0) { | |
console.log("[AI Stream] Sample:", chunk.substring(0, 50) + (chunk.length > 50 ? "..." : "")); | |
} | |
if (responseType === 'diff') { | |
// --- Diff Mode --- | |
accumulatedDiffResponse += chunk; // Just accumulate the raw response | |
console.log("[AI Diff] Accumulated diff length now:", accumulatedDiffResponse.length); | |
} else { | |
// --- Full HTML Mode --- | |
fullContentResponse += chunk; // Accumulate for preview | |
// Use regex to find the start of the HTML doc | |
const newHtmlMatch = fullContentResponse.match(/<!DOCTYPE html>[\s\S]*/); | |
const newHtml = newHtmlMatch ? newHtmlMatch[0] : null; | |
if (newHtml) { | |
console.log("[AI Full] Found HTML content, length:", newHtml.length); | |
// Throttle the re-renders to avoid flashing/flicker - reduced from 300ms to 150ms | |
const now = Date.now(); | |
if (now - lastRenderTime > 150) { | |
// Force-close the HTML tag for preview if needed | |
let partialDoc = newHtml; | |
if (!partialDoc.trim().endsWith("</html>")) { | |
partialDoc += "\n</html>"; | |
} | |
console.log("[AI Full] Updating preview with partial HTML"); | |
setHtml(partialDoc); // Update the preview iframe content | |
lastRenderTime = now; | |
} | |
// Scroll editor down if content is long (heuristic) | |
if (newHtml.length > 200 && now - lastRenderTime < 50) { // Only scroll if recently rendered | |
onScrollToBottom(); | |
} | |
} else { | |
console.log("[AI Full] No HTML content found yet in stream"); | |
} | |
} | |
} catch (streamError) { | |
console.error("[AI Response] Error processing stream chunk:", streamError); | |
// Continue trying to read the stream despite error | |
} | |
} // end while loop | |
} else { | |
throw new Error("Response body is null"); | |
} | |
} catch (error: unknown) { | |
setisAiWorking(false); | |
setStreamProgress(0); | |
// Handle the error with proper type checking | |
if (error instanceof Error) { | |
toast.error(error.message); | |
} else if (typeof error === 'object' && error !== null && 'openLogin' in error) { | |
const loginError = error as { openLogin: boolean, message?: string }; | |
toast.error(loginError.message || 'Authentication error'); | |
if (loginError.openLogin) { | |
setOpen(true); | |
} | |
} else { | |
toast.error('An unexpected error occurred'); | |
} | |
} | |
}; | |
return ( | |
<div | |
className={`bg-gray-950 rounded-xl py-2 lg:py-2.5 pl-3.5 lg:pl-4 pr-2 lg:pr-2.5 absolute lg:sticky bottom-3 left-3 lg:bottom-4 lg:left-4 w-[calc(100%-1.5rem)] lg:w-[calc(100%-2rem)] z-10 group`} | |
> | |
{/* Progress indicator */} | |
{isAiWorking && ( | |
<div className="absolute top-0 left-0 h-2 bg-gradient-to-r from-purple-500 via-pink-500 to-blue-500 transition-all duration-200 ease-in-out shadow-lg animate-pulse" | |
style={{ width: `${streamProgress}%`, borderTopLeftRadius: '0.75rem', zIndex: 20 }}> | |
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-2"> | |
<span className="text-xs font-bold text-white drop-shadow-lg"> | |
{streamProgress}% | |
</span> | |
<span className="inline-block w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin"></span> | |
</div> | |
</div> | |
)} | |
<div className="w-full relative flex items-center justify-between"> | |
<RiSparkling2Fill className={`text-lg lg:text-xl ${isAiWorking ? 'text-pink-500 animate-pulse' : 'text-gray-500'} group-focus-within:text-pink-500`} /> | |
<input | |
type="text" | |
disabled={isAiWorking} | |
className="w-full bg-transparent max-lg:text-sm outline-none pl-3 text-white placeholder:text-gray-500 font-code" | |
placeholder={ | |
hasAsked ? "What do you want to ask AI next?" : "Ask AI anything..." | |
} | |
value={prompt} | |
onChange={(e) => setPrompt(e.target.value)} | |
onKeyDown={(e) => { | |
if (e.key === "Enter") { | |
callAi(); | |
} | |
}} | |
/> | |
{isAiWorking ? ( | |
<div className="flex-none flex items-center justify-center rounded-full text-sm font-semibold size-8 text-center bg-pink-500 text-white shadow-sm dark:shadow-highlight/20"> | |
<div className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full mr-1"></div> | |
<span className="ml-1 text-xs font-bold animate-ellipsis">{streamProgress}%</span> | |
</div> | |
) : ( | |
<button | |
disabled={isAiWorking} | |
className="relative overflow-hidden cursor-pointer flex-none flex items-center justify-center rounded-full text-sm font-semibold size-8 text-center bg-pink-500 hover:bg-pink-400 text-white shadow-sm dark:shadow-highlight/20 disabled:bg-gray-300 disabled:text-gray-500 disabled:cursor-not-allowed disabled:hover:bg-gray-300" | |
onClick={callAi} | |
> | |
<GrSend className="-translate-x-[1px]" /> | |
</button> | |
)} | |
</div> | |
{/* Response mode indicator */} | |
{isAiWorking && responseMode && ( | |
<div className="absolute -top-8 right-0 text-xs font-medium text-white bg-gray-800/90 backdrop-blur-sm px-3 py-1.5 rounded-full flex items-center gap-1.5 animate-pulse border border-gray-700 shadow-lg z-20"> | |
<div className="size-2 rounded-full bg-pink-500 animate-pulse"></div> | |
{responseMode === 'diff' ? 'Applying changes to code...' : 'Generating new HTML...'} | |
</div> | |
)} | |
<div | |
className={classNames( | |
"h-screen w-screen bg-black/20 fixed left-0 top-0 z-10", | |
{ | |
"opacity-0 pointer-events-none": !open, | |
} | |
)} | |
onClick={() => setOpen(false)} | |
></div> | |
<div | |
className={classNames( | |
"absolute top-0 -translate-y-[calc(100%+8px)] right-0 z-10 w-80 bg-white border border-gray-200 rounded-lg shadow-lg transition-all duration-75 overflow-hidden", | |
{ | |
"opacity-0 pointer-events-none": !open, | |
} | |
)} | |
> | |
<Login html={html}> | |
<p className="text-gray-500 text-sm mb-3"> | |
You reached the limit of free AI usage. Please login to continue. | |
</p> | |
</Login> | |
</div> | |
</div> | |
); | |
} | |
export default AskAI; | |