| import { useQuery, useQueryClient, useMutation } from "@tanstack/react-query"; |
| import { useMemo, useRef, useState } from "react"; |
| import { toast } from "sonner"; |
| import { useLocalStorage } from "react-use"; |
|
|
| import { MODELS } from "@/lib/providers"; |
| import { useEditor } from "./useEditor"; |
| import { Page, EnhancedSettings } from "@/types"; |
| import { api } from "@/lib/api"; |
| import { useRouter } from "next/navigation"; |
| import { useUser } from "./useUser"; |
| import { LivePreviewRef } from "@/components/editor/live-preview"; |
|
|
| export const useAi = (onScrollToBottom?: () => void, livePreviewRef?: React.RefObject<LivePreviewRef | null>) => { |
| const client = useQueryClient(); |
| const audio = useRef<HTMLAudioElement | null>(null); |
| const { setPages, setCurrentPage, setPrompts, prompts, pages, project, setProject, commits, setCommits, setLastSavedPages, isSameHtml } = useEditor(); |
| const [controller, setController] = useState<AbortController | null>(null); |
| const [storageProvider, setStorageProvider] = useLocalStorage("provider", "auto"); |
| const [storageModel, setStorageModel] = useLocalStorage("model", MODELS[0].value); |
| const router = useRouter(); |
| const { projects, setProjects } = useUser(); |
|
|
| const { data: isAiWorking = false } = useQuery({ |
| queryKey: ["ai.isAiWorking"], |
| queryFn: async () => false, |
| refetchOnWindowFocus: false, |
| refetchOnReconnect: false, |
| refetchOnMount: false, |
| }); |
| const setIsAiWorking = (newIsAiWorking: boolean) => { |
| client.setQueryData(["ai.isAiWorking"], newIsAiWorking); |
| }; |
|
|
| const { data: isThinking = false } = useQuery({ |
| queryKey: ["ai.isThinking"], |
| queryFn: async () => false, |
| refetchOnWindowFocus: false, |
| refetchOnReconnect: false, |
| refetchOnMount: false, |
| }); |
| const setIsThinking = (newIsThinking: boolean) => { |
| client.setQueryData(["ai.isThinking"], newIsThinking); |
| }; |
|
|
| const { data: selectedElement } = useQuery<HTMLElement | null>({ |
| queryKey: ["ai.selectedElement"], |
| queryFn: async () => null, |
| refetchOnWindowFocus: false, |
| refetchOnReconnect: false, |
| refetchOnMount: false, |
| initialData: null |
| }); |
| const setSelectedElement = (newSelectedElement: HTMLElement | null) => { |
| client.setQueryData(["ai.selectedElement"], newSelectedElement); |
| }; |
|
|
| const { data: isEditableModeEnabled = false } = useQuery({ |
| queryKey: ["ai.isEditableModeEnabled"], |
| queryFn: async () => false, |
| refetchOnWindowFocus: false, |
| refetchOnReconnect: false, |
| refetchOnMount: false, |
| }); |
| const setIsEditableModeEnabled = (newIsEditableModeEnabled: boolean) => { |
| client.setQueryData(["ai.isEditableModeEnabled"], newIsEditableModeEnabled); |
| }; |
|
|
| const { data: selectedFiles } = useQuery<string[]>({ |
| queryKey: ["ai.selectedFiles"], |
| queryFn: async () => [], |
| refetchOnWindowFocus: false, |
| refetchOnReconnect: false, |
| refetchOnMount: false, |
| initialData: [] |
| }); |
| const setSelectedFiles = (newFiles: string[]) => { |
| client.setQueryData(["ai.selectedFiles"], newFiles) |
| }; |
|
|
| const { data: provider } = useQuery({ |
| queryKey: ["ai.provider"], |
| queryFn: async () => storageProvider ?? "auto", |
| refetchOnWindowFocus: false, |
| refetchOnReconnect: false, |
| refetchOnMount: false, |
| initialData: storageProvider ?? "auto" |
| }); |
| const setProvider = (newProvider: string) => { |
| setStorageProvider(newProvider); |
| client.setQueryData(["ai.provider"], newProvider); |
| }; |
|
|
| const { data: model } = useQuery({ |
| queryKey: ["ai.model"], |
| queryFn: async () => { |
| |
| const selectedModel = MODELS.find(m => m.value === storageModel || m.label === storageModel); |
| if (selectedModel) { |
| return selectedModel.value; |
| } |
| return MODELS[0].value; |
| }, |
| refetchOnWindowFocus: false, |
| refetchOnReconnect: false, |
| refetchOnMount: false, |
| initialData: undefined, |
| }); |
| const setModel = (newModel: string) => { |
| setStorageModel(newModel); |
| client.setQueryData(["ai.model"], newModel); |
| }; |
|
|
| const createNewProject = async (prompt: string, htmlPages: Page[], projectName: string | undefined, isLoggedIn?: boolean) => { |
| if (isLoggedIn) { |
| const response = await api.post("/me/projects", { |
| title: projectName, |
| pages: htmlPages, |
| prompt, |
| }); |
| if (response.data.ok) { |
| setIsAiWorking(false); |
| |
| if (livePreviewRef?.current) { |
| livePreviewRef.current.reset(); |
| } |
| router.replace(`/projects/${response.data.space.project.space_id}`); |
| setProject(response.data.space); |
| setProjects([...projects, response.data.space]); |
| toast.success("AI responded successfully"); |
| if (audio.current) audio.current.play(); |
| } |
| } else { |
| setIsAiWorking(false); |
| if (livePreviewRef?.current) { |
| livePreviewRef.current.reset(); |
| } |
| toast.success("AI responded successfully"); |
| if (audio.current) audio.current.play(); |
| } |
| } |
| |
| const callAiNewProject = async (prompt: string, enhancedSettings?: EnhancedSettings, redesignMarkdown?: string, isLoggedIn?: boolean) => { |
| if (isAiWorking) return; |
| if (!redesignMarkdown && !prompt.trim()) return; |
| |
| setIsAiWorking(true); |
| |
| const abortController = new AbortController(); |
| setController(abortController); |
| |
| try { |
| const request = await fetch("/api/ask", { |
| method: "POST", |
| body: JSON.stringify({ |
| prompt, |
| provider, |
| model, |
| redesignMarkdown, |
| enhancedSettings, |
| }), |
| 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"); |
| let contentResponse = ""; |
|
|
| const read = async (): Promise<any> => { |
| const { done, value } = await reader.read(); |
| |
| if (done) { |
| const trimmedResponse = contentResponse.trim(); |
| if (trimmedResponse.startsWith("{") && trimmedResponse.endsWith("}")) { |
| try { |
| const jsonResponse = JSON.parse(trimmedResponse); |
| if (jsonResponse && !jsonResponse.ok) { |
| setIsAiWorking(false); |
| 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); |
| return { error: "api_error", message: jsonResponse.message }; |
| } |
| } |
| } catch (e) { |
| |
| } |
| } |
| |
| const newPages = formatPages(contentResponse); |
| const projectName = contentResponse.match(/<<<<<<< PROJECT_NAME_START ([\s\S]*?) >>>>>>> PROJECT_NAME_END/)?.[1]?.trim(); |
| setPages(newPages); |
| setLastSavedPages([...newPages]); |
| createNewProject(prompt, newPages, projectName, isLoggedIn); |
| setPrompts([...prompts, prompt]); |
|
|
| return { success: true, pages: newPages }; |
| } |
|
|
| const chunk = decoder.decode(value, { stream: true }); |
| contentResponse += chunk; |
| |
| const trimmedResponse = contentResponse.trim(); |
| if (trimmedResponse.startsWith("{") && trimmedResponse.endsWith("}")) { |
| try { |
| const jsonResponse = JSON.parse(trimmedResponse); |
| if (jsonResponse && !jsonResponse.ok) { |
| setIsAiWorking(false); |
| 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); |
| return { error: "api_error", message: jsonResponse.message }; |
| } |
| } |
| } catch (e) { |
| |
| } |
| } |
|
|
| formatPages(contentResponse); |
| |
| |
| return read(); |
| }; |
|
|
| return await read(); |
| } |
| |
| } catch (error: any) { |
| setIsAiWorking(false); |
| setIsThinking(false); |
| setController(null); |
| |
| if (!abortController.signal.aborted) { |
| toast.error(error.message || "Network error occurred"); |
| } |
| |
| if (error.openLogin) { |
| return { error: "login_required" }; |
| } |
| return { error: "network_error", message: error.message }; |
| } |
| }; |
|
|
| const callAiFollowUp = async (prompt: string, enhancedSettings?: EnhancedSettings, isNew?: boolean) => { |
| if (isAiWorking) return; |
| if (!prompt.trim()) return; |
|
|
| |
| setIsAiWorking(true); |
| |
| const abortController = new AbortController(); |
| setController(abortController); |
| |
| try { |
| const request = await fetch("/api/ask", { |
| method: "PUT", |
| body: JSON.stringify({ |
| prompt, |
| provider, |
| previousPrompts: prompts, |
| model, |
| pages, |
| selectedElementHtml: selectedElement?.outerHTML, |
| files: selectedFiles, |
| repoId: project?.space_id, |
| isNew, |
| enhancedSettings, |
| }), |
| 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"); |
| const iframe = document.getElementById( |
| "preview-iframe" |
| ) as HTMLIFrameElement; |
|
|
| if (isNew && res.repoId) { |
| router.push(`/projects/${res.repoId}`); |
| setIsAiWorking(false); |
| } else { |
| setPages(res.pages); |
| setLastSavedPages([...res.pages]); |
| setCommits([res.commit, ...commits]); |
| setPrompts( |
| [...prompts, prompt] |
| ) |
| setSelectedElement(null); |
| setSelectedFiles([]); |
| setIsEditableModeEnabled(false); |
| setIsAiWorking(false); |
| } |
|
|
| if (audio.current) audio.current.play(); |
| if (iframe) { |
| setTimeout(() => { |
| iframe.src = iframe.src; |
| }, 500); |
| } |
|
|
| 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 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; |
| }; |
|
|
| const cancelRequest = () => { |
| if (controller) { |
| controller.abort(); |
| setController(null); |
| } |
| setIsAiWorking(false); |
| setIsThinking(false); |
| |
| if (livePreviewRef?.current) { |
| livePreviewRef.current.reset(); |
| } |
| }; |
|
|
| const selectedModel = useMemo(() => { |
| return MODELS.find(m => m.value === model || m.label === model); |
| }, [model]); |
|
|
| return { |
| isThinking, |
| setIsThinking, |
| callAiNewProject, |
| callAiFollowUp, |
| isAiWorking, |
| setIsAiWorking, |
| selectedElement, |
| setSelectedElement, |
| selectedFiles, |
| setSelectedFiles, |
| isEditableModeEnabled, |
| setIsEditableModeEnabled, |
| globalAiLoading: isThinking || isAiWorking, |
| cancelRequest, |
| model, |
| setModel, |
| provider, |
| setProvider, |
| selectedModel, |
| audio, |
| }; |
| } |