diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..d6e2638 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.analysis.typeCheckingMode": "strict" +} diff --git a/README.md b/README.md index e4739a4..bc82497 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # screenshot-to-code -This simple app converts a screenshot to code (HTML/Tailwind CSS, or React or Vue or Bootstrap). It uses GPT-4 Vision to generate the code and DALL-E 3 to generate similar-looking images. You can now also enter a URL to clone a live website! +This simple app converts a screenshot to code (HTML/Tailwind CSS, or React or Bootstrap or Vue). It uses GPT-4 Vision to generate the code and DALL-E 3 to generate similar-looking images. You can now also enter a URL to clone a live website! https://github.com/abi/screenshot-to-code/assets/23818/6cebadae-2fe3-4986-ac6a-8fb9db030045 @@ -12,6 +12,7 @@ See the [Examples](#-examples) section below for more demos. ## 🌟 Recent Updates +- Dec 11 - Start a new project from existing code (allows you to come back to an older project) - Dec 7 - 🔥 🔥 🔥 View a history of your edits, and branch off them - Nov 30 - Dark mode, output code in Ionic (thanks [@dialmedu](https://github.com/dialmedu)), set OpenAI base URL - Nov 28 - 🔥 🔥 🔥 Customize your stack: React or Bootstrap or TailwindCSS @@ -37,6 +38,12 @@ poetry shell poetry run uvicorn main:app --reload --port 7001 ``` +You can also run the backend (when you're in `backend`): + +```bash +poetry run pyright +``` + Run the frontend: ```bash @@ -57,7 +64,7 @@ MOCK=true poetry run uvicorn main:app --reload --port 7001 ## Configuration -* You can configure the OpenAI base URL if you need to use a proxy: Set OPENAI_BASE_URL in the `backend/.env` or directly in the UI in the settings dialog +- You can configure the OpenAI base URL if you need to use a proxy: Set OPENAI_BASE_URL in the `backend/.env` or directly in the UI in the settings dialog ## Docker diff --git a/backend/.gitignore b/backend/.gitignore index d9005f2..a42aad3 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -150,3 +150,7 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + + +# Temporary eval output +evals_data diff --git a/backend/README.md b/backend/README.md index 7c1cad2..155bf46 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,3 +1,7 @@ -Run tests +# Run the type checker -pytest test_prompts.py +poetry run pyright + +# Run tests + +poetry run pytest diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..7dd21f9 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,11 @@ +# Useful for debugging purposes when you don't want to waste GPT4-Vision credits +# Setting to True will stream a mock response instead of calling the OpenAI API +# TODO: Should only be set to true when value is 'True', not any abitrary truthy value +import os + + +SHOULD_MOCK_AI_RESPONSE = bool(os.environ.get("MOCK", False)) + +# Set to True when running in production (on the hosted version) +# Used as a feature flag to enable or disable certain features +IS_PROD = os.environ.get("IS_PROD", False) diff --git a/backend/evals/__init__.py b/backend/evals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/evals/config.py b/backend/evals/config.py new file mode 100644 index 0000000..7643027 --- /dev/null +++ b/backend/evals/config.py @@ -0,0 +1 @@ +EVALS_DIR = "./evals_data" diff --git a/backend/evals/core.py b/backend/evals/core.py new file mode 100644 index 0000000..61db1a3 --- /dev/null +++ b/backend/evals/core.py @@ -0,0 +1,29 @@ +import os + +from llm import stream_openai_response +from prompts import assemble_prompt +from prompts.types import Stack +from utils import pprint_prompt + + +async def generate_code_core(image_url: str, stack: Stack) -> str: + prompt_messages = assemble_prompt(image_url, stack) + openai_api_key = os.environ.get("OPENAI_API_KEY") + openai_base_url = None + + pprint_prompt(prompt_messages) + + async def process_chunk(content: str): + pass + + if not openai_api_key: + raise Exception("OpenAI API key not found") + + completion = await stream_openai_response( + prompt_messages, + api_key=openai_api_key, + base_url=openai_base_url, + callback=lambda x: process_chunk(x), + ) + + return completion diff --git a/backend/evals/utils.py b/backend/evals/utils.py new file mode 100644 index 0000000..6a52f88 --- /dev/null +++ b/backend/evals/utils.py @@ -0,0 +1,7 @@ +import base64 + + +async def image_to_data_url(filepath: str): + with open(filepath, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode() + return f"data:image/png;base64,{encoded_string}" diff --git a/backend/image_generation.py b/backend/image_generation.py index bb272f8..d3e71b1 100644 --- a/backend/image_generation.py +++ b/backend/image_generation.py @@ -1,15 +1,15 @@ import asyncio -import os import re +from typing import Dict, List, Union from openai import AsyncOpenAI from bs4 import BeautifulSoup -async def process_tasks(prompts, api_key, base_url): +async def process_tasks(prompts: List[str], api_key: str, base_url: str): tasks = [generate_image(prompt, api_key, base_url) for prompt in prompts] results = await asyncio.gather(*tasks, return_exceptions=True) - processed_results = [] + processed_results: List[Union[str, None]] = [] for result in results: if isinstance(result, Exception): print(f"An exception occurred: {result}") @@ -20,9 +20,9 @@ async def process_tasks(prompts, api_key, base_url): return processed_results -async def generate_image(prompt, api_key, base_url): +async def generate_image(prompt: str, api_key: str, base_url: str): client = AsyncOpenAI(api_key=api_key, base_url=base_url) - image_params = { + image_params: Dict[str, Union[str, int]] = { "model": "dall-e-3", "quality": "standard", "style": "natural", @@ -35,7 +35,7 @@ async def generate_image(prompt, api_key, base_url): return res.data[0].url -def extract_dimensions(url): +def extract_dimensions(url: str): # Regular expression to match numbers in the format '300x200' matches = re.findall(r"(\d+)x(\d+)", url) @@ -48,11 +48,11 @@ def extract_dimensions(url): return (100, 100) -def create_alt_url_mapping(code): +def create_alt_url_mapping(code: str) -> Dict[str, str]: soup = BeautifulSoup(code, "html.parser") images = soup.find_all("img") - mapping = {} + mapping: Dict[str, str] = {} for image in images: if not image["src"].startswith("https://placehold.co"): @@ -61,7 +61,9 @@ def create_alt_url_mapping(code): return mapping -async def generate_images(code, api_key, base_url, image_cache): +async def generate_images( + code: str, api_key: str, base_url: Union[str, None], image_cache: Dict[str, str] +): # Find all images soup = BeautifulSoup(code, "html.parser") images = soup.find_all("img") diff --git a/backend/llm.py b/backend/llm.py index e2b41c4..66e3a47 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -1,16 +1,16 @@ -import os -from typing import Awaitable, Callable +from typing import Awaitable, Callable, List from openai import AsyncOpenAI +from openai.types.chat import ChatCompletionMessageParam, ChatCompletionChunk MODEL_GPT_4_VISION = "gpt-4-vision-preview" async def stream_openai_response( - messages, + messages: List[ChatCompletionMessageParam], api_key: str, base_url: str | None, callback: Callable[[str], Awaitable[None]], -): +) -> str: client = AsyncOpenAI(api_key=api_key, base_url=base_url) model = MODEL_GPT_4_VISION @@ -23,9 +23,10 @@ async def stream_openai_response( params["max_tokens"] = 4096 params["temperature"] = 0 - completion = await client.chat.completions.create(**params) + stream = await client.chat.completions.create(**params) # type: ignore full_response = "" - async for chunk in completion: + async for chunk in stream: # type: ignore + assert isinstance(chunk, ChatCompletionChunk) content = chunk.choices[0].delta.content or "" full_response += content await callback(content) diff --git a/backend/main.py b/backend/main.py index fe80dba..7a1797f 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,26 +1,12 @@ # Load environment variables first from dotenv import load_dotenv - load_dotenv() -import json -import os -import traceback -from datetime import datetime -from fastapi import FastAPI, WebSocket -from fastapi.staticfiles import StaticFiles +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, HTMLResponse -import openai -from llm import stream_openai_response -from mock import mock_completion -from utils import pprint_prompt -from image_generation import create_alt_url_mapping, generate_images -from prompts import assemble_prompt -from routes import screenshot -from access_token import validate_access_token +from routes import screenshot, generate_code, home, evals app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None) @@ -33,247 +19,8 @@ app.add_middleware( allow_headers=["*"], ) - -# Useful for debugging purposes when you don't want to waste GPT4-Vision credits -# Setting to True will stream a mock response instead of calling the OpenAI API -# TODO: Should only be set to true when value is 'True', not any abitrary truthy value -SHOULD_MOCK_AI_RESPONSE = bool(os.environ.get("MOCK", False)) - -# Set to True when running in production (on the hosted version) -# Used as a feature flag to enable or disable certain features -IS_PROD = os.environ.get("IS_PROD", False) - -# if webui folder exists, we are in build mode -IS_BUILD = os.path.exists("webui") - - +# Add routes +app.include_router(generate_code.router) app.include_router(screenshot.router) - - -@app.get("/") -async def get_status(): - if IS_BUILD: - # if in build mode, return webui/index.html - return FileResponse("webui/index.html") - - return HTMLResponse( - content="