| import { useState, useRef } from "react"; |
| import { toast } from "sonner"; |
| import { MODELS } from "@/lib/providers"; |
| import { Page } from "@/types"; |
|
|
| interface UseCallAiProps { |
| onNewPrompt: (prompt: string) => void; |
| onSuccess: (page: Page[], p: string, n?: number[][]) => void; |
| onScrollToBottom: () => void; |
| setPages: React.Dispatch<React.SetStateAction<Page[]>>; |
| setCurrentPage: React.Dispatch<React.SetStateAction<string>>; |
| currentPage: Page; |
| pages: Page[]; |
| isAiWorking: boolean; |
| setisAiWorking: React.Dispatch<React.SetStateAction<boolean>>; |
| } |
|
|
| export const useCallAi = ({ |
| onNewPrompt, |
| onSuccess, |
| onScrollToBottom, |
| setPages, |
| setCurrentPage, |
| pages, |
| isAiWorking, |
| setisAiWorking, |
| }: UseCallAiProps) => { |
| const audio = useRef<HTMLAudioElement | null>(null); |
| const [controller, setController] = useState<AbortController | null>(null); |
|
|
| const callAiNewProject = async (prompt: string, model: string | undefined, provider: string | undefined, redesignMarkdown?: string, handleThink?: (think: string) => void, onFinishThink?: () => void) => { |
| if (isAiWorking) return; |
| if (!redesignMarkdown && !prompt.trim()) return; |
| |
| setisAiWorking(true); |
| |
| const abortController = new AbortController(); |
| setController(abortController); |
| |
| try { |
| onNewPrompt(prompt); |
| |
| const request = await fetch("/api/ask-ai", { |
| method: "POST", |
| body: JSON.stringify({ |
| prompt, |
| provider, |
| model, |
| 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 contentResponse = ""; |
|
|
| 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) { |
| |
| return { error: "login_required" }; |
| } else if (jsonResponse.openSelectProvider) { |
| |
| return { error: "provider_required", message: jsonResponse.message }; |
| } else if (jsonResponse.openProModal) { |
| |
| return { error: "pro_required" }; |
| } else { |
| toast.error(jsonResponse.message); |
| setisAiWorking(false); |
| return { error: "api_error", message: jsonResponse.message }; |
| } |
| } |
|
|
| toast.success("AI responded successfully"); |
| setisAiWorking(false); |
| |
| if (audio.current) audio.current.play(); |
|
|
| const newPages = formatPages(contentResponse); |
| onSuccess(newPages, prompt); |
|
|
| return { success: true, pages: newPages }; |
| } |
|
|
| const chunk = decoder.decode(value, { stream: true }); |
| contentResponse += chunk; |
| |
| if (selectedModel?.isThinker) { |
| const thinkMatch = contentResponse.match(/<think>[\s\S]*/)?.[0]; |
| if (thinkMatch && !contentResponse?.includes("</think>")) { |
| handleThink?.(thinkMatch.replace("<think>", "").trim()); |
| return read(); |
| } |
| } |
|
|
| if (contentResponse.includes("</think>")) { |
| onFinishThink?.(); |
| } |
|
|
| formatPages(contentResponse); |
| return read(); |
| }; |
|
|
| return await read(); |
| } |
| |
| } catch (error: any) { |
| setisAiWorking(false); |
| toast.error(error.message); |
| if (error.openLogin) { |
| return { error: "login_required" }; |
| } |
| return { error: "network_error", message: error.message }; |
| } |
| }; |
|
|
| const callAiNewPage = async (prompt: string, model: string | undefined, provider: string | undefined, currentPagePath: string, previousPrompts?: string[]) => { |
| if (isAiWorking) return; |
| if (!prompt.trim()) return; |
| |
| setisAiWorking(true); |
| |
| const abortController = new AbortController(); |
| setController(abortController); |
| |
| try { |
| onNewPrompt(prompt); |
| |
| const request = await fetch("/api/ask-ai", { |
| method: "POST", |
| body: JSON.stringify({ |
| prompt, |
| provider, |
| model, |
| pages, |
| previousPrompts, |
| }), |
| 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 contentResponse = ""; |
|
|
| 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) { |
| |
| return { error: "login_required" }; |
| } else if (jsonResponse.openSelectProvider) { |
| |
| return { error: "provider_required", message: jsonResponse.message }; |
| } else if (jsonResponse.openProModal) { |
| |
| return { error: "pro_required" }; |
| } else { |
| toast.error(jsonResponse.message); |
| setisAiWorking(false); |
| return { error: "api_error", message: jsonResponse.message }; |
| } |
| } |
|
|
| toast.success("AI responded successfully"); |
| setisAiWorking(false); |
| |
| if (selectedModel?.isThinker) { |
| |
| |
| } |
| |
| if (audio.current) audio.current.play(); |
|
|
| const newPage = formatPage(contentResponse, currentPagePath); |
| if (!newPage) { return { error: "api_error", message: "Failed to format page" } } |
| onSuccess([...pages, newPage], prompt); |
|
|
| return { success: true, pages: [...pages, newPage] }; |
| } |
|
|
| const chunk = decoder.decode(value, { stream: true }); |
| contentResponse += chunk; |
| |
| if (selectedModel?.isThinker) { |
| const thinkMatch = contentResponse.match(/<think>[\s\S]*/)?.[0]; |
| if (thinkMatch && !contentResponse?.includes("</think>")) { |
| |
| return read(); |
| } |
| } |
|
|
| formatPage(contentResponse, currentPagePath); |
| return read(); |
| }; |
|
|
| return await read(); |
| } |
| |
| } catch (error: any) { |
| setisAiWorking(false); |
| toast.error(error.message); |
| if (error.openLogin) { |
| return { error: "login_required" }; |
| } |
| return { error: "network_error", message: error.message }; |
| } |
| }; |
|
|
| const callAiFollowUp = async (prompt: string, model: string | undefined, provider: string | undefined, previousPrompts: string[], selectedElementHtml?: string, files?: string[]) => { |
| if (isAiWorking) return; |
| if (!prompt.trim()) return; |
| |
| setisAiWorking(true); |
| |
| const abortController = new AbortController(); |
| setController(abortController); |
| |
| try { |
| onNewPrompt(prompt); |
| |
| const request = await fetch("/api/ask-ai", { |
| method: "PUT", |
| body: JSON.stringify({ |
| prompt, |
| provider, |
| previousPrompts, |
| model, |
| pages, |
| selectedElementHtml, |
| files, |
| }), |
| 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) { |
| setisAiWorking(false); |
| return { error: "login_required" }; |
| } else if (res.openSelectProvider) { |
| setisAiWorking(false); |
| return { error: "provider_required", message: res.message }; |
| } else if (res.openProModal) { |
| setisAiWorking(false); |
| return { error: "pro_required" }; |
| } else { |
| toast.error(res.message); |
| setisAiWorking(false); |
| return { error: "api_error", message: res.message }; |
| } |
| } |
|
|
| toast.success("AI responded successfully"); |
| setisAiWorking(false); |
|
|
| setPages(res.pages); |
| onSuccess(res.pages, prompt, res.updatedLines); |
| |
| if (audio.current) audio.current.play(); |
|
|
| return { success: true, html: res.html, updatedLines: res.updatedLines }; |
| } |
| |
| } catch (error: any) { |
| setisAiWorking(false); |
| toast.error(error.message); |
| if (error.openLogin) { |
| return { error: "login_required" }; |
| } |
| return { error: "network_error", message: error.message }; |
| } |
| }; |
|
|
| |
| const stopController = () => { |
| if (controller) { |
| controller.abort(); |
| setController(null); |
| setisAiWorking(false); |
| } |
| }; |
|
|
| const formatPages = (content: string) => { |
| const pages: Page[] = []; |
| if (!content.match(/<<<<<<< START_TITLE (.*?) >>>>>>> END_TITLE/)) { |
| return pages; |
| } |
|
|
| const cleanedContent = content.replace( |
| /[\s\S]*?<<<<<<< START_TITLE (.*?) >>>>>>> END_TITLE/, |
| "<<<<<<< START_TITLE $1 >>>>>>> END_TITLE" |
| ); |
| const htmlChunks = cleanedContent.split( |
| /<<<<<<< START_TITLE (.*?) >>>>>>> END_TITLE/ |
| ); |
| const processedChunks = new Set<number>(); |
|
|
| htmlChunks.forEach((chunk, index) => { |
| if (processedChunks.has(index) || !chunk?.trim()) { |
| return; |
| } |
| const htmlContent = extractHtmlContent(htmlChunks[index + 1]); |
|
|
| if (htmlContent) { |
| const page: Page = { |
| path: chunk.trim(), |
| html: htmlContent, |
| }; |
| pages.push(page); |
|
|
| if (htmlContent.length > 200) { |
| onScrollToBottom(); |
| } |
|
|
| processedChunks.add(index); |
| processedChunks.add(index + 1); |
| } |
| }); |
| if (pages.length > 0) { |
| setPages(pages); |
| const lastPagePath = pages[pages.length - 1]?.path; |
| setCurrentPage(lastPagePath || "index.html"); |
| } |
|
|
| return pages; |
| }; |
|
|
| const formatPage = (content: string, currentPagePath: string) => { |
| if (!content.match(/<<<<<<< START_TITLE (.*?) >>>>>>> END_TITLE/)) { |
| return null; |
| } |
|
|
| const cleanedContent = content.replace( |
| /[\s\S]*?<<<<<<< START_TITLE (.*?) >>>>>>> END_TITLE/, |
| "<<<<<<< START_TITLE $1 >>>>>>> END_TITLE" |
| ); |
|
|
| const htmlChunks = cleanedContent.split( |
| /<<<<<<< START_TITLE (.*?) >>>>>>> END_TITLE/ |
| )?.filter(Boolean); |
|
|
| const pagePath = htmlChunks[0]?.trim() || ""; |
| const htmlContent = extractHtmlContent(htmlChunks[1]); |
|
|
| if (!pagePath || !htmlContent) { |
| return null; |
| } |
|
|
| const page: Page = { |
| path: pagePath, |
| html: htmlContent, |
| }; |
|
|
| setPages(prevPages => { |
| const existingPageIndex = prevPages.findIndex(p => p.path === currentPagePath || p.path === pagePath); |
| |
| if (existingPageIndex !== -1) { |
| const updatedPages = [...prevPages]; |
| updatedPages[existingPageIndex] = page; |
| return updatedPages; |
| } else { |
| return [...prevPages, page]; |
| } |
| }); |
|
|
| setCurrentPage(pagePath); |
|
|
| if (htmlContent.length > 200) { |
| onScrollToBottom(); |
| } |
|
|
| return page; |
| }; |
|
|
| |
| const extractHtmlContent = (chunk: string): string => { |
| if (!chunk) return ""; |
|
|
| |
| const htmlMatch = chunk.trim().match(/<!DOCTYPE html>[\s\S]*/); |
| if (!htmlMatch) return ""; |
|
|
| let htmlContent = htmlMatch[0]; |
|
|
| |
| htmlContent = ensureCompleteHtml(htmlContent); |
|
|
| |
| htmlContent = htmlContent.replace(/```/g, ""); |
|
|
| return htmlContent; |
| }; |
|
|
| |
| const ensureCompleteHtml = (html: string): string => { |
| let completeHtml = html; |
|
|
| |
| if (completeHtml.includes("<head>") && !completeHtml.includes("</head>")) { |
| completeHtml += "\n</head>"; |
| } |
|
|
| |
| if (completeHtml.includes("<body") && !completeHtml.includes("</body>")) { |
| completeHtml += "\n</body>"; |
| } |
|
|
| |
| if (!completeHtml.includes("</html>")) { |
| completeHtml += "\n</html>"; |
| } |
|
|
| return completeHtml; |
| }; |
|
|
| return { |
| callAiNewProject, |
| callAiFollowUp, |
| callAiNewPage, |
| stopController, |
| controller, |
| audio, |
| }; |
| }; |
|
|