dr-data
Fix OpenRouter API key input field validation
8e5e6d5
"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 [openrouterApiKey] = useLocalStorage<string>("openrouter-api-key", "");
// Check if OpenRouter API key is properly loaded from localStorage
const isOpenRouterApiKeyValid = useMemo(() => {
return openrouterApiKey && openrouterApiKey.trim().length > 0;
}, [openrouterApiKey]);
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) {
console.log('🔄 DIFF-PATCH MODE: Frontend sending PUT request', {
isFollowUp,
redesignMarkdown: !!redesignMarkdown,
isSameHtml,
hasSelectedElement: !!selectedElement,
htmlLength: html.length,
provider,
model
});
const selectedElementHtml = selectedElement
? selectedElement.outerHTML
: "";
console.log('📤 PUT request payload:', {
method: 'PUT',
promptLength: prompt.length,
hasSelectedElement: !!selectedElementHtml,
selectedElementLength: selectedElementHtml.length,
totalHtmlLength: html.length,
hasOpenRouterApiKey: isOpenRouterApiKeyValid
});
// Validate OpenRouter API key if using OpenRouter
if (provider === "openrouter" && !isOpenRouterApiKeyValid) {
toast.error("OpenRouter API key is required for this model");
setOpenProvider(true);
setProviderError("OpenRouter API key is required for this model");
setisAiWorking(false);
return;
}
const request = await fetch("/api/ask-ai", {
method: "PUT",
body: JSON.stringify({
prompt,
provider,
previousPrompt,
model,
html,
selectedElementHtml,
openrouterApiKey: openrouterApiKey?.trim() || undefined,
}),
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 {
console.log('🆕 INITIAL REQUEST: Frontend sending POST request', {
isFollowUp,
redesignMarkdown: !!redesignMarkdown,
isSameHtml,
reason: !isFollowUp ? 'isFollowUp=false' : redesignMarkdown ? 'has redesignMarkdown' : 'isSameHtml=true',
provider,
model,
htmlLength: html.length
});
// Validate OpenRouter API key if using OpenRouter
if (provider === "openrouter" && !isOpenRouterApiKeyValid) {
toast.error("OpenRouter API key is required for this model");
setOpenProvider(true);
setProviderError("OpenRouter API key is required for this model");
setisAiWorking(false);
return;
}
const request = await fetch("/api/ask-ai", {
method: "POST",
body: JSON.stringify({
prompt,
provider,
model,
html: isSameHtml ? "" : html,
redesignMarkdown,
openrouterApiKey: openrouterApiKey?.trim() || undefined,
}),
headers: {
"Content-Type": "application/json",
"x-forwarded-for": window.location.hostname,
},
signal: abortController.signal,
});
if (request && request.body) {
// ROBUST ERROR HANDLING: Handle both JSON and HTML error responses
if (!request.ok) {
console.error('❌ Request failed:', {
status: request.status,
statusText: request.statusText,
url: request.url,
headers: Object.fromEntries(request.headers.entries())
});
try {
// Get the full response as text first
const responseText = await request.text();
console.log('📄 Raw error response:', {
length: responseText.length,
contentType: request.headers.get('content-type'),
preview: responseText.substring(0, 500) + (responseText.length > 500 ? '...' : ''),
isHtml: responseText.trim().toLowerCase().startsWith('<!doctype html') || responseText.trim().toLowerCase().startsWith('<html')
});
let res;
// Check if response is HTML (common for server errors)
if (responseText.trim().toLowerCase().startsWith('<!doctype html') ||
responseText.trim().toLowerCase().startsWith('<html') ||
responseText.includes('<title>') ||
responseText.includes('Internal Server Error')) {
console.error('❌ HTML error page received instead of JSON');
// Extract error info from HTML if possible
let errorMessage = 'Server error occurred';
const titleMatch = responseText.match(/<title[^>]*>([^<]+)<\/title>/i);
if (titleMatch && titleMatch[1]) {
errorMessage = titleMatch[1].trim();
}
// Look for common error patterns
if (responseText.includes('Internal Server Error')) {
errorMessage = 'Internal Server Error - Please try again';
} else if (responseText.includes('Service Unavailable')) {
errorMessage = 'Service temporarily unavailable';
} else if (responseText.includes('Bad Gateway')) {
errorMessage = 'Gateway error - Please try again';
}
res = {
ok: false,
error: errorMessage,
message: errorMessage,
isHtmlError: true
};
} else {
// Try to parse as JSON
try {
res = JSON.parse(responseText);
console.error('❌ Error response (JSON):', res);
} catch (jsonError) {
console.error('❌ Failed to parse JSON, treating as text:', {
jsonError: (jsonError as Error).message || String(jsonError),
responseLength: responseText.length
});
// Create error object from text response
let errorMessage = responseText.trim();
// Clean up common error patterns
if (errorMessage.length > 200) {
errorMessage = errorMessage.substring(0, 200) + '...';
}
if (!errorMessage) {
errorMessage = `HTTP ${request.status}: ${request.statusText}`;
}
res = {
ok: false,
error: errorMessage,
message: errorMessage,
isTextError: true
};
}
}
// Handle different error types
if (res.openLogin) {
setOpen(true);
} else if (res.openSelectProvider) {
setOpenProvider(true);
setProviderError(res.message || res.error || 'Provider error');
} else if (res.openProModal) {
setOpenProModal(true);
} else {
const errorMsg = res.message || res.error || 'Unknown error occurred';
console.error('❌ Showing error to user:', errorMsg);
toast.error(errorMsg);
}
} catch (parseError) {
console.error('❌ Complete failure to parse error response:', {
parseError: (parseError as Error).message || String(parseError),
status: request.status,
statusText: request.statusText
});
toast.error(`Request failed: ${request.status} ${request.statusText}`);
}
setisAiWorking(false);
return;
}
console.log('✅ Request successful, processing stream...', {
status: request.status,
headers: Object.fromEntries(request.headers.entries())
});
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;
let chunkCount = 0;
const read = async () => {
const { done, value } = await reader.read();
if (done) {
console.log('✅ Stream completed:', {
totalChunks: chunkCount,
finalContentLength: contentResponse.length,
hasDoctype: contentResponse.includes('<!DOCTYPE html>'),
hasHtmlEnd: contentResponse.includes('</html>')
});
toast.success("AI responded successfully");
setPreviousPrompt(prompt);
setPrompt("");
setisAiWorking(false);
setHasAsked(true);
// Note: Removed automatic model reset to preserve user's selection
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);
} else {
console.warn('⚠️ No complete HTML document found in response');
}
onSuccess(finalDoc ?? contentResponse, prompt);
return;
}
chunkCount++;
const chunk = decoder.decode(value, { stream: true });
console.log(`📦 Frontend chunk ${chunkCount}:`, {
chunkLength: chunk.length,
hasJsonError: chunk.trim().startsWith('{'),
preview: chunk.substring(0, 200) + (chunk.length > 200 ? '...' : '')
});
// Check if this chunk looks like a JSON error response
if (chunk.trim().startsWith('{') && chunk.trim().endsWith('}')) {
try {
const res = JSON.parse(chunk);
// Only treat as error if it has error indicators
if (res.ok === false || res.error || res.openLogin || res.openSelectProvider || res.openProModal) {
console.error('❌ Error response received:', res);
if (res.openLogin) {
setOpen(true);
} else if (res.openSelectProvider) {
setOpenProvider(true);
setProviderError(res.message || res.error);
} else if (res.openProModal) {
setOpenProModal(true);
} else {
toast.error(res.message || res.error || 'Unknown error occurred');
}
setisAiWorking(false);
return;
}
} catch {
// If it looks like JSON but can't be parsed, treat as content
console.log('⚠️ Chunk looks like JSON but failed to parse, treating as content');
}
}
// Treat as normal content
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>";
}
// Ultra-conservative throttling to eliminate all flashing
const now = Date.now();
const isCompleteDocument = partialDoc.includes('</html>');
const htmlLength = partialDoc.length;
const timeSinceLastUpdate = lastRenderTime === 0 ? 0 : now - lastRenderTime;
// ZERO-FLASH throttling - more responsive since we have seamless injection
let throttleDelay = 1000; // Default: responsive for zero-flash system
if (isCompleteDocument) {
throttleDelay = 200; // Fast completion
} else if (htmlLength < 1000) {
throttleDelay = 1500; // Moderate for small content
} else if (htmlLength > 8000) {
throttleDelay = 800; // Faster for large content since no flash
} else if (timeSinceLastUpdate > 3000) {
throttleDelay = 500; // Faster if it's been long
}
// Only update if enough time has passed OR it's completion
const shouldUpdate = (now - lastRenderTime > throttleDelay) || isCompleteDocument;
if (shouldUpdate) {
setHtml(partialDoc);
lastRenderTime = now;
console.log('� Frontend: Zero-flash HTML update', {
htmlLength: partialDoc.length,
isComplete: isCompleteDocument,
chunkNumber: chunkCount,
throttleDelay,
timeSinceLastUpdate,
updateReason: isCompleteDocument ? 'completion' : 'time-based'
});
} else {
console.log('⏳ Frontend: Throttling update for optimal UX', {
htmlLength: partialDoc.length,
timeSinceLastUpdate,
requiredDelay: throttleDelay,
chunkNumber: chunkCount
});
}
if (partialDoc.length > 200) {
onScrollToBottom();
}
} else {
// Still thinking/no HTML yet
console.log('🤔 Still waiting for HTML to start...');
}
read();
};
read();
}
}
} catch (error: any) {
console.error('❌ Frontend error in callAi:', {
errorMessage: error.message,
errorStack: error.stack,
model,
provider,
isAborted: error.name === 'AbortError'
});
setisAiWorking(false);
if (error.name === 'AbortError') {
toast.error('Request was cancelled');
} else {
toast.error(error.message || 'An unexpected error occurred');
}
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={() => {
console.log("🎯 Edit button clicked:", {
currentState: isEditableModeEnabled,
newState: !isEditableModeEnabled,
hasSetFunction: !!setIsEditableModeEnabled
});
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}
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: boolean) => {
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>
);
}