527 lines
16 KiB
TypeScript
527 lines
16 KiB
TypeScript
"use client";
|
|
import { useEffect, useRef, useState, useContext, useCallback } from "react";
|
|
import { CodeGenerationParams, generateCode } from "./generateCode";
|
|
import { detectComponents } from "./detectComponent";
|
|
import Spinner from "./components/Spinner";
|
|
import {
|
|
FaCode,
|
|
FaDesktop,
|
|
FaDownload,
|
|
FaCopy,
|
|
FaChevronLeft,
|
|
} from "react-icons/fa";
|
|
|
|
import { Switch } from "./components/ui/switch";
|
|
import { Button } from "./components/ui/button";
|
|
import { Tabs, TabsList, TabsTrigger } from "./components/ui/tabs";
|
|
import { AppState, GeneratedCodeConfig } from "./types";
|
|
import html2canvas from "html2canvas";
|
|
import HistoryDisplay from "./components/history/HistoryDisplay";
|
|
import { extractHistoryTree } from "./components/history/utils";
|
|
import toast from "react-hot-toast";
|
|
import { UploadFileContext } from './contexts/UploadFileContext';
|
|
import { SettingContext } from './contexts/SettingContext';
|
|
import { HistoryContext } from './contexts/HistoryContext';
|
|
import { EditorContext, deviceType } from './contexts/EditorContext';
|
|
import UpdateChatInput from './components/chatInput/Update';
|
|
import dynamic from "next/dynamic";
|
|
import { useDebounceFn } from 'ahooks';
|
|
import { useRouter } from 'next/navigation';
|
|
import copy from "copy-to-clipboard";
|
|
import CodePreview from './components/CodePreview';
|
|
import classNames from "classnames";
|
|
import { useRouter as useNextRouter } from 'next/router';
|
|
import SettingsDialog from './components/SettingsDialog';
|
|
|
|
const CodeTab = dynamic(
|
|
async () => (await import("./components/CodeTab")),
|
|
{
|
|
ssr: false,
|
|
},
|
|
)
|
|
|
|
const PreviewBox = dynamic(
|
|
async () => (await import("../engine")),
|
|
{
|
|
ssr: false,
|
|
},
|
|
)
|
|
|
|
function App() {
|
|
const [appState, setAppState] = useState<AppState>(AppState.INITIAL);
|
|
const [generatedCode, setGeneratedCode] = useState<string>('');
|
|
|
|
const [referenceImages, setReferenceImages] = useState<string[]>([]);
|
|
const [referenceText, setReferenceText] = useState<string>('');
|
|
|
|
const [executionConsole, setExecutionConsole] = useState<string[]>([]);
|
|
const [updateInstruction, setUpdateInstruction] = useState("");
|
|
const [partValue, setPartValue] = useState<{
|
|
uid: string;
|
|
message: string
|
|
}>({ uid: '', message: '' });
|
|
|
|
const {
|
|
dataUrls,
|
|
setDataUrls,
|
|
} = useContext(UploadFileContext)
|
|
|
|
// Settings
|
|
const { settings, setSettings, initCreate, setInitCreate, initCreateText, setInitCreateText } = useContext(SettingContext);
|
|
|
|
const { history, addHistory, currentVersion, setCurrentVersion, resetHistory, updateHistoryCode } = useContext(HistoryContext);
|
|
const { enableEdit, setEnableEdit, device, setDevice } = useContext(EditorContext)
|
|
const [tabValue, setTabValue] = useState<string>('desktop')
|
|
const [openDialog, setOpenDialog] = useState<boolean>(false);
|
|
|
|
// Tracks the currently shown version from app history
|
|
|
|
const [shouldIncludeResultImage, setShouldIncludeResultImage] =
|
|
useState<boolean>(false);
|
|
|
|
const router = useRouter();
|
|
const nextRouter = useNextRouter();
|
|
|
|
const wsRef = useRef<AbortController>(null);
|
|
const initFn = useDebounceFn(() => {
|
|
if (dataUrls.length) {
|
|
doCreate(dataUrls, '');
|
|
setDataUrls([]);
|
|
}
|
|
}, {
|
|
wait: 300
|
|
});
|
|
const initTextFn = useDebounceFn(() => {
|
|
if (initCreateText) {
|
|
doCreate([], initCreateText);
|
|
setInitCreateText('');
|
|
}
|
|
}, {
|
|
wait: 300
|
|
});
|
|
|
|
// When the user already has the settings in local storage, newly added keys
|
|
// do not get added to the settings so if it's falsy, we populate it with the default
|
|
// value
|
|
useEffect(() => {
|
|
if (!settings.generatedCodeConfig) {
|
|
setSettings({
|
|
...settings,
|
|
generatedCodeConfig: GeneratedCodeConfig.REACT_ANTD,
|
|
});
|
|
}
|
|
|
|
}, [settings.generatedCodeConfig, setSettings]);
|
|
|
|
useEffect(() => {
|
|
const slug = nextRouter.query.slug;
|
|
if (slug === 'create') {
|
|
if (dataUrls.length) {
|
|
initFn.run();
|
|
}
|
|
if (initCreateText) {
|
|
initTextFn.run();
|
|
}
|
|
}
|
|
}, [initCreate, dataUrls, initCreateText]);
|
|
|
|
const takeScreenshot = async (): Promise<string> => {
|
|
const iframeElement = document.querySelector(
|
|
".lc-simulator-content-frame"
|
|
) as HTMLIFrameElement;
|
|
if (!iframeElement?.contentWindow?.document.body) {
|
|
return "";
|
|
}
|
|
|
|
const canvas = await html2canvas(iframeElement.contentWindow.document.body);
|
|
const png = canvas.toDataURL("image/png");
|
|
return png;
|
|
};
|
|
|
|
const downloadCode = () => {
|
|
// Create a blob from the generated code
|
|
const blob = new Blob([generatedCode], { type: "text/html" });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
// Create an anchor element and set properties for download
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "index.html"; // Set the file name for download
|
|
document.body.appendChild(a); // Append to the document
|
|
a.click(); // Programmatically click the anchor to trigger download
|
|
|
|
// Clean up by removing the anchor and revoking the Blob URL
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const reset = () => {
|
|
setAppState(AppState.INITIAL);
|
|
setGeneratedCode("");
|
|
setReferenceImages([]);
|
|
setExecutionConsole([]);
|
|
resetHistory();
|
|
};
|
|
|
|
function doGenerateCode(
|
|
params: CodeGenerationParams,
|
|
parentVersion: number | null
|
|
) {
|
|
setExecutionConsole([]);
|
|
setAppState(AppState.CODING);
|
|
|
|
// Merge settings with params
|
|
const updatedParams = { ...params, ...settings, slug: nextRouter.query.slug };
|
|
generateCode(
|
|
wsRef,
|
|
updatedParams,
|
|
(token) => setGeneratedCode((prev) => prev + token),
|
|
(code) => {
|
|
setGeneratedCode(code);
|
|
addHistory(params.generationType, updateInstruction, referenceImages, referenceText, code, partValue.message);
|
|
},
|
|
(line) => setExecutionConsole((prev) => [...prev, line]),
|
|
() => {
|
|
setAppState(AppState.CODE_READY);
|
|
},
|
|
(error) => {
|
|
if (error === 'No openai key, set it') {
|
|
setOpenDialog(true);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
// Initial version creation
|
|
function doCreate(referenceImages: string[], text: string, slug?: string) {
|
|
reset();
|
|
|
|
setReferenceImages(referenceImages);
|
|
setReferenceText(text);
|
|
// console.log('doCreate begin', referenceImages[0])
|
|
|
|
if (referenceImages.length > 0 || text || slug) {
|
|
if(settings.useCVModel){
|
|
detectComponents(referenceImages[0])
|
|
.then((res) => res.json())
|
|
.then((data) => {
|
|
doGenerateCode(
|
|
{
|
|
generationType: "create",
|
|
image: referenceImages[0],
|
|
text,
|
|
components: data?.componentPosition,
|
|
},
|
|
currentVersion
|
|
);
|
|
})
|
|
}else{
|
|
doGenerateCode(
|
|
{
|
|
generationType: "create",
|
|
image: referenceImages[0],
|
|
text,
|
|
},
|
|
currentVersion
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Subsequent updates
|
|
async function doUpdate(partData?: any) {
|
|
if (currentVersion === null) {
|
|
toast.error(
|
|
"No current version set. Contact support or open a Gitee issue."
|
|
);
|
|
return;
|
|
}
|
|
|
|
const updatedHistory = [
|
|
...extractHistoryTree(history, currentVersion),
|
|
updateInstruction,
|
|
];
|
|
|
|
if (shouldIncludeResultImage) {
|
|
const resultImage = await takeScreenshot();
|
|
doGenerateCode(
|
|
{
|
|
generationType: "update",
|
|
image: referenceImages[0],
|
|
text: referenceText,
|
|
resultImage: resultImage,
|
|
history: updatedHistory,
|
|
},
|
|
currentVersion
|
|
);
|
|
} else {
|
|
doGenerateCode(
|
|
{
|
|
generationType: "update",
|
|
image: referenceImages[0],
|
|
text: referenceText,
|
|
history: updatedHistory,
|
|
},
|
|
currentVersion
|
|
);
|
|
}
|
|
|
|
setGeneratedCode("");
|
|
setUpdateInstruction("");
|
|
}
|
|
|
|
async function doPartUpdate(partData?: any) {
|
|
const { uid, message } = partData;
|
|
const codeHtml = generatedCode;
|
|
updateHistoryCode(codeHtml)
|
|
const updatePrompt = `
|
|
Find the element with attribute data-uid="${uid}" and change it as described below:
|
|
${message}
|
|
Re-output the code and do not need to output the data-uid attribute.
|
|
`;
|
|
setPartValue(partData);
|
|
setUpdateInstruction(updatePrompt);
|
|
}
|
|
|
|
|
|
useEffect(() => {
|
|
const errorUpdate = updateInstruction.includes('Fix the code error and re-output the code');
|
|
const partUpdate = updateInstruction.includes('Re-enter the code.');
|
|
if (errorUpdate || partUpdate || updateInstruction) {
|
|
doUpdate();
|
|
}
|
|
}, [
|
|
updateInstruction
|
|
])
|
|
|
|
async function fixBug(error: {
|
|
message: string;
|
|
stack: string;
|
|
}) {
|
|
const errorPrompt = `
|
|
Fix the code error and re-output the code.
|
|
error message:
|
|
${error.message}
|
|
${error.stack}
|
|
`;
|
|
setUpdateInstruction(errorPrompt);
|
|
}
|
|
|
|
const copyCode = useCallback(() => {
|
|
copy(generatedCode);
|
|
toast.success("Copied to clipboard");
|
|
}, [generatedCode]);
|
|
|
|
|
|
return (
|
|
<div className="dark:bg-black dark:text-white h-full">
|
|
<div className="fixed inset-y-0 z-40 flex w-[200px] flex-col">
|
|
<div className="flex grow flex-col gap-y-2 overflow-y-auto border-r border-gray-200 bg-white px-4 py-4 dark:bg-zinc-950 dark:text-white">
|
|
{(appState === AppState.CODING ||
|
|
appState === AppState.CODE_READY) && (
|
|
<>
|
|
{appState === AppState.CODING && (
|
|
<div>
|
|
<CodePreview code={generatedCode} />
|
|
</div>
|
|
)}
|
|
|
|
|
|
<div>
|
|
<div className="grid w-full gap-2">
|
|
<div className="flex justify-between items-center gap-x-2">
|
|
<div className="font-500 text-xs text-slate-700 dark:text-white">
|
|
Include screenshot of current version?
|
|
</div>
|
|
<Switch
|
|
checked={shouldIncludeResultImage}
|
|
onCheckedChange={setShouldIncludeResultImage}
|
|
className="dark:bg-gray-700"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Reference image display */}
|
|
<div className="flex flex-col mt-2">
|
|
<div className="flex flex-col">
|
|
<div>
|
|
{referenceText ? (
|
|
<div className="border p-1 border-slate-200 w-full rounded bg-[#ebebeb]">
|
|
<p className="text-sm">
|
|
{referenceText}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
(referenceImages[0]) ? (
|
|
<img
|
|
className="w-[340px] border border-gray-200 rounded-md"
|
|
src={referenceImages[0]}
|
|
alt="Reference"
|
|
/>
|
|
) : <></>
|
|
)}
|
|
</div>
|
|
<div className="text-gray-400 uppercase text-sm text-center mt-1">
|
|
Original Info
|
|
</div>
|
|
</div>
|
|
<div className="bg-gray-400 px-4 py-2 rounded text-sm hidden">
|
|
<h2 className="text-lg mb-4 border-b border-gray-800">
|
|
Console
|
|
</h2>
|
|
{executionConsole.map((line, index) => (
|
|
<div
|
|
key={index}
|
|
className="border-b border-gray-400 mb-2 text-gray-600 font-mono"
|
|
>
|
|
{line}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
{
|
|
<HistoryDisplay
|
|
history={history}
|
|
currentVersion={currentVersion}
|
|
revertToVersion={(index) => {
|
|
if (
|
|
index < 0 ||
|
|
index >= history.length ||
|
|
!history[index]
|
|
)
|
|
return;
|
|
setCurrentVersion(index);
|
|
setGeneratedCode(history[index].code);
|
|
}}
|
|
shouldDisableReverts={appState === AppState.CODING}
|
|
/>
|
|
}
|
|
</div>
|
|
</div>
|
|
<main className="pl-[200px] relative h-full flex flex-col pb-10">
|
|
<div className="w-[90%] ml-[5%] flex-1 mt-4">
|
|
<div className="flex absolute gap-2">
|
|
<Button
|
|
onClick={() => {
|
|
reset();
|
|
router.push('/', { scroll: false })
|
|
}}
|
|
>
|
|
<FaChevronLeft />
|
|
Return</Button>
|
|
{appState === AppState.CODE_READY && (
|
|
<>
|
|
<span
|
|
onClick={copyCode}
|
|
className="hover:bg-slate-200 rounded-sm w-[36px] h-[36px] flex items-center justify-center border-black border-2"
|
|
>
|
|
<FaCopy />
|
|
</span>
|
|
<span
|
|
onClick={downloadCode}
|
|
className="hover:bg-slate-200 rounded-sm w-[36px] h-[36px] flex items-center justify-center border-black border-2"
|
|
>
|
|
<FaDownload />
|
|
</span>
|
|
</>
|
|
)}
|
|
|
|
{appState === AppState.CODING && (
|
|
<>
|
|
<span className="flex items-center gap-x-1">
|
|
<Spinner />
|
|
{executionConsole.slice(-1)[0]}
|
|
</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
<Tabs onValueChange={(e: any) => {
|
|
setTabValue(e)
|
|
}} className="h-full flex flex-col" defaultValue={'desktop'}>
|
|
<div className="flex justify-end mr-8 mb-4">
|
|
<TabsList>
|
|
{
|
|
<>
|
|
<TabsTrigger value="desktop" className="flex gap-x-2">
|
|
<FaDesktop /> Webpage
|
|
</TabsTrigger>
|
|
</>
|
|
}
|
|
<TabsTrigger value="code" className="flex gap-x-2">
|
|
<FaCode />
|
|
Code
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
</div>
|
|
|
|
<div
|
|
className={
|
|
classNames('h-full flex justify-center', {
|
|
'hidden': tabValue !== 'desktop'
|
|
})
|
|
}
|
|
>
|
|
<div
|
|
className={
|
|
classNames("h-full", {
|
|
"w-full": device === deviceType.PC,
|
|
"w-[375px]": device === deviceType.MOBILE
|
|
}
|
|
)}
|
|
>
|
|
<PreviewBox
|
|
code={generatedCode}
|
|
appState={appState}
|
|
sendMessageChange={(data) => {
|
|
doPartUpdate(data);
|
|
}}
|
|
generatedCodeConfig={settings.generatedCodeConfig}
|
|
history={history}
|
|
fixBug={fixBug}
|
|
/>
|
|
</div>
|
|
|
|
</div>
|
|
<div
|
|
className={
|
|
classNames('h-full', {
|
|
'hidden': tabValue !== 'code'
|
|
})
|
|
}
|
|
>
|
|
<CodeTab
|
|
code={generatedCode}
|
|
setCode={setGeneratedCode}
|
|
settings={settings}
|
|
/>
|
|
</div>
|
|
</Tabs>
|
|
</div>
|
|
<div className="flex justify-center mt-10">
|
|
<div className="w-[520px] rounded-md shadow-sm ">
|
|
<UpdateChatInput updateSendMessage={(message: string) => {
|
|
setUpdateInstruction(message);
|
|
setPartValue({
|
|
uid: '',
|
|
message: ''
|
|
});
|
|
}} />
|
|
</div>
|
|
</div>
|
|
</main>
|
|
<span className="hidden">
|
|
<SettingsDialog
|
|
settings={settings}
|
|
setSettings={setSettings}
|
|
openDialog={openDialog}
|
|
setOpenDialog={setOpenDialog}
|
|
/>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App; |