├── .gitattributes
├── .gitignore
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── Troubleshooting.md
├── backend
├── .gitignore
├── .pre-commit-config.yaml
├── Dockerfile
├── README.md
├── access_token.py
├── build.sh
├── config.py
├── evals
│ ├── __init__.py
│ ├── config.py
│ ├── core.py
│ └── utils.py
├── image_generation.py
├── llm.py
├── main.py
├── mock_llm.py
├── poetry.lock
├── prompts
│ ├── __init__.py
│ ├── imported_code_prompts.py
│ ├── screenshot_system_prompts.py
│ ├── test_prompts.py
│ └── types.py
├── pyproject.toml
├── pyrightconfig.json
├── routes
│ ├── evals.py
│ ├── generate_code.py
│ ├── home.py
│ └── screenshot.py
├── run_evals.py
├── start.py
└── utils.py
├── design-docs.md
├── docker-compose.yml
├── frontend
├── .env.example
├── .eslintrc.cjs
├── .gitignore
├── Dockerfile
├── components.json
├── index.html
├── package.json
├── postcss.config.js
├── public
│ └── brand
│ │ └── twitter-summary-card.png
├── src
│ ├── App.tsx
│ ├── components
│ │ ├── CodeMirror.tsx
│ │ ├── CodePreview.tsx
│ │ ├── CodeTab.tsx
│ │ ├── ImageUpload.tsx
│ │ ├── ImportCodeSection.tsx
│ │ ├── OnboardingNote.tsx
│ │ ├── OutputSettingsSection.tsx
│ │ ├── PicoBadge.tsx
│ │ ├── Preview.tsx
│ │ ├── SettingsDialog.tsx
│ │ ├── Spinner.tsx
│ │ ├── TermsOfServiceDialog.tsx
│ │ ├── UrlInputSection.tsx
│ │ ├── evals
│ │ │ ├── EvalsPage.tsx
│ │ │ └── RatingPicker.tsx
│ │ ├── history
│ │ │ ├── HistoryDisplay.tsx
│ │ │ ├── history_types.ts
│ │ │ ├── utils.test.ts
│ │ │ └── utils.ts
│ │ ├── settings
│ │ │ └── AccessCodeSection.tsx
│ │ └── ui
│ │ │ ├── accordion.tsx
│ │ │ ├── alert-dialog.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── button.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── collapsible.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── hover-card.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── popover.tsx
│ │ │ ├── progress.tsx
│ │ │ ├── scroll-area.tsx
│ │ │ ├── select.tsx
│ │ │ ├── separator.tsx
│ │ │ ├── switch.tsx
│ │ │ ├── tabs.tsx
│ │ │ └── textarea.tsx
│ ├── config.ts
│ ├── constants.ts
│ ├── generateCode.ts
│ ├── hooks
│ │ ├── usePersistedState.ts
│ │ └── useThrottle.ts
│ ├── index.css
│ ├── lib
│ │ ├── stacks.ts
│ │ └── utils.ts
│ ├── main.tsx
│ ├── types.ts
│ └── vite-env.d.ts
├── tailwind.config.js
├── tsconfig.json
├── tsconfig.node.json
├── vite.config.ts
└── yarn.lock
└── sweep.yaml
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .aider*
2 |
3 | # Project-related files
4 |
5 | # Run logs
6 | backend/run_logs/*
7 |
8 | # Weird Docker setup related files
9 | backend/backend/*
10 |
11 | # Env vars
12 | frontend/.env.local
13 | .env
14 |
15 | # Mac files
16 | .DS_Store
17 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "python.analysis.typeCheckingMode": "strict"
3 | }
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Abi Raja
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # screenshot-to-code
2 |
3 | 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!
4 |
5 | https://github.com/abi/screenshot-to-code/assets/23818/6cebadae-2fe3-4986-ac6a-8fb9db030045
6 |
7 | See the [Examples](#-examples) section below for more demos.
8 |
9 | ## 🚀 Try It Out!
10 |
11 | 🆕 [Try it here](https://screenshottocode.com) (bring your own OpenAI key - **your key must have access to GPT-4 Vision. See [FAQ](#%EF%B8%8F-faqs) section below for details**). Or see [Getting Started](#-getting-started) below for local install instructions.
12 |
13 |
14 | ## 🛠 Getting Started
15 |
16 | The app has a React/Vite frontend and a FastAPI backend. You will need an OpenAI API key with access to the GPT-4 Vision API.
17 |
18 | Run the backend (I use Poetry for package management - `pip install poetry` if you don't have it):
19 |
20 | ```bash
21 | cd backend
22 | echo "OPENAI_API_KEY=sk-your-key" > .env
23 | poetry install
24 | poetry shell
25 | poetry run uvicorn main:app --reload --port 7001
26 | ```
27 |
28 | Run the frontend:
29 |
30 | ```bash
31 | cd frontend
32 | yarn
33 | yarn dev
34 | ```
35 |
36 | Open http://localhost:5173 to use the app.
37 |
38 | If you prefer to run the backend on a different port, update VITE_WS_BACKEND_URL in `frontend/.env.local`
39 |
40 | For debugging purposes, if you don't want to waste GPT4-Vision credits, you can run the backend in mock mode (which streams a pre-recorded response):
41 |
42 | ```bash
43 | MOCK=true poetry run uvicorn main:app --reload --port 7001
44 | ```
45 |
46 | ## Configuration
47 |
48 | - 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
49 |
50 | ## Docker
51 |
52 | If you have Docker installed on your system, in the root directory, run:
53 |
54 | ```bash
55 | echo "OPENAI_API_KEY=sk-your-key" > .env
56 | docker-compose up -d --build
57 | ```
58 |
59 | The app will be up and running at http://localhost:5173. Note that you can't develop the application with this setup as the file changes won't trigger a rebuild.
60 |
61 | ## 🙋♂️ FAQs
62 |
63 | - **I'm running into an error when setting up the backend. How can I fix it?** [Try this](https://github.com/abi/screenshot-to-code/issues/3#issuecomment-1814777959). If that still doesn't work, open an issue.
64 | - **How do I get an OpenAI API key?** See https://github.com/abi/screenshot-to-code/blob/main/Troubleshooting.md
65 | - **How can I provide feedback?** For feedback, feature requests and bug reports, open an issue or ping me on [Twitter](https://twitter.com/_abi_).
66 |
67 | ## 📚 Examples
68 |
69 | **NYTimes**
70 |
71 | | Original | Replica |
72 | | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
73 | | | |
74 |
75 | **Instagram page (with not Taylor Swift pics)**
76 |
77 | https://github.com/abi/screenshot-to-code/assets/23818/503eb86a-356e-4dfc-926a-dabdb1ac7ba1
78 |
79 | **Hacker News** but it gets the colors wrong at first so we nudge it
80 |
81 | https://github.com/abi/screenshot-to-code/assets/23818/3fec0f77-44e8-4fb3-a769-ac7410315e5d
82 |
83 | ## 🌍 Hosted Version
84 |
85 | 🆕 [Try it here](https://screenshottocode.com) (bring your own OpenAI key - **your key must have access to GPT-4 Vision. See [FAQ](#%EF%B8%8F-faqs) section for details**). Or see [Getting Started](#-getting-started) for local install instructions.
86 |
87 | [](https://www.buymeacoffee.com/abiraja)
88 |
--------------------------------------------------------------------------------
/Troubleshooting.md:
--------------------------------------------------------------------------------
1 | ### Getting an OpenAI API key with GPT4-Vision model access
2 |
3 | You don't need a ChatGPT Pro account. Screenshot to code uses API keys from your OpenAI developer account. In order to get access to the GPT4 Vision model, log into your OpenAI account and then, follow these instructions:
4 |
5 | 1. Open [OpenAI Dashboard](https://platform.openai.com/)
6 | 1. Go to Settings > Billing
7 | 1. Click at the Add payment details
8 |
9 | 4. You have to buy some credits. The minimum is $5.
10 |
11 | 5. Go to Settings > Limits and check at the bottom of the page, your current tier has to be "Tier 1" to have GPT4 access
12 |
13 | 6. Go to Screenshot to code and paste it in the Settings dialog under OpenAI key (gear icon). Your key is only stored in your browser. Never stored on our servers.
14 |
15 | Some users have also reported that it can take upto 30 minutes after your credit purchase for the GPT4 vision model to be activated.
16 |
17 | If you've followed these steps, and it still doesn't work, feel free to open a Github issue.
18 |
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | build/
12 | develop-eggs/
13 | dist/
14 | downloads/
15 | eggs/
16 | .eggs/
17 | lib/
18 | lib64/
19 | parts/
20 | sdist/
21 | var/
22 | wheels/
23 | share/python-wheels/
24 | *.egg-info/
25 | .installed.cfg
26 | *.egg
27 | MANIFEST
28 |
29 | # PyInstaller
30 | # Usually these files are written by a python script from a template
31 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
32 | *.manifest
33 | *.spec
34 |
35 | # Installer logs
36 | pip-log.txt
37 | pip-delete-this-directory.txt
38 |
39 | # Unit test / coverage reports
40 | htmlcov/
41 | .tox/
42 | .nox/
43 | .coverage
44 | .coverage.*
45 | .cache
46 | nosetests.xml
47 | coverage.xml
48 | *.cover
49 | *.py,cover
50 | .hypothesis/
51 | .pytest_cache/
52 | cover/
53 |
54 | # Translations
55 | *.mo
56 | *.pot
57 |
58 | # Django stuff:
59 | *.log
60 | local_settings.py
61 | db.sqlite3
62 | db.sqlite3-journal
63 |
64 | # Flask stuff:
65 | instance/
66 | .webassets-cache
67 |
68 | # Scrapy stuff:
69 | .scrapy
70 |
71 | # Sphinx documentation
72 | docs/_build/
73 |
74 | # PyBuilder
75 | .pybuilder/
76 | target/
77 |
78 | # Jupyter Notebook
79 | .ipynb_checkpoints
80 |
81 | # IPython
82 | profile_default/
83 | ipython_config.py
84 |
85 | # pyenv
86 | # For a library or package, you might want to ignore these files since the code is
87 | # intended to run in multiple environments; otherwise, check them in:
88 | # .python-version
89 |
90 | # pipenv
91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies
93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not
94 | # install all needed dependencies.
95 | #Pipfile.lock
96 |
97 | # poetry
98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
99 | # This is especially recommended for binary packages to ensure reproducibility, and is more
100 | # commonly ignored for libraries.
101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
102 | #poetry.lock
103 |
104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
105 | __pypackages__/
106 |
107 | # Celery stuff
108 | celerybeat-schedule
109 | celerybeat.pid
110 |
111 | # SageMath parsed files
112 | *.sage.py
113 |
114 | # Environments
115 | .env
116 | .venv
117 | env/
118 | venv/
119 | ENV/
120 | env.bak/
121 | venv.bak/
122 |
123 | # Spyder project settings
124 | .spyderproject
125 | .spyproject
126 |
127 | # Rope project settings
128 | .ropeproject
129 |
130 | # mkdocs documentation
131 | /site
132 |
133 | # mypy
134 | .mypy_cache/
135 | .dmypy.json
136 | dmypy.json
137 |
138 | # Pyre type checker
139 | .pyre/
140 |
141 | # pytype static type analyzer
142 | .pytype/
143 |
144 | # Cython debug symbols
145 | cython_debug/
146 |
147 | # PyCharm
148 | # JetBrains specific template is maintainted in a separate JetBrains.gitignore that can
149 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
150 | # and can be added to the global gitignore or merged into this file. For a more nuclear
151 | # option (not recommended) you can uncomment the following to ignore the entire idea folder.
152 | #.idea/
153 |
154 |
155 | # Temporary eval output
156 | evals_data
157 |
--------------------------------------------------------------------------------
/backend/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | # See https://pre-commit.com for more information
2 | # See https://pre-commit.com/hooks.html for more hooks
3 | repos:
4 | - repo: https://github.com/pre-commit/pre-commit-hooks
5 | rev: v3.2.0
6 | hooks:
7 | - id: end-of-file-fixer
8 | - id: check-yaml
9 | - id: check-added-large-files
10 | - repo: local
11 | hooks:
12 | - id: poetry-pytest
13 | name: Run pytest with Poetry
14 | entry: poetry run --directory backend pytest
15 | language: system
16 | pass_filenames: false
17 | always_run: true
18 | files: ^backend/
19 |
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.12-slim-bullseye
2 |
3 | ENV POETRY_VERSION 1.4.1
4 |
5 | # Install system dependencies
6 | RUN pip install "poetry==$POETRY_VERSION"
7 |
8 | # Set work directory
9 | WORKDIR /app
10 |
11 | # Copy only requirements to cache them in docker layer
12 | COPY poetry.lock pyproject.toml /app/
13 |
14 | # Disable the creation of virtual environments
15 | RUN poetry config virtualenvs.create false
16 |
17 | # Install dependencies
18 | RUN poetry install
19 |
20 | # Copy the current directory contents into the container at /app
21 | COPY ./ /app/
22 |
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
1 | # Run the type checker
2 |
3 | poetry run pyright
4 |
5 | # Run tests
6 |
7 | poetry run pytest
8 |
--------------------------------------------------------------------------------
/backend/access_token.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import httpx
4 |
5 |
6 | async def validate_access_token(access_code: str):
7 | async with httpx.AsyncClient() as client:
8 | url = (
9 | "https://backend.buildpicoapps.com/screenshot_to_code/validate_access_token"
10 | )
11 | data = json.dumps(
12 | {
13 | "access_code": access_code,
14 | "secret": os.environ.get("PICO_BACKEND_SECRET"),
15 | }
16 | )
17 | headers = {"Content-Type": "application/json"}
18 |
19 | response = await client.post(url, content=data, headers=headers)
20 | response_data = response.json()
21 | return response_data
22 |
--------------------------------------------------------------------------------
/backend/build.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | # exit on error
3 | set -o errexit
4 |
5 | echo "Installing the latest version of poetry..."
6 | pip install --upgrade pip
7 | pip install poetry==1.4.1
8 |
9 | rm poetry.lock
10 | poetry lock
11 | python -m poetry install
12 |
--------------------------------------------------------------------------------
/backend/config.py:
--------------------------------------------------------------------------------
1 | # Useful for debugging purposes when you don't want to waste GPT4-Vision credits
2 | # Setting to True will stream a mock response instead of calling the OpenAI API
3 | # TODO: Should only be set to true when value is 'True', not any abitrary truthy value
4 | import os
5 |
6 |
7 | SHOULD_MOCK_AI_RESPONSE = bool(os.environ.get("MOCK", False))
8 |
9 | # Set to True when running in production (on the hosted version)
10 | # Used as a feature flag to enable or disable certain features
11 | IS_PROD = os.environ.get("IS_PROD", False)
12 |
--------------------------------------------------------------------------------
/backend/evals/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hacksider/screenshot-to-code/1bc26b616fc6ec9c8fd749a002fe256a72f272f9/backend/evals/__init__.py
--------------------------------------------------------------------------------
/backend/evals/config.py:
--------------------------------------------------------------------------------
1 | EVALS_DIR = "./evals_data"
2 |
--------------------------------------------------------------------------------
/backend/evals/core.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from llm import stream_openai_response
4 | from prompts import assemble_prompt
5 | from prompts.types import Stack
6 |
7 |
8 | async def generate_code_core(image_url: str, stack: Stack) -> str:
9 | prompt_messages = assemble_prompt(image_url, stack)
10 | openai_api_key = os.environ.get("OPENAI_API_KEY")
11 | openai_base_url = None
12 |
13 | async def process_chunk(content: str):
14 | pass
15 |
16 | if not openai_api_key:
17 | raise Exception("OpenAI API key not found")
18 |
19 | completion = await stream_openai_response(
20 | prompt_messages,
21 | api_key=openai_api_key,
22 | base_url=openai_base_url,
23 | callback=lambda x: process_chunk(x),
24 | )
25 |
26 | return completion
27 |
--------------------------------------------------------------------------------
/backend/evals/utils.py:
--------------------------------------------------------------------------------
1 | import base64
2 |
3 |
4 | async def image_to_data_url(filepath: str):
5 | with open(filepath, "rb") as image_file:
6 | encoded_string = base64.b64encode(image_file.read()).decode()
7 | return f"data:image/png;base64,{encoded_string}"
8 |
--------------------------------------------------------------------------------
/backend/image_generation.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import re
3 | from typing import Dict, List, Union
4 | from openai import AsyncOpenAI
5 | from bs4 import BeautifulSoup
6 |
7 |
8 | async def process_tasks(prompts: List[str], api_key: str, base_url: str):
9 | tasks = [generate_image(prompt, api_key, base_url) for prompt in prompts]
10 | results = await asyncio.gather(*tasks, return_exceptions=True)
11 |
12 | processed_results: List[Union[str, None]] = []
13 | for result in results:
14 | if isinstance(result, Exception):
15 | print(f"An exception occurred: {result}")
16 | processed_results.append(None)
17 | else:
18 | processed_results.append(result)
19 |
20 | return processed_results
21 |
22 |
23 | async def generate_image(prompt: str, api_key: str, base_url: str):
24 | client = AsyncOpenAI(api_key=api_key, base_url=base_url)
25 | image_params: Dict[str, Union[str, int]] = {
26 | "model": "dall-e-3",
27 | "quality": "standard",
28 | "style": "natural",
29 | "n": 1,
30 | "size": "1024x1024",
31 | "prompt": prompt,
32 | }
33 | res = await client.images.generate(**image_params)
34 | await client.close()
35 | return res.data[0].url
36 |
37 |
38 | def extract_dimensions(url: str):
39 | # Regular expression to match numbers in the format '300x200'
40 | matches = re.findall(r"(\d+)x(\d+)", url)
41 |
42 | if matches:
43 | width, height = matches[0] # Extract the first match
44 | width = int(width)
45 | height = int(height)
46 | return (width, height)
47 | else:
48 | return (100, 100)
49 |
50 |
51 | def create_alt_url_mapping(code: str) -> Dict[str, str]:
52 | soup = BeautifulSoup(code, "html.parser")
53 | images = soup.find_all("img")
54 |
55 | mapping: Dict[str, str] = {}
56 |
57 | for image in images:
58 | if not image["src"].startswith("https://placehold.co"):
59 | mapping[image["alt"]] = image["src"]
60 |
61 | return mapping
62 |
63 |
64 | async def generate_images(
65 | code: str, api_key: str, base_url: Union[str, None], image_cache: Dict[str, str]
66 | ):
67 | # Find all images
68 | soup = BeautifulSoup(code, "html.parser")
69 | images = soup.find_all("img")
70 |
71 | # Extract alt texts as image prompts
72 | alts = []
73 | for img in images:
74 | # Only include URL if the image starts with https://placehold.co
75 | # and it's not already in the image_cache
76 | if (
77 | img["src"].startswith("https://placehold.co")
78 | and image_cache.get(img.get("alt")) is None
79 | ):
80 | alts.append(img.get("alt", None))
81 |
82 | # Exclude images with no alt text
83 | alts = [alt for alt in alts if alt is not None]
84 |
85 | # Remove duplicates
86 | prompts = list(set(alts))
87 |
88 | # Return early if there are no images to replace
89 | if len(prompts) == 0:
90 | return code
91 |
92 | # Generate images
93 | results = await process_tasks(prompts, api_key, base_url)
94 |
95 | # Create a dict mapping alt text to image URL
96 | mapped_image_urls = dict(zip(prompts, results))
97 |
98 | # Merge with image_cache
99 | mapped_image_urls = {**mapped_image_urls, **image_cache}
100 |
101 | # Replace old image URLs with the generated URLs
102 | for img in images:
103 | # Skip images that don't start with https://placehold.co (leave them alone)
104 | if not img["src"].startswith("https://placehold.co"):
105 | continue
106 |
107 | new_url = mapped_image_urls[img.get("alt")]
108 |
109 | if new_url:
110 | # Set width and height attributes
111 | width, height = extract_dimensions(img["src"])
112 | img["width"] = width
113 | img["height"] = height
114 | # Replace img['src'] with the mapped image URL
115 | img["src"] = new_url
116 | else:
117 | print("Image generation failed for alt text:" + img.get("alt"))
118 |
119 | # Return the modified HTML
120 | # (need to prettify it because BeautifulSoup messes up the formatting)
121 | return soup.prettify()
122 |
--------------------------------------------------------------------------------
/backend/llm.py:
--------------------------------------------------------------------------------
1 | from typing import Awaitable, Callable, List
2 | from openai import AsyncOpenAI
3 | from openai.types.chat import ChatCompletionMessageParam, ChatCompletionChunk
4 |
5 | MODEL_GPT_4_VISION = "gpt-4-vision-preview"
6 |
7 |
8 | async def stream_openai_response(
9 | messages: List[ChatCompletionMessageParam],
10 | api_key: str,
11 | base_url: str | None,
12 | callback: Callable[[str], Awaitable[None]],
13 | ) -> str:
14 | client = AsyncOpenAI(api_key=api_key, base_url=base_url)
15 |
16 | model = MODEL_GPT_4_VISION
17 |
18 | # Base parameters
19 | params = {"model": model, "messages": messages, "stream": True, "timeout": 600}
20 |
21 | # Add 'max_tokens' only if the model is a GPT4 vision model
22 | if model == MODEL_GPT_4_VISION:
23 | params["max_tokens"] = 4096
24 | params["temperature"] = 0
25 |
26 | stream = await client.chat.completions.create(**params) # type: ignore
27 | full_response = ""
28 | async for chunk in stream: # type: ignore
29 | assert isinstance(chunk, ChatCompletionChunk)
30 | content = chunk.choices[0].delta.content or ""
31 | full_response += content
32 | await callback(content)
33 |
34 | await client.close()
35 |
36 | return full_response
37 |
--------------------------------------------------------------------------------
/backend/main.py:
--------------------------------------------------------------------------------
1 | # Load environment variables first
2 | from dotenv import load_dotenv
3 |
4 | load_dotenv()
5 |
6 |
7 | from fastapi import FastAPI
8 | from fastapi.middleware.cors import CORSMiddleware
9 | from routes import screenshot, generate_code, home, evals
10 |
11 | app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None)
12 |
13 | # Configure CORS settings
14 | app.add_middleware(
15 | CORSMiddleware,
16 | allow_origins=["*"],
17 | allow_credentials=True,
18 | allow_methods=["*"],
19 | allow_headers=["*"],
20 | )
21 |
22 | # Add routes
23 | app.include_router(generate_code.router)
24 | app.include_router(screenshot.router)
25 | app.include_router(home.router)
26 | app.include_router(evals.router)
27 |
--------------------------------------------------------------------------------
/backend/mock_llm.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from typing import Awaitable, Callable
3 |
4 |
5 | async def mock_completion(process_chunk: Callable[[str], Awaitable[None]]) -> str:
6 | code_to_return = NO_IMAGES_NYTIMES_MOCK_CODE
7 |
8 | for i in range(0, len(code_to_return), 10):
9 | await process_chunk(code_to_return[i : i + 10])
10 | await asyncio.sleep(0.01)
11 |
12 | return code_to_return
13 |
14 |
15 | APPLE_MOCK_CODE = """
16 |
17 |
18 |
19 | Product Showcase
20 |
21 |
22 |
27 |
28 |
29 |
30 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
WATCH SERIES 9
57 |
Smarter. Brighter. Mightier.
58 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | """
71 |
72 | NYTIMES_MOCK_CODE = """
73 |
74 |
75 |
76 |
77 | The New York Times - News
78 |
79 |
80 |
81 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
Tuesday, November 14, 2023 Today's Paper
95 |
96 |
97 |
98 |
99 |
100 |
Give the times
101 |
Account
102 |
103 |
104 |
105 |
109 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 | Israeli Military Raids Gaza’s Largest Hospital
121 | Israeli troops have entered the Al-Shifa Hospital complex, where conditions have grown dire and Israel says Hamas fighters are embedded.
122 | See more updates
123 |
124 |
125 |
126 |
127 |
128 |
129 | From Elvis to Elopements, the Evolution of the Las Vegas Wedding
130 | The glittering city that attracts thousands of couples seeking unconventional nuptials has grown beyond the drive-through wedding.
131 | 8 MIN READ
132 |
133 |
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 | """
142 |
143 | NO_IMAGES_NYTIMES_MOCK_CODE = """
144 |
145 |
146 |
147 |
148 | The New York Times - News
149 |
150 |
151 |
152 |
157 |
158 |
159 |
160 |
161 |
162 |
163 |
164 |
165 |
Tuesday, November 14, 2023 Today's Paper
166 |
167 |
168 |
Give the times
169 |
Account
170 |
171 |
172 |
173 |
177 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
188 | Israeli Military Raids Gaza’s Largest Hospital
189 | Israeli troops have entered the Al-Shifa Hospital complex, where conditions have grown dire and Israel says Hamas fighters are embedded.
190 | See more updates
191 |
192 |
193 |
194 |
195 |
196 | From Elvis to Elopements, the Evolution of the Las Vegas Wedding
197 | The glittering city that attracts thousands of couples seeking unconventional nuptials has grown beyond the drive-through wedding.
198 | 8 MIN READ
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 |
207 |
208 | """
209 |
--------------------------------------------------------------------------------
/backend/prompts/__init__.py:
--------------------------------------------------------------------------------
1 | from typing import List, NoReturn, Union
2 |
3 | from openai.types.chat import ChatCompletionMessageParam, ChatCompletionContentPartParam
4 |
5 | from prompts.imported_code_prompts import IMPORTED_CODE_SYSTEM_PROMPTS
6 | from prompts.screenshot_system_prompts import SYSTEM_PROMPTS
7 | from prompts.types import Stack
8 |
9 |
10 | USER_PROMPT = """
11 | Generate code for a web page that looks exactly like this.
12 | """
13 |
14 | SVG_USER_PROMPT = """
15 | Generate code for a SVG that looks exactly like this.
16 | """
17 |
18 |
19 | def assemble_imported_code_prompt(
20 | code: str, stack: Stack, result_image_data_url: Union[str, None] = None
21 | ) -> List[ChatCompletionMessageParam]:
22 | system_content = IMPORTED_CODE_SYSTEM_PROMPTS[stack]
23 |
24 | user_content = (
25 | "Here is the code of the app: " + code
26 | if stack != "svg"
27 | else "Here is the code of the SVG: " + code
28 | )
29 | return [
30 | {
31 | "role": "system",
32 | "content": system_content,
33 | },
34 | {
35 | "role": "user",
36 | "content": user_content,
37 | },
38 | ]
39 | # TODO: Use result_image_data_url
40 |
41 |
42 | def assemble_prompt(
43 | image_data_url: str,
44 | stack: Stack,
45 | result_image_data_url: Union[str, None] = None,
46 | ) -> List[ChatCompletionMessageParam]:
47 | system_content = SYSTEM_PROMPTS[stack]
48 | user_prompt = USER_PROMPT if stack != "svg" else SVG_USER_PROMPT
49 |
50 | user_content: List[ChatCompletionContentPartParam] = [
51 | {
52 | "type": "image_url",
53 | "image_url": {"url": image_data_url, "detail": "high"},
54 | },
55 | {
56 | "type": "text",
57 | "text": user_prompt,
58 | },
59 | ]
60 |
61 | # Include the result image if it exists
62 | if result_image_data_url:
63 | user_content.insert(
64 | 1,
65 | {
66 | "type": "image_url",
67 | "image_url": {"url": result_image_data_url, "detail": "high"},
68 | },
69 | )
70 | return [
71 | {
72 | "role": "system",
73 | "content": system_content,
74 | },
75 | {
76 | "role": "user",
77 | "content": user_content,
78 | },
79 | ]
80 |
--------------------------------------------------------------------------------
/backend/prompts/imported_code_prompts.py:
--------------------------------------------------------------------------------
1 | from prompts.types import SystemPrompts
2 |
3 |
4 | IMPORTED_CODE_TAILWIND_SYSTEM_PROMPT = """
5 | You are an expert Tailwind developer.
6 |
7 | - Do not add comments in the code such as "" and "" in place of writing the full code. WRITE THE FULL CODE.
8 | - Repeat elements as needed. For example, if there are 15 items, the code should have 15 items. DO NOT LEAVE comments like "" or bad things will happen.
9 | - For images, use placeholder images from https://placehold.co and include a detailed description of the image in the alt text so that an image generation AI can generate the image later.
10 |
11 | In terms of libraries,
12 |
13 | - Use this script to include Tailwind:
14 | - You can use Google Fonts
15 | - Font Awesome for icons:
16 |
17 | Return only the full code in tags.
18 | Do not include markdown "```" or "```html" at the start or end.
19 | """
20 |
21 | IMPORTED_CODE_REACT_TAILWIND_SYSTEM_PROMPT = """
22 | You are an expert React/Tailwind developer
23 |
24 | - Do not add comments in the code such as "" and "" in place of writing the full code. WRITE THE FULL CODE.
25 | - Repeat elements as needed. For example, if there are 15 items, the code should have 15 items. DO NOT LEAVE comments like "" or bad things will happen.
26 | - For images, use placeholder images from https://placehold.co and include a detailed description of the image in the alt text so that an image generation AI can generate the image later.
27 |
28 | In terms of libraries,
29 |
30 | - Use these script to include React so that it can run on a standalone page:
31 |
32 |
33 |
34 | - Use this script to include Tailwind:
35 | - You can use Google Fonts
36 | - Font Awesome for icons:
37 |
38 | Return only the full code in tags.
39 | Do not include markdown "```" or "```html" at the start or end.
40 | """
41 |
42 | IMPORTED_CODE_BOOTSTRAP_SYSTEM_PROMPT = """
43 | You are an expert Bootstrap developer.
44 |
45 | - Do not add comments in the code such as "" and "" in place of writing the full code. WRITE THE FULL CODE.
46 | - Repeat elements as needed. For example, if there are 15 items, the code should have 15 items. DO NOT LEAVE comments like "" or bad things will happen.
47 | - For images, use placeholder images from https://placehold.co and include a detailed description of the image in the alt text so that an image generation AI can generate the image later.
48 |
49 | In terms of libraries,
50 |
51 | - Use this script to include Bootstrap:
52 | - You can use Google Fonts
53 | - Font Awesome for icons:
54 |
55 | Return only the full code in tags.
56 | Do not include markdown "```" or "```html" at the start or end.
57 | """
58 |
59 | IMPORTED_CODE_IONIC_TAILWIND_SYSTEM_PROMPT = """
60 | You are an expert Ionic/Tailwind developer.
61 |
62 | - Do not add comments in the code such as "" and "" in place of writing the full code. WRITE THE FULL CODE.
63 | - Repeat elements as needed. For example, if there are 15 items, the code should have 15 items. DO NOT LEAVE comments like "" or bad things will happen.
64 | - For images, use placeholder images from https://placehold.co and include a detailed description of the image in the alt text so that an image generation AI can generate the image later.
65 |
66 | In terms of libraries,
67 |
68 | - Use these script to include Ionic so that it can run on a standalone page:
69 |
70 |
71 |
72 | - Use this script to include Tailwind:
73 | - You can use Google Fonts
74 | - ionicons for icons, add the following
78 |
79 |
80 |
81 | Return only the full code in tags.
82 | Do not include markdown "```" or "```html" at the start or end.
83 | """
84 |
85 | IMPORTED_CODE_VUE_TAILWIND_SYSTEM_PROMPT = """
86 | You are an expert Vue/Tailwind developer.
87 |
88 | - Do not add comments in the code such as "" and "" in place of writing the full code. WRITE THE FULL CODE.
89 | - Repeat elements as needed. For example, if there are 15 items, the code should have 15 items. DO NOT LEAVE comments like "" or bad things will happen.
90 | - For images, use placeholder images from https://placehold.co and include a detailed description of the image in the alt text so that an image generation AI can generate the image later.
91 |
92 | In terms of libraries,
93 |
94 | - Use these script to include Vue so that it can run on a standalone page:
95 |
96 | - Use Vue using the global build like so:
97 | {{ message }}
98 |
109 | - Use this script to include Tailwind:
110 | - You can use Google Fonts
111 | - Font Awesome for icons:
112 |
113 | Return only the full code in tags.
114 | Do not include markdown "```" or "```html" at the start or end.
115 | The return result must only include the code."""
116 |
117 | IMPORTED_CODE_SVG_SYSTEM_PROMPT = """
118 | You are an expert at building SVGs.
119 |
120 | - Do not add comments in the code such as "" and "" in place of writing the full code. WRITE THE FULL CODE.
121 | - Repeat elements as needed to match the screenshot. For example, if there are 15 items, the code should have 15 items. DO NOT LEAVE comments like "" or bad things will happen.
122 | - For images, use placeholder images from https://placehold.co and include a detailed description of the image in the alt text so that an image generation AI can generate the image later.
123 | - You can use Google Fonts
124 |
125 | Return only the full code in tags.
126 | Do not include markdown "```" or "```svg" at the start or end.
127 | """
128 |
129 | IMPORTED_CODE_SYSTEM_PROMPTS = SystemPrompts(
130 | html_tailwind=IMPORTED_CODE_TAILWIND_SYSTEM_PROMPT,
131 | react_tailwind=IMPORTED_CODE_REACT_TAILWIND_SYSTEM_PROMPT,
132 | bootstrap=IMPORTED_CODE_BOOTSTRAP_SYSTEM_PROMPT,
133 | ionic_tailwind=IMPORTED_CODE_IONIC_TAILWIND_SYSTEM_PROMPT,
134 | vue_tailwind=IMPORTED_CODE_VUE_TAILWIND_SYSTEM_PROMPT,
135 | svg=IMPORTED_CODE_SVG_SYSTEM_PROMPT,
136 | )
137 |
--------------------------------------------------------------------------------
/backend/prompts/screenshot_system_prompts.py:
--------------------------------------------------------------------------------
1 | from prompts.types import SystemPrompts
2 |
3 |
4 | HTML_TAILWIND_SYSTEM_PROMPT = """
5 | You are an expert Tailwind developer
6 | You take screenshots of a reference web page from the user, and then build single page apps
7 | using Tailwind, HTML and JS.
8 | You might also be given a screenshot(The second image) of a web page that you have already built, and asked to
9 | update it to look more like the reference image(The first image).
10 |
11 | - Make sure the app looks exactly like the screenshot.
12 | - Pay close attention to background color, text color, font size, font family,
13 | padding, margin, border, etc. Match the colors and sizes exactly.
14 | - Use the exact text from the screenshot.
15 | - Do not add comments in the code such as "" and "" in place of writing the full code. WRITE THE FULL CODE.
16 | - Repeat elements as needed to match the screenshot. For example, if there are 15 items, the code should have 15 items. DO NOT LEAVE comments like "" or bad things will happen.
17 | - For images, use placeholder images from https://placehold.co and include a detailed description of the image in the alt text so that an image generation AI can generate the image later.
18 |
19 | In terms of libraries,
20 |
21 | - Use this script to include Tailwind:
22 | - You can use Google Fonts
23 | - Font Awesome for icons:
24 |
25 | Return only the full code in tags.
26 | Do not include markdown "```" or "```html" at the start or end.
27 | """
28 |
29 | BOOTSTRAP_SYSTEM_PROMPT = """
30 | You are an expert Bootstrap developer
31 | You take screenshots of a reference web page from the user, and then build single page apps
32 | using Bootstrap, HTML and JS.
33 | You might also be given a screenshot(The second image) of a web page that you have already built, and asked to
34 | update it to look more like the reference image(The first image).
35 |
36 | - Make sure the app looks exactly like the screenshot.
37 | - Pay close attention to background color, text color, font size, font family,
38 | padding, margin, border, etc. Match the colors and sizes exactly.
39 | - Use the exact text from the screenshot.
40 | - Do not add comments in the code such as "" and "" in place of writing the full code. WRITE THE FULL CODE.
41 | - Repeat elements as needed to match the screenshot. For example, if there are 15 items, the code should have 15 items. DO NOT LEAVE comments like "" or bad things will happen.
42 | - For images, use placeholder images from https://placehold.co and include a detailed description of the image in the alt text so that an image generation AI can generate the image later.
43 |
44 | In terms of libraries,
45 |
46 | - Use this script to include Bootstrap:
47 | - You can use Google Fonts
48 | - Font Awesome for icons:
49 |
50 | Return only the full code in tags.
51 | Do not include markdown "```" or "```html" at the start or end.
52 | """
53 |
54 | REACT_TAILWIND_SYSTEM_PROMPT = """
55 | You are an expert React/Tailwind developer
56 | You take screenshots of a reference web page from the user, and then build single page apps
57 | using React and Tailwind CSS.
58 | You might also be given a screenshot(The second image) of a web page that you have already built, and asked to
59 | update it to look more like the reference image(The first image).
60 |
61 | - Make sure the app looks exactly like the screenshot.
62 | - Pay close attention to background color, text color, font size, font family,
63 | padding, margin, border, etc. Match the colors and sizes exactly.
64 | - Use the exact text from the screenshot.
65 | - Do not add comments in the code such as "" and "" in place of writing the full code. WRITE THE FULL CODE.
66 | - Repeat elements as needed to match the screenshot. For example, if there are 15 items, the code should have 15 items. DO NOT LEAVE comments like "" or bad things will happen.
67 | - For images, use placeholder images from https://placehold.co and include a detailed description of the image in the alt text so that an image generation AI can generate the image later.
68 |
69 | In terms of libraries,
70 |
71 | - Use these script to include React so that it can run on a standalone page:
72 |
73 |
74 |
75 | - Use this script to include Tailwind:
76 | - You can use Google Fonts
77 | - Font Awesome for icons:
78 |
79 | Return only the full code in tags.
80 | Do not include markdown "```" or "```html" at the start or end.
81 | """
82 |
83 | IONIC_TAILWIND_SYSTEM_PROMPT = """
84 | You are an expert Ionic/Tailwind developer
85 | You take screenshots of a reference web page from the user, and then build single page apps
86 | using Ionic and Tailwind CSS.
87 | You might also be given a screenshot(The second image) of a web page that you have already built, and asked to
88 | update it to look more like the reference image(The first image).
89 |
90 | - Make sure the app looks exactly like the screenshot.
91 | - Pay close attention to background color, text color, font size, font family,
92 | padding, margin, border, etc. Match the colors and sizes exactly.
93 | - Use the exact text from the screenshot.
94 | - Do not add comments in the code such as "" and "" in place of writing the full code. WRITE THE FULL CODE.
95 | - Repeat elements as needed to match the screenshot. For example, if there are 15 items, the code should have 15 items. DO NOT LEAVE comments like "" or bad things will happen.
96 | - For images, use placeholder images from https://placehold.co and include a detailed description of the image in the alt text so that an image generation AI can generate the image later.
97 |
98 | In terms of libraries,
99 |
100 | - Use these script to include Ionic so that it can run on a standalone page:
101 |
102 |
103 |
104 | - Use this script to include Tailwind:
105 | - You can use Google Fonts
106 | - ionicons for icons, add the following
110 |
111 |
112 |
113 | Return only the full code in tags.
114 | Do not include markdown "```" or "```html" at the start or end.
115 | """
116 |
117 | VUE_TAILWIND_SYSTEM_PROMPT = """
118 | You are an expert Vue/Tailwind developer
119 | You take screenshots of a reference web page from the user, and then build single page apps
120 | using Vue and Tailwind CSS.
121 | You might also be given a screenshot(The second image) of a web page that you have already built, and asked to
122 | update it to look more like the reference image(The first image).
123 |
124 | - Make sure the app looks exactly like the screenshot.
125 | - Pay close attention to background color, text color, font size, font family,
126 | padding, margin, border, etc. Match the colors and sizes exactly.
127 | - Use the exact text from the screenshot.
128 | - Do not add comments in the code such as "" and "" in place of writing the full code. WRITE THE FULL CODE.
129 | - Repeat elements as needed to match the screenshot. For example, if there are 15 items, the code should have 15 items. DO NOT LEAVE comments like "" or bad things will happen.
130 | - For images, use placeholder images from https://placehold.co and include a detailed description of the image in the alt text so that an image generation AI can generate the image later.
131 | - Use Vue using the global build like so:
132 |
133 | {{ message }}
134 |
145 |
146 | In terms of libraries,
147 |
148 | - Use these script to include Vue so that it can run on a standalone page:
149 |
150 | - Use this script to include Tailwind:
151 | - You can use Google Fonts
152 | - Font Awesome for icons:
153 |
154 | Return only the full code in tags.
155 | Do not include markdown "```" or "```html" at the start or end.
156 | The return result must only include the code.
157 | """
158 |
159 |
160 | SVG_SYSTEM_PROMPT = """
161 | You are an expert at building SVGs.
162 | You take screenshots of a reference web page from the user, and then build a SVG that looks exactly like the screenshot.
163 |
164 | - Make sure the SVG looks exactly like the screenshot.
165 | - Pay close attention to background color, text color, font size, font family,
166 | padding, margin, border, etc. Match the colors and sizes exactly.
167 | - Use the exact text from the screenshot.
168 | - Do not add comments in the code such as "" and "" in place of writing the full code. WRITE THE FULL CODE.
169 | - Repeat elements as needed to match the screenshot. For example, if there are 15 items, the code should have 15 items. DO NOT LEAVE comments like "" or bad things will happen.
170 | - For images, use placeholder images from https://placehold.co and include a detailed description of the image in the alt text so that an image generation AI can generate the image later.
171 | - You can use Google Fonts
172 |
173 | Return only the full code in tags.
174 | Do not include markdown "```" or "```svg" at the start or end.
175 | """
176 |
177 |
178 | SYSTEM_PROMPTS = SystemPrompts(
179 | html_tailwind=HTML_TAILWIND_SYSTEM_PROMPT,
180 | react_tailwind=REACT_TAILWIND_SYSTEM_PROMPT,
181 | bootstrap=BOOTSTRAP_SYSTEM_PROMPT,
182 | ionic_tailwind=IONIC_TAILWIND_SYSTEM_PROMPT,
183 | vue_tailwind=VUE_TAILWIND_SYSTEM_PROMPT,
184 | svg=SVG_SYSTEM_PROMPT,
185 | )
186 |
--------------------------------------------------------------------------------
/backend/prompts/types.py:
--------------------------------------------------------------------------------
1 | from typing import Literal, TypedDict
2 |
3 |
4 | class SystemPrompts(TypedDict):
5 | html_tailwind: str
6 | react_tailwind: str
7 | bootstrap: str
8 | ionic_tailwind: str
9 | vue_tailwind: str
10 | svg: str
11 |
12 |
13 | Stack = Literal[
14 | "html_tailwind",
15 | "react_tailwind",
16 | "bootstrap",
17 | "ionic_tailwind",
18 | "vue_tailwind",
19 | "svg",
20 | ]
21 |
--------------------------------------------------------------------------------
/backend/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.poetry]
2 | name = "backend"
3 | version = "0.1.0"
4 | description = ""
5 | authors = ["Abi Raja "]
6 | license = "MIT"
7 |
8 | [tool.poetry.dependencies]
9 | python = "^3.10"
10 | fastapi = "^0.95.0"
11 | uvicorn = "^0.25.0"
12 | websockets = "^12.0"
13 | openai = "^1.2.4"
14 | python-dotenv = "^1.0.0"
15 | beautifulsoup4 = "^4.12.2"
16 | httpx = "^0.25.1"
17 | pre-commit = "^3.6.2"
18 |
19 | [tool.poetry.group.dev.dependencies]
20 | pytest = "^7.4.3"
21 | pyright = "^1.1.345"
22 |
23 | [build-system]
24 | requires = ["poetry-core"]
25 | build-backend = "poetry.core.masonry.api"
26 |
--------------------------------------------------------------------------------
/backend/pyrightconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "exclude": ["image_generation.py"]
3 | }
4 |
--------------------------------------------------------------------------------
/backend/routes/evals.py:
--------------------------------------------------------------------------------
1 | import os
2 | from fastapi import APIRouter
3 | from pydantic import BaseModel
4 | from evals.utils import image_to_data_url
5 | from evals.config import EVALS_DIR
6 |
7 |
8 | router = APIRouter()
9 |
10 |
11 | class Eval(BaseModel):
12 | input: str
13 | output: str
14 |
15 |
16 | @router.get("/evals")
17 | async def get_evals():
18 | # Get all evals from EVALS_DIR
19 | input_dir = EVALS_DIR + "/inputs"
20 | output_dir = EVALS_DIR + "/outputs"
21 |
22 | evals: list[Eval] = []
23 | for file in os.listdir(input_dir):
24 | if file.endswith(".png"):
25 | input_file_path = os.path.join(input_dir, file)
26 | input_file = await image_to_data_url(input_file_path)
27 |
28 | # Construct the corresponding output file name
29 | output_file_name = file.replace(".png", ".html")
30 | output_file_path = os.path.join(output_dir, output_file_name)
31 |
32 | # Check if the output file exists
33 | if os.path.exists(output_file_path):
34 | with open(output_file_path, "r") as f:
35 | output_file_data = f.read()
36 | else:
37 | output_file_data = "Output file not found."
38 |
39 | evals.append(
40 | Eval(
41 | input=input_file,
42 | output=output_file_data,
43 | )
44 | )
45 |
46 | return evals
47 |
--------------------------------------------------------------------------------
/backend/routes/generate_code.py:
--------------------------------------------------------------------------------
1 | import os
2 | import traceback
3 | from fastapi import APIRouter, WebSocket
4 | import openai
5 | from config import IS_PROD, SHOULD_MOCK_AI_RESPONSE
6 | from llm import stream_openai_response
7 | from openai.types.chat import ChatCompletionMessageParam
8 | from mock_llm import mock_completion
9 | from typing import Dict, List, cast, get_args
10 | from image_generation import create_alt_url_mapping, generate_images
11 | from prompts import assemble_imported_code_prompt, assemble_prompt
12 | from access_token import validate_access_token
13 | from datetime import datetime
14 | import json
15 | from prompts.types import Stack
16 |
17 | from utils import pprint_prompt # type: ignore
18 |
19 |
20 | router = APIRouter()
21 |
22 |
23 | def write_logs(prompt_messages: List[ChatCompletionMessageParam], completion: str):
24 | # Get the logs path from environment, default to the current working directory
25 | logs_path = os.environ.get("LOGS_PATH", os.getcwd())
26 |
27 | # Create run_logs directory if it doesn't exist within the specified logs path
28 | logs_directory = os.path.join(logs_path, "run_logs")
29 | if not os.path.exists(logs_directory):
30 | os.makedirs(logs_directory)
31 |
32 | print("Writing to logs directory:", logs_directory)
33 |
34 | # Generate a unique filename using the current timestamp within the logs directory
35 | filename = datetime.now().strftime(f"{logs_directory}/messages_%Y%m%d_%H%M%S.json")
36 |
37 | # Write the messages dict into a new file for each run
38 | with open(filename, "w") as f:
39 | f.write(json.dumps({"prompt": prompt_messages, "completion": completion}))
40 |
41 |
42 | @router.websocket("/generate-code")
43 | async def stream_code(websocket: WebSocket):
44 | await websocket.accept()
45 |
46 | print("Incoming websocket connection...")
47 |
48 | async def throw_error(
49 | message: str,
50 | ):
51 | await websocket.send_json({"type": "error", "value": message})
52 | await websocket.close()
53 |
54 | # TODO: Are the values always strings?
55 | params: Dict[str, str] = await websocket.receive_json()
56 |
57 | print("Received params")
58 |
59 | # Read the code config settings from the request. Fall back to default if not provided.
60 | generated_code_config = ""
61 | if "generatedCodeConfig" in params and params["generatedCodeConfig"]:
62 | generated_code_config = params["generatedCodeConfig"]
63 | print(f"Generating {generated_code_config} code")
64 |
65 | # Get the OpenAI API key from the request. Fall back to environment variable if not provided.
66 | # If neither is provided, we throw an error.
67 | openai_api_key = None
68 | if "accessCode" in params and params["accessCode"]:
69 | print("Access code - using platform API key")
70 | res = await validate_access_token(params["accessCode"])
71 | if res["success"]:
72 | openai_api_key = os.environ.get("PLATFORM_OPENAI_API_KEY")
73 | else:
74 | await websocket.send_json(
75 | {
76 | "type": "error",
77 | "value": res["failure_reason"],
78 | }
79 | )
80 | return
81 | else:
82 | if params["openAiApiKey"]:
83 | openai_api_key = params["openAiApiKey"]
84 | print("Using OpenAI API key from client-side settings dialog")
85 | else:
86 | openai_api_key = os.environ.get("OPENAI_API_KEY")
87 | if openai_api_key:
88 | print("Using OpenAI API key from environment variable")
89 |
90 | if not openai_api_key:
91 | print("OpenAI API key not found")
92 | await websocket.send_json(
93 | {
94 | "type": "error",
95 | "value": "No OpenAI API key found. Please add your API key in the settings dialog or add it to backend/.env file. If you add it to .env, make sure to restart the backend server.",
96 | }
97 | )
98 | return
99 |
100 | # Validate the generated code config
101 | if not generated_code_config in get_args(Stack):
102 | await throw_error(f"Invalid generated code config: {generated_code_config}")
103 | return
104 | # Cast the variable to the Stack type
105 | valid_stack = cast(Stack, generated_code_config)
106 |
107 | # Get the OpenAI Base URL from the request. Fall back to environment variable if not provided.
108 | openai_base_url = None
109 | # Disable user-specified OpenAI Base URL in prod
110 | if not os.environ.get("IS_PROD"):
111 | if "openAiBaseURL" in params and params["openAiBaseURL"]:
112 | openai_base_url = params["openAiBaseURL"]
113 | print("Using OpenAI Base URL from client-side settings dialog")
114 | else:
115 | openai_base_url = os.environ.get("OPENAI_BASE_URL")
116 | if openai_base_url:
117 | print("Using OpenAI Base URL from environment variable")
118 |
119 | if not openai_base_url:
120 | print("Using official OpenAI URL")
121 |
122 | # Get the image generation flag from the request. Fall back to True if not provided.
123 | should_generate_images = (
124 | params["isImageGenerationEnabled"]
125 | if "isImageGenerationEnabled" in params
126 | else True
127 | )
128 |
129 | print("generating code...")
130 | await websocket.send_json({"type": "status", "value": "Generating code..."})
131 |
132 | async def process_chunk(content: str):
133 | await websocket.send_json({"type": "chunk", "value": content})
134 |
135 | # Image cache for updates so that we don't have to regenerate images
136 | image_cache: Dict[str, str] = {}
137 |
138 | # If this generation started off with imported code, we need to assemble the prompt differently
139 | if params.get("isImportedFromCode") and params["isImportedFromCode"]:
140 | original_imported_code = params["history"][0]
141 | prompt_messages = assemble_imported_code_prompt(
142 | original_imported_code, valid_stack
143 | )
144 | for index, text in enumerate(params["history"][1:]):
145 | if index % 2 == 0:
146 | message: ChatCompletionMessageParam = {
147 | "role": "user",
148 | "content": text,
149 | }
150 | else:
151 | message: ChatCompletionMessageParam = {
152 | "role": "assistant",
153 | "content": text,
154 | }
155 | prompt_messages.append(message)
156 | else:
157 | # Assemble the prompt
158 | try:
159 | if params.get("resultImage") and params["resultImage"]:
160 | prompt_messages = assemble_prompt(
161 | params["image"], valid_stack, params["resultImage"]
162 | )
163 | else:
164 | prompt_messages = assemble_prompt(params["image"], valid_stack)
165 | except:
166 | await websocket.send_json(
167 | {
168 | "type": "error",
169 | "value": "Error assembling prompt. Contact support at support@picoapps.xyz",
170 | }
171 | )
172 | await websocket.close()
173 | return
174 |
175 | if params["generationType"] == "update":
176 | # Transform the history tree into message format
177 | # TODO: Move this to frontend
178 | for index, text in enumerate(params["history"]):
179 | if index % 2 == 0:
180 | message: ChatCompletionMessageParam = {
181 | "role": "assistant",
182 | "content": text,
183 | }
184 | else:
185 | message: ChatCompletionMessageParam = {
186 | "role": "user",
187 | "content": text,
188 | }
189 | prompt_messages.append(message)
190 |
191 | image_cache = create_alt_url_mapping(params["history"][-2])
192 |
193 | pprint_prompt(prompt_messages)
194 |
195 | if SHOULD_MOCK_AI_RESPONSE:
196 | completion = await mock_completion(process_chunk)
197 | else:
198 | try:
199 | completion = await stream_openai_response(
200 | prompt_messages,
201 | api_key=openai_api_key,
202 | base_url=openai_base_url,
203 | callback=lambda x: process_chunk(x),
204 | )
205 | except openai.AuthenticationError as e:
206 | print("[GENERATE_CODE] Authentication failed", e)
207 | error_message = (
208 | "Incorrect OpenAI key. Please make sure your OpenAI API key is correct, or create a new OpenAI API key on your OpenAI dashboard."
209 | + (
210 | " Alternatively, you can purchase code generation credits directly on this website."
211 | if IS_PROD
212 | else ""
213 | )
214 | )
215 | return await throw_error(error_message)
216 | except openai.NotFoundError as e:
217 | print("[GENERATE_CODE] Model not found", e)
218 | error_message = (
219 | e.message
220 | + ". Please make sure you have followed the instructions correctly to obtain an OpenAI key with GPT vision access: https://github.com/abi/screenshot-to-code/blob/main/Troubleshooting.md"
221 | + (
222 | " Alternatively, you can purchase code generation credits directly on this website."
223 | if IS_PROD
224 | else ""
225 | )
226 | )
227 | return await throw_error(error_message)
228 | except openai.RateLimitError as e:
229 | print("[GENERATE_CODE] Rate limit exceeded", e)
230 | error_message = (
231 | "OpenAI error - 'You exceeded your current quota, please check your plan and billing details.'"
232 | + (
233 | " Alternatively, you can purchase code generation credits directly on this website."
234 | if IS_PROD
235 | else ""
236 | )
237 | )
238 | return await throw_error(error_message)
239 |
240 | # Write the messages dict into a log so that we can debug later
241 | write_logs(prompt_messages, completion)
242 |
243 | try:
244 | if should_generate_images:
245 | await websocket.send_json(
246 | {"type": "status", "value": "Generating images..."}
247 | )
248 | updated_html = await generate_images(
249 | completion,
250 | api_key=openai_api_key,
251 | base_url=openai_base_url,
252 | image_cache=image_cache,
253 | )
254 | else:
255 | updated_html = completion
256 | await websocket.send_json({"type": "setCode", "value": updated_html})
257 | await websocket.send_json(
258 | {"type": "status", "value": "Code generation complete."}
259 | )
260 | except Exception as e:
261 | traceback.print_exc()
262 | print("Image generation failed", e)
263 | # Send set code even if image generation fails since that triggers
264 | # the frontend to update history
265 | await websocket.send_json({"type": "setCode", "value": completion})
266 | await websocket.send_json(
267 | {"type": "status", "value": "Image generation failed but code is complete."}
268 | )
269 |
270 | await websocket.close()
271 |
--------------------------------------------------------------------------------
/backend/routes/home.py:
--------------------------------------------------------------------------------
1 | from fastapi import APIRouter
2 | from fastapi.responses import HTMLResponse
3 |
4 |
5 | router = APIRouter()
6 |
7 |
8 | @router.get("/")
9 | async def get_status():
10 | return HTMLResponse(
11 | content="Your backend is running correctly. Please open the front-end URL (default is http://localhost:5173) to use screenshot-to-code. "
12 | )
13 |
--------------------------------------------------------------------------------
/backend/routes/screenshot.py:
--------------------------------------------------------------------------------
1 | import base64
2 | from fastapi import APIRouter
3 | from pydantic import BaseModel
4 | import httpx
5 |
6 | router = APIRouter()
7 |
8 |
9 | def bytes_to_data_url(image_bytes: bytes, mime_type: str) -> str:
10 | base64_image = base64.b64encode(image_bytes).decode("utf-8")
11 | return f"data:{mime_type};base64,{base64_image}"
12 |
13 |
14 | async def capture_screenshot(
15 | target_url: str, api_key: str, device: str = "desktop"
16 | ) -> bytes:
17 | api_base_url = "https://api.screenshotone.com/take"
18 |
19 | params = {
20 | "access_key": api_key,
21 | "url": target_url,
22 | "full_page": "true",
23 | "device_scale_factor": "1",
24 | "format": "png",
25 | "block_ads": "true",
26 | "block_cookie_banners": "true",
27 | "block_trackers": "true",
28 | "cache": "false",
29 | "viewport_width": "342",
30 | "viewport_height": "684",
31 | }
32 |
33 | if device == "desktop":
34 | params["viewport_width"] = "1280"
35 | params["viewport_height"] = "832"
36 |
37 | async with httpx.AsyncClient(timeout=60) as client:
38 | response = await client.get(api_base_url, params=params)
39 | if response.status_code == 200 and response.content:
40 | return response.content
41 | else:
42 | raise Exception("Error taking screenshot")
43 |
44 |
45 | class ScreenshotRequest(BaseModel):
46 | url: str
47 | apiKey: str
48 |
49 |
50 | class ScreenshotResponse(BaseModel):
51 | url: str
52 |
53 |
54 | @router.post("/api/screenshot")
55 | async def app_screenshot(request: ScreenshotRequest):
56 | # Extract the URL from the request body
57 | url = request.url
58 | api_key = request.apiKey
59 |
60 | # TODO: Add error handling
61 | image_bytes = await capture_screenshot(url, api_key=api_key)
62 |
63 | # Convert the image bytes to a data url
64 | data_url = bytes_to_data_url(image_bytes, "image/png")
65 |
66 | return ScreenshotResponse(url=data_url)
67 |
--------------------------------------------------------------------------------
/backend/run_evals.py:
--------------------------------------------------------------------------------
1 | # Load environment variables first
2 | from dotenv import load_dotenv
3 |
4 | load_dotenv()
5 |
6 | import os
7 | from typing import Any, Coroutine
8 | import asyncio
9 |
10 | from evals.config import EVALS_DIR
11 | from evals.core import generate_code_core
12 | from evals.utils import image_to_data_url
13 |
14 | STACK = "html_tailwind"
15 |
16 |
17 | async def main():
18 | INPUT_DIR = EVALS_DIR + "/inputs"
19 | OUTPUT_DIR = EVALS_DIR + "/outputs"
20 |
21 | # Get all the files in the directory (only grab pngs)
22 | evals = [f for f in os.listdir(INPUT_DIR) if f.endswith(".png")]
23 |
24 | tasks: list[Coroutine[Any, Any, str]] = []
25 | for filename in evals:
26 | filepath = os.path.join(INPUT_DIR, filename)
27 | data_url = await image_to_data_url(filepath)
28 | task = generate_code_core(data_url, STACK)
29 | tasks.append(task)
30 |
31 | results = await asyncio.gather(*tasks)
32 |
33 | os.makedirs(OUTPUT_DIR, exist_ok=True)
34 |
35 | for filename, content in zip(evals, results):
36 | # File name is derived from the original filename in evals
37 | output_filename = f"{os.path.splitext(filename)[0]}.html"
38 | output_filepath = os.path.join(OUTPUT_DIR, output_filename)
39 | with open(output_filepath, "w") as file:
40 | file.write(content)
41 |
42 |
43 | asyncio.run(main())
44 |
--------------------------------------------------------------------------------
/backend/start.py:
--------------------------------------------------------------------------------
1 | import uvicorn
2 |
3 | if __name__ == "__main__":
4 | uvicorn.run("main:app", port=7001, reload=True)
5 |
--------------------------------------------------------------------------------
/backend/utils.py:
--------------------------------------------------------------------------------
1 | import copy
2 | import json
3 | from typing import List
4 | from openai.types.chat import ChatCompletionMessageParam
5 |
6 |
7 | def pprint_prompt(prompt_messages: List[ChatCompletionMessageParam]):
8 | print(json.dumps(truncate_data_strings(prompt_messages), indent=4))
9 |
10 |
11 | def truncate_data_strings(data: List[ChatCompletionMessageParam]): # type: ignore
12 | # Deep clone the data to avoid modifying the original object
13 | cloned_data = copy.deepcopy(data)
14 |
15 | if isinstance(cloned_data, dict):
16 | for key, value in cloned_data.items(): # type: ignore
17 | # Recursively call the function if the value is a dictionary or a list
18 | if isinstance(value, (dict, list)):
19 | cloned_data[key] = truncate_data_strings(value) # type: ignore
20 | # Truncate the string if it it's long and add ellipsis and length
21 | elif isinstance(value, str):
22 | cloned_data[key] = value[:40] # type: ignore
23 | if len(value) > 40:
24 | cloned_data[key] += "..." + f" ({len(value)} chars)" # type: ignore
25 |
26 | elif isinstance(cloned_data, list): # type: ignore
27 | # Process each item in the list
28 | cloned_data = [truncate_data_strings(item) for item in cloned_data] # type: ignore
29 |
30 | return cloned_data # type: ignore
31 |
--------------------------------------------------------------------------------
/design-docs.md:
--------------------------------------------------------------------------------
1 | ## Version History
2 |
3 | Version history is stored as a tree on the client-side.
4 |
5 | 
6 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: '3.9'
2 |
3 | services:
4 | backend:
5 | build:
6 | context: ./backend
7 | dockerfile: Dockerfile
8 |
9 | env_file:
10 | - .env
11 |
12 | # or
13 | # environment:
14 | #- BACKEND_PORT=7001 # if you change the port, make sure to also change the VITE_WS_BACKEND_URL at frontend/.env.local
15 | # - OPENAI_API_KEY=your_openai_api_key
16 |
17 | ports:
18 | - "${BACKEND_PORT:-7001}:${BACKEND_PORT:-7001}"
19 |
20 | command: poetry run uvicorn main:app --host 0.0.0.0 --port ${BACKEND_PORT:-7001}
21 |
22 | frontend:
23 | build:
24 | context: ./frontend
25 | dockerfile: Dockerfile
26 | ports:
27 | - "5173:5173"
28 |
--------------------------------------------------------------------------------
/frontend/.env.example:
--------------------------------------------------------------------------------
1 | VITE_WS_BACKEND_URL=ws://127.0.0.1:7001
2 |
--------------------------------------------------------------------------------
/frontend/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | # Env files
27 | .env*
28 |
--------------------------------------------------------------------------------
/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:20.9-bullseye-slim
2 |
3 | # Set the working directory in the container
4 | WORKDIR /app
5 |
6 | # Copy package.json and yarn.lock
7 | COPY package.json yarn.lock /app/
8 |
9 | # Install dependencies
10 | RUN yarn install
11 |
12 | # Copy the current directory contents into the container at /app
13 | COPY ./ /app/
14 |
15 | # Expose port 5173 to access the server
16 | EXPOSE 5173
17 |
18 | # Command to run the application
19 | CMD ["yarn", "dev", "--host", "0.0.0.0"]
20 |
--------------------------------------------------------------------------------
/frontend/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/index.css",
9 | "baseColor": "slate",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
10 |
11 |
12 |
13 |
14 |
15 |
19 |
20 |
21 | <%- injectHead %>
22 |
23 | Screenshot to Code
24 |
25 |
26 |
27 |
31 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
48 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "screenshot-to-code",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "dev-hosted": "vite --mode prod",
9 | "build": "tsc && vite build",
10 | "build-hosted": "tsc && vite build --mode prod",
11 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
12 | "preview": "vite preview",
13 | "test": "vitest"
14 | },
15 | "dependencies": {
16 | "@codemirror/lang-html": "^6.4.6",
17 | "@radix-ui/react-accordion": "^1.1.2",
18 | "@radix-ui/react-alert-dialog": "^1.0.5",
19 | "@radix-ui/react-checkbox": "^1.0.4",
20 | "@radix-ui/react-collapsible": "^1.0.3",
21 | "@radix-ui/react-dialog": "^1.0.5",
22 | "@radix-ui/react-hover-card": "^1.0.7",
23 | "@radix-ui/react-icons": "^1.3.0",
24 | "@radix-ui/react-label": "^2.0.2",
25 | "@radix-ui/react-popover": "^1.0.7",
26 | "@radix-ui/react-progress": "^1.0.3",
27 | "@radix-ui/react-scroll-area": "^1.0.5",
28 | "@radix-ui/react-select": "^2.0.0",
29 | "@radix-ui/react-separator": "^1.0.3",
30 | "@radix-ui/react-slot": "^1.0.2",
31 | "@radix-ui/react-switch": "^1.0.3",
32 | "@radix-ui/react-tabs": "^1.0.4",
33 | "class-variance-authority": "^0.7.0",
34 | "classnames": "^2.3.2",
35 | "clsx": "^2.0.0",
36 | "codemirror": "^6.0.1",
37 | "copy-to-clipboard": "^3.3.3",
38 | "html2canvas": "^1.4.1",
39 | "react": "^18.2.0",
40 | "react-dom": "^18.2.0",
41 | "react-dropzone": "^14.2.3",
42 | "react-hot-toast": "^2.4.1",
43 | "react-icons": "^4.12.0",
44 | "react-router-dom": "^6.20.1",
45 | "tailwind-merge": "^2.0.0",
46 | "tailwindcss-animate": "^1.0.7",
47 | "thememirror": "^2.0.1",
48 | "vite-plugin-checker": "^0.6.2"
49 | },
50 | "devDependencies": {
51 | "@types/node": "^20.9.0",
52 | "@types/react": "^18.2.15",
53 | "@types/react-dom": "^18.2.7",
54 | "@typescript-eslint/eslint-plugin": "^6.0.0",
55 | "@typescript-eslint/parser": "^6.0.0",
56 | "@vitejs/plugin-react": "^4.0.3",
57 | "autoprefixer": "^10.4.16",
58 | "eslint": "^8.45.0",
59 | "eslint-plugin-react-hooks": "^4.6.0",
60 | "eslint-plugin-react-refresh": "^0.4.3",
61 | "postcss": "^8.4.31",
62 | "tailwindcss": "^3.3.5",
63 | "typescript": "^5.0.2",
64 | "vite": "^4.4.5",
65 | "vite-plugin-html": "^3.2.0",
66 | "vitest": "^1.0.1"
67 | },
68 | "engines": {
69 | "node": ">=14.18.0"
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/frontend/postcss.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/frontend/public/brand/twitter-summary-card.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/hacksider/screenshot-to-code/1bc26b616fc6ec9c8fd749a002fe256a72f272f9/frontend/public/brand/twitter-summary-card.png
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 | import ImageUpload from "./components/ImageUpload";
3 | import CodePreview from "./components/CodePreview";
4 | import Preview from "./components/Preview";
5 | import { generateCode } from "./generateCode";
6 | import Spinner from "./components/Spinner";
7 | import classNames from "classnames";
8 | import {
9 | FaCode,
10 | FaDesktop,
11 | FaDownload,
12 | FaMobile,
13 | FaUndo,
14 | } from "react-icons/fa";
15 |
16 | import { Switch } from "./components/ui/switch";
17 | import { Button } from "@/components/ui/button";
18 | import { Textarea } from "@/components/ui/textarea";
19 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "./components/ui/tabs";
20 | import SettingsDialog from "./components/SettingsDialog";
21 | import { AppState, CodeGenerationParams, EditorTheme, Settings } from "./types";
22 | import { IS_RUNNING_ON_CLOUD } from "./config";
23 | import { PicoBadge } from "./components/PicoBadge";
24 | import { OnboardingNote } from "./components/OnboardingNote";
25 | import { usePersistedState } from "./hooks/usePersistedState";
26 | import { UrlInputSection } from "./components/UrlInputSection";
27 | import TermsOfServiceDialog from "./components/TermsOfServiceDialog";
28 | import html2canvas from "html2canvas";
29 | import { USER_CLOSE_WEB_SOCKET_CODE } from "./constants";
30 | import CodeTab from "./components/CodeTab";
31 | import OutputSettingsSection from "./components/OutputSettingsSection";
32 | import { History } from "./components/history/history_types";
33 | import HistoryDisplay from "./components/history/HistoryDisplay";
34 | import { extractHistoryTree } from "./components/history/utils";
35 | import toast from "react-hot-toast";
36 | import ImportCodeSection from "./components/ImportCodeSection";
37 | import { Stack } from "./lib/stacks";
38 |
39 | const IS_OPENAI_DOWN = false;
40 |
41 | function App() {
42 | const [appState, setAppState] = useState(AppState.INITIAL);
43 | const [generatedCode, setGeneratedCode] = useState("");
44 |
45 | const [referenceImages, setReferenceImages] = useState([]);
46 | const [executionConsole, setExecutionConsole] = useState([]);
47 | const [updateInstruction, setUpdateInstruction] = useState("");
48 | const [isImportedFromCode, setIsImportedFromCode] = useState(false);
49 |
50 | // Settings
51 | const [settings, setSettings] = usePersistedState(
52 | {
53 | openAiApiKey: null,
54 | openAiBaseURL: null,
55 | screenshotOneApiKey: null,
56 | isImageGenerationEnabled: true,
57 | editorTheme: EditorTheme.COBALT,
58 | generatedCodeConfig: Stack.HTML_TAILWIND,
59 | // Only relevant for hosted version
60 | isTermOfServiceAccepted: false,
61 | accessCode: null,
62 | },
63 | "setting"
64 | );
65 |
66 | // App history
67 | const [appHistory, setAppHistory] = useState([]);
68 | // Tracks the currently shown version from app history
69 | const [currentVersion, setCurrentVersion] = useState(null);
70 |
71 | const [shouldIncludeResultImage, setShouldIncludeResultImage] =
72 | useState(false);
73 |
74 | const wsRef = useRef(null);
75 |
76 | // When the user already has the settings in local storage, newly added keys
77 | // do not get added to the settings so if it's falsy, we populate it with the default
78 | // value
79 | useEffect(() => {
80 | if (!settings.generatedCodeConfig) {
81 | setSettings((prev) => ({
82 | ...prev,
83 | generatedCodeConfig: Stack.HTML_TAILWIND,
84 | }));
85 | }
86 | }, [settings.generatedCodeConfig, setSettings]);
87 |
88 | const takeScreenshot = async (): Promise => {
89 | const iframeElement = document.querySelector(
90 | "#preview-desktop"
91 | ) as HTMLIFrameElement;
92 | if (!iframeElement?.contentWindow?.document.body) {
93 | return "";
94 | }
95 |
96 | const canvas = await html2canvas(iframeElement.contentWindow.document.body);
97 | const png = canvas.toDataURL("image/png");
98 | return png;
99 | };
100 |
101 | const downloadCode = () => {
102 | // Create a blob from the generated code
103 | const blob = new Blob([generatedCode], { type: "text/html" });
104 | const url = URL.createObjectURL(blob);
105 |
106 | // Create an anchor element and set properties for download
107 | const a = document.createElement("a");
108 | a.href = url;
109 | a.download = "index.html"; // Set the file name for download
110 | document.body.appendChild(a); // Append to the document
111 | a.click(); // Programmatically click the anchor to trigger download
112 |
113 | // Clean up by removing the anchor and revoking the Blob URL
114 | document.body.removeChild(a);
115 | URL.revokeObjectURL(url);
116 | };
117 |
118 | const reset = () => {
119 | setAppState(AppState.INITIAL);
120 | setGeneratedCode("");
121 | setReferenceImages([]);
122 | setExecutionConsole([]);
123 | setUpdateInstruction("");
124 | setIsImportedFromCode(false);
125 | setAppHistory([]);
126 | setCurrentVersion(null);
127 | setShouldIncludeResultImage(false);
128 | };
129 |
130 | const cancelCodeGeneration = () => {
131 | wsRef.current?.close?.(USER_CLOSE_WEB_SOCKET_CODE);
132 | // make sure stop can correct the state even if the websocket is already closed
133 | cancelCodeGenerationAndReset();
134 | };
135 |
136 | const cancelCodeGenerationAndReset = () => {
137 | // When this is the first version, reset the entire app state
138 | if (currentVersion === null) {
139 | reset();
140 | } else {
141 | // Otherwise, revert to the last version
142 | setGeneratedCode(appHistory[currentVersion].code);
143 | setAppState(AppState.CODE_READY);
144 | }
145 | };
146 |
147 | function doGenerateCode(
148 | params: CodeGenerationParams,
149 | parentVersion: number | null
150 | ) {
151 | setExecutionConsole([]);
152 | setAppState(AppState.CODING);
153 |
154 | // Merge settings with params
155 | const updatedParams = { ...params, ...settings };
156 |
157 | generateCode(
158 | wsRef,
159 | updatedParams,
160 | // On change
161 | (token) => setGeneratedCode((prev) => prev + token),
162 | // On set code
163 | (code) => {
164 | setGeneratedCode(code);
165 | if (params.generationType === "create") {
166 | setAppHistory([
167 | {
168 | type: "ai_create",
169 | parentIndex: null,
170 | code,
171 | inputs: { image_url: referenceImages[0] },
172 | },
173 | ]);
174 | setCurrentVersion(0);
175 | } else {
176 | setAppHistory((prev) => {
177 | // Validate parent version
178 | if (parentVersion === null) {
179 | toast.error(
180 | "No parent version set. Contact support or open a Github issue."
181 | );
182 | return prev;
183 | }
184 |
185 | const newHistory: History = [
186 | ...prev,
187 | {
188 | type: "ai_edit",
189 | parentIndex: parentVersion,
190 | code,
191 | inputs: {
192 | prompt: updateInstruction,
193 | },
194 | },
195 | ];
196 | setCurrentVersion(newHistory.length - 1);
197 | return newHistory;
198 | });
199 | }
200 | },
201 | // On status update
202 | (line) => setExecutionConsole((prev) => [...prev, line]),
203 | // On cancel
204 | () => {
205 | cancelCodeGenerationAndReset();
206 | },
207 | // On complete
208 | () => {
209 | setAppState(AppState.CODE_READY);
210 | }
211 | );
212 | }
213 |
214 | // Initial version creation
215 | function doCreate(referenceImages: string[]) {
216 | // Reset any existing state
217 | reset();
218 |
219 | setReferenceImages(referenceImages);
220 | if (referenceImages.length > 0) {
221 | doGenerateCode(
222 | {
223 | generationType: "create",
224 | image: referenceImages[0],
225 | },
226 | currentVersion
227 | );
228 | }
229 | }
230 |
231 | // Subsequent updates
232 | async function doUpdate() {
233 | if (currentVersion === null) {
234 | toast.error(
235 | "No current version set. Contact support or open a Github issue."
236 | );
237 | return;
238 | }
239 |
240 | let historyTree;
241 | try {
242 | historyTree = extractHistoryTree(appHistory, currentVersion);
243 | } catch {
244 | toast.error(
245 | "Version history is invalid. This shouldn't happen. Please contact support or open a Github issue."
246 | );
247 | return;
248 | }
249 |
250 | const updatedHistory = [...historyTree, updateInstruction];
251 |
252 | if (shouldIncludeResultImage) {
253 | const resultImage = await takeScreenshot();
254 | doGenerateCode(
255 | {
256 | generationType: "update",
257 | image: referenceImages[0],
258 | resultImage: resultImage,
259 | history: updatedHistory,
260 | isImportedFromCode,
261 | },
262 | currentVersion
263 | );
264 | } else {
265 | doGenerateCode(
266 | {
267 | generationType: "update",
268 | image: referenceImages[0],
269 | history: updatedHistory,
270 | isImportedFromCode,
271 | },
272 | currentVersion
273 | );
274 | }
275 |
276 | setGeneratedCode("");
277 | setUpdateInstruction("");
278 | }
279 |
280 | const handleTermDialogOpenChange = (open: boolean) => {
281 | setSettings((s) => ({
282 | ...s,
283 | isTermOfServiceAccepted: !open,
284 | }));
285 | };
286 |
287 | function setStack(stack: Stack) {
288 | setSettings((prev) => ({
289 | ...prev,
290 | generatedCodeConfig: stack,
291 | }));
292 | }
293 |
294 | function importFromCode(code: string, stack: Stack) {
295 | setIsImportedFromCode(true);
296 |
297 | // Set up this project
298 | setGeneratedCode(code);
299 | setStack(stack);
300 | setAppHistory([
301 | {
302 | type: "code_create",
303 | parentIndex: null,
304 | code,
305 | inputs: { code },
306 | },
307 | ]);
308 | setCurrentVersion(0);
309 |
310 | setAppState(AppState.CODE_READY);
311 | }
312 |
313 | return (
314 |
315 | {IS_RUNNING_ON_CLOUD &&
}
316 | {IS_RUNNING_ON_CLOUD && (
317 |
321 | )}
322 |
323 |
324 |
325 |
Screenshot to Code
326 |
327 |
328 |
329 |
setStack(config)}
332 | shouldDisableUpdates={
333 | appState === AppState.CODING || appState === AppState.CODE_READY
334 | }
335 | />
336 |
337 | {IS_RUNNING_ON_CLOUD &&
338 | !(settings.openAiApiKey || settings.accessCode) && (
339 |
340 | )}
341 |
342 | {IS_OPENAI_DOWN && (
343 |
344 | OpenAI API is currently down. Try back in 30 minutes or later. We
345 | apologize for the inconvenience.
346 |
347 | )}
348 |
349 | {(appState === AppState.CODING ||
350 | appState === AppState.CODE_READY) && (
351 | <>
352 | {/* Show code preview only when coding */}
353 | {appState === AppState.CODING && (
354 |
355 |
356 |
357 | {executionConsole.slice(-1)[0]}
358 |
359 |
360 |
364 | Cancel
365 |
366 |
367 |
368 |
369 | )}
370 |
371 | {appState === AppState.CODE_READY && (
372 |
373 |
396 |
397 |
401 | Download
402 |
403 |
407 |
408 | Reset
409 |
410 |
411 |
412 | )}
413 |
414 | {/* Reference image display */}
415 |
416 | {referenceImages.length > 0 && (
417 |
418 |
423 |
428 |
429 |
430 | Original Screenshot
431 |
432 |
433 | )}
434 |
435 |
436 | Console
437 |
438 | {executionConsole.map((line, index) => (
439 |
443 | {line}
444 |
445 | ))}
446 |
447 |
448 | >
449 | )}
450 | {
451 | {
455 | if (
456 | index < 0 ||
457 | index >= appHistory.length ||
458 | !appHistory[index]
459 | )
460 | return;
461 | setCurrentVersion(index);
462 | setGeneratedCode(appHistory[index].code);
463 | }}
464 | shouldDisableReverts={appState === AppState.CODING}
465 | />
466 | }
467 |
468 |
469 |
470 |
471 | {appState === AppState.INITIAL && (
472 |
473 |
474 |
478 |
479 |
480 | )}
481 |
482 | {(appState === AppState.CODING || appState === AppState.CODE_READY) && (
483 |
484 |
485 |
486 |
487 |
488 | Desktop
489 |
490 |
491 | Mobile
492 |
493 |
494 |
495 | Code
496 |
497 |
498 |
499 |
500 |
501 |
502 |
503 |
504 |
505 |
506 |
511 |
512 |
513 |
514 | )}
515 |
516 |
517 | );
518 | }
519 |
520 | export default App;
521 |
--------------------------------------------------------------------------------
/frontend/src/components/CodeMirror.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect, useMemo } from "react";
2 | import { EditorState } from "@codemirror/state";
3 | import { EditorView, keymap, lineNumbers, ViewUpdate } from "@codemirror/view";
4 | import { espresso, cobalt } from "thememirror";
5 | import {
6 | defaultKeymap,
7 | history,
8 | indentWithTab,
9 | redo,
10 | undo,
11 | } from "@codemirror/commands";
12 | import { bracketMatching } from "@codemirror/language";
13 | import { html } from "@codemirror/lang-html";
14 | import { EditorTheme } from "@/types";
15 |
16 | interface Props {
17 | code: string;
18 | editorTheme: EditorTheme;
19 | onCodeChange: (code: string) => void;
20 | }
21 |
22 | function CodeMirror({ code, editorTheme, onCodeChange }: Props) {
23 | const ref = useRef(null);
24 | const view = useRef(null);
25 | const editorState = useMemo(
26 | () =>
27 | EditorState.create({
28 | extensions: [
29 | history(),
30 | keymap.of([
31 | ...defaultKeymap,
32 | indentWithTab,
33 | { key: "Mod-z", run: undo, preventDefault: true },
34 | { key: "Mod-Shift-z", run: redo, preventDefault: true },
35 | ]),
36 | lineNumbers(),
37 | bracketMatching(),
38 | html(),
39 | editorTheme === EditorTheme.ESPRESSO ? espresso : cobalt,
40 | EditorView.lineWrapping,
41 | EditorView.updateListener.of((update: ViewUpdate) => {
42 | if (update.docChanged) {
43 | const updatedCode = update.state.doc.toString();
44 | onCodeChange(updatedCode);
45 | }
46 | }),
47 | ],
48 | }),
49 | [editorTheme]
50 | );
51 | useEffect(() => {
52 | view.current = new EditorView({
53 | state: editorState,
54 | parent: ref.current as Element,
55 | });
56 |
57 | return () => {
58 | if (view.current) {
59 | view.current.destroy();
60 | view.current = null;
61 | }
62 | };
63 | }, []);
64 |
65 | useEffect(() => {
66 | if (view.current && view.current.state.doc.toString() !== code) {
67 | view.current.dispatch({
68 | changes: { from: 0, to: view.current.state.doc.length, insert: code },
69 | });
70 | }
71 | }, [code]);
72 |
73 | return (
74 |
78 | );
79 | }
80 |
81 | export default CodeMirror;
82 |
--------------------------------------------------------------------------------
/frontend/src/components/CodePreview.tsx:
--------------------------------------------------------------------------------
1 | import { useRef, useEffect } from "react";
2 |
3 | interface Props {
4 | code: string;
5 | }
6 |
7 | function CodePreview({ code }: Props) {
8 | const scrollRef = useRef(null);
9 |
10 | useEffect(() => {
11 | if (scrollRef.current) {
12 | scrollRef.current.scrollLeft = scrollRef.current.scrollWidth;
13 | }
14 | }, [code]);
15 |
16 | return (
17 |
22 | {code}
23 |
24 | );
25 | }
26 |
27 | export default CodePreview;
28 |
--------------------------------------------------------------------------------
/frontend/src/components/CodeTab.tsx:
--------------------------------------------------------------------------------
1 | import { FaCopy } from "react-icons/fa";
2 | import CodeMirror from "./CodeMirror";
3 | import { Button } from "./ui/button";
4 | import { Settings } from "../types";
5 | import copy from "copy-to-clipboard";
6 | import { useCallback } from "react";
7 | import toast from "react-hot-toast";
8 |
9 | interface Props {
10 | code: string;
11 | setCode: React.Dispatch>;
12 | settings: Settings;
13 | }
14 |
15 | function CodeTab({ code, setCode, settings }: Props) {
16 | const copyCode = useCallback(() => {
17 | copy(code);
18 | toast.success("Copied to clipboard");
19 | }, [code]);
20 |
21 | const doOpenInCodepenio = useCallback(async () => {
22 | // TODO: Update CSS and JS external links depending on the framework being used
23 | const data = {
24 | html: code,
25 | editors: "100", // 1: Open HTML, 0: Close CSS, 0: Close JS
26 | layout: "left",
27 | css_external:
28 | "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.3/css/all.min.css" +
29 | (code.includes("
58 |
59 |
64 | Copy Code
65 |
66 |
70 | Open in{" "}
71 |
76 |
77 |
78 |
83 |
84 | );
85 | }
86 |
87 | export default CodeTab;
88 |
--------------------------------------------------------------------------------
/frontend/src/components/ImageUpload.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useMemo } from "react";
2 | // useCallback
3 | import { useDropzone } from "react-dropzone";
4 | // import { PromptImage } from "../../../types";
5 | import { toast } from "react-hot-toast";
6 |
7 | const baseStyle = {
8 | flex: 1,
9 | width: "80%",
10 | margin: "0 auto",
11 | minHeight: "400px",
12 | display: "flex",
13 | flexDirection: "column",
14 | alignItems: "center",
15 | justifyContent: "center",
16 | padding: "20px",
17 | borderWidth: 2,
18 | borderRadius: 2,
19 | borderColor: "#eeeeee",
20 | borderStyle: "dashed",
21 | backgroundColor: "#fafafa",
22 | color: "#bdbdbd",
23 | outline: "none",
24 | transition: "border .24s ease-in-out",
25 | };
26 |
27 | const focusedStyle = {
28 | borderColor: "#2196f3",
29 | };
30 |
31 | const acceptStyle = {
32 | borderColor: "#00e676",
33 | };
34 |
35 | const rejectStyle = {
36 | borderColor: "#ff1744",
37 | };
38 |
39 | // TODO: Move to a separate file
40 | function fileToDataURL(file: File) {
41 | return new Promise((resolve, reject) => {
42 | const reader = new FileReader();
43 | reader.onload = () => resolve(reader.result);
44 | reader.onerror = (error) => reject(error);
45 | reader.readAsDataURL(file);
46 | });
47 | }
48 |
49 | type FileWithPreview = {
50 | preview: string;
51 | } & File;
52 |
53 | interface Props {
54 | setReferenceImages: (referenceImages: string[]) => void;
55 | }
56 |
57 | function ImageUpload({ setReferenceImages }: Props) {
58 | const [files, setFiles] = useState([]);
59 | const { getRootProps, getInputProps, isFocused, isDragAccept, isDragReject } =
60 | useDropzone({
61 | maxFiles: 1,
62 | maxSize: 1024 * 1024 * 5, // 5 MB
63 | accept: {
64 | "image/png": [".png"],
65 | "image/jpeg": [".jpeg"],
66 | "image/jpg": [".jpg"],
67 | },
68 | onDrop: (acceptedFiles) => {
69 | // Set up the preview thumbnail images
70 | setFiles(
71 | acceptedFiles.map((file: File) =>
72 | Object.assign(file, {
73 | preview: URL.createObjectURL(file),
74 | })
75 | ) as FileWithPreview[]
76 | );
77 |
78 | // Convert images to data URLs and set the prompt images state
79 | Promise.all(acceptedFiles.map((file) => fileToDataURL(file)))
80 | .then((dataUrls) => {
81 | setReferenceImages(dataUrls.map((dataUrl) => dataUrl as string));
82 | })
83 | .catch((error) => {
84 | toast.error("Error reading files" + error);
85 | console.error("Error reading files:", error);
86 | });
87 | },
88 | onDropRejected: (rejectedFiles) => {
89 | toast.error(rejectedFiles[0].errors[0].message);
90 | },
91 | });
92 |
93 | // const pasteEvent = useCallback(
94 | // (event: ClipboardEvent) => {
95 | // const clipboardData = event.clipboardData;
96 | // if (!clipboardData) return;
97 |
98 | // const items = clipboardData.items;
99 | // const files = [];
100 | // for (let i = 0; i < items.length; i++) {
101 | // const file = items[i].getAsFile();
102 | // if (file && file.type.startsWith("image/")) {
103 | // files.push(file);
104 | // }
105 | // }
106 |
107 | // // Convert images to data URLs and set the prompt images state
108 | // Promise.all(files.map((file) => fileToDataURL(file)))
109 | // .then((dataUrls) => {
110 | // if (dataUrls.length > 0) {
111 | // setReferenceImages(dataUrls.map((dataUrl) => dataUrl as string));
112 | // }
113 | // })
114 | // .catch((error) => {
115 | // // TODO: Display error to user
116 | // console.error("Error reading files:", error);
117 | // });
118 | // },
119 | // [setReferenceImages]
120 | // );
121 |
122 | // TODO: Make sure we don't listen to paste events in text input components
123 | // useEffect(() => {
124 | // window.addEventListener("paste", pasteEvent);
125 | // }, [pasteEvent]);
126 |
127 | useEffect(() => {
128 | return () => files.forEach((file) => URL.revokeObjectURL(file.preview));
129 | }, [files]); // Added files as a dependency
130 |
131 | const style = useMemo(
132 | () => ({
133 | ...baseStyle,
134 | ...(isFocused ? focusedStyle : {}),
135 | ...(isDragAccept ? acceptStyle : {}),
136 | ...(isDragReject ? rejectStyle : {}),
137 | }),
138 | [isFocused, isDragAccept, isDragReject]
139 | );
140 |
141 | return (
142 |
143 | {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
144 |
145 |
146 |
147 | Drag & drop a screenshot here,
148 | or paste from clipboard,
149 | or click to upload
150 |
151 |
152 |
153 | );
154 | }
155 |
156 | export default ImageUpload;
157 |
--------------------------------------------------------------------------------
/frontend/src/components/ImportCodeSection.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Button } from "./ui/button";
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogDescription,
7 | DialogFooter,
8 | DialogHeader,
9 | DialogTitle,
10 | DialogTrigger,
11 | } from "./ui/dialog";
12 | import { Textarea } from "./ui/textarea";
13 | import OutputSettingsSection from "./OutputSettingsSection";
14 | import toast from "react-hot-toast";
15 | import { Stack } from "../lib/stacks";
16 |
17 | interface Props {
18 | importFromCode: (code: string, stack: Stack) => void;
19 | }
20 |
21 | function ImportCodeSection({ importFromCode }: Props) {
22 | const [code, setCode] = useState("");
23 | const [stack, setStack] = useState(undefined);
24 |
25 | const doImport = () => {
26 | if (code === "") {
27 | toast.error("Please paste in some code");
28 | return;
29 | }
30 |
31 | if (stack === undefined) {
32 | toast.error("Please select your stack");
33 | return;
34 | }
35 |
36 | importFromCode(code, stack);
37 | };
38 | return (
39 |
40 |
41 | Import from Code
42 |
43 |
44 |
45 | Paste in your HTML code
46 |
47 | Make sure that the code you're importing is valid HTML.
48 |
49 |
50 |
51 |
70 |
71 | );
72 | }
73 |
74 | export default ImportCodeSection;
75 |
--------------------------------------------------------------------------------
/frontend/src/components/OnboardingNote.tsx:
--------------------------------------------------------------------------------
1 | export function OnboardingNote() {
2 | return (
3 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/components/OutputSettingsSection.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | Select,
4 | SelectContent,
5 | SelectGroup,
6 | SelectItem,
7 | SelectTrigger,
8 | } from "./ui/select";
9 | import { Badge } from "./ui/badge";
10 | import { Stack, STACK_DESCRIPTIONS } from "../lib/stacks";
11 |
12 | function generateDisplayComponent(stack: Stack) {
13 | const stackComponents = STACK_DESCRIPTIONS[stack].components;
14 |
15 | return (
16 |
17 | {stackComponents.map((component, index) => (
18 |
19 | {component}
20 | {index < stackComponents.length - 1 && " + "}
21 |
22 | ))}
23 |
24 | );
25 | }
26 |
27 | interface Props {
28 | stack: Stack | undefined;
29 | setStack: (config: Stack) => void;
30 | label?: string;
31 | shouldDisableUpdates?: boolean;
32 | }
33 |
34 | function OutputSettingsSection({
35 | stack,
36 | setStack,
37 | label = "Generating:",
38 | shouldDisableUpdates = false,
39 | }: Props) {
40 | return (
41 |
42 |
43 |
{label}
44 |
setStack(value as Stack)}
47 | disabled={shouldDisableUpdates}
48 | >
49 |
50 | {stack ? generateDisplayComponent(stack) : "Select a stack"}
51 |
52 |
53 |
54 | {Object.values(Stack).map((stack) => (
55 |
56 |
57 | {generateDisplayComponent(stack)}
58 | {STACK_DESCRIPTIONS[stack].inBeta && (
59 |
60 | Beta
61 |
62 | )}
63 |
64 |
65 | ))}
66 |
67 |
68 |
69 |
70 |
71 | );
72 | }
73 |
74 | export default OutputSettingsSection;
75 |
--------------------------------------------------------------------------------
/frontend/src/components/PicoBadge.tsx:
--------------------------------------------------------------------------------
1 | import { Settings } from "../types";
2 |
3 | export function PicoBadge({ settings }: { settings: Settings }) {
4 | return (
5 | <>
6 |
10 |
14 | feature requests?
15 |
16 |
17 | {!settings.accessCode && (
18 |
19 |
23 | an open source project by Pico
24 |
25 |
26 | )}
27 | {settings.accessCode && (
28 |
29 |
33 | email support
34 |
35 |
36 | )}
37 | >
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/frontend/src/components/Preview.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 | import classNames from "classnames";
3 | // import useThrottle from "../hooks/useThrottle";
4 |
5 | interface Props {
6 | code: string;
7 | device: "mobile" | "desktop";
8 | }
9 |
10 | function Preview({ code, device }: Props) {
11 | const throttledCode = code;
12 | // Temporary disable throttling for the preview not updating when the code changes
13 | // useThrottle(code, 200);
14 | const iframeRef = useRef(null);
15 |
16 | useEffect(() => {
17 | const iframe = iframeRef.current;
18 | if (iframe && iframe.contentDocument) {
19 | iframe.contentDocument.open();
20 | iframe.contentDocument.write(throttledCode);
21 | iframe.contentDocument.close();
22 | }
23 | }, [throttledCode]);
24 |
25 | return (
26 |
27 |
40 |
41 | );
42 | }
43 |
44 | export default Preview;
45 |
--------------------------------------------------------------------------------
/frontend/src/components/SettingsDialog.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | Dialog,
4 | DialogClose,
5 | DialogContent,
6 | DialogFooter,
7 | DialogHeader,
8 | DialogTitle,
9 | DialogTrigger,
10 | } from "@/components/ui/dialog";
11 | import { FaCog } from "react-icons/fa";
12 | import { EditorTheme, Settings } from "../types";
13 | import { Switch } from "./ui/switch";
14 | import { Label } from "./ui/label";
15 | import { Input } from "./ui/input";
16 | import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select";
17 | import { capitalize } from "../lib/utils";
18 | import { IS_RUNNING_ON_CLOUD } from "../config";
19 | import {
20 | Accordion,
21 | AccordionContent,
22 | AccordionItem,
23 | AccordionTrigger,
24 | } from "./ui/accordion";
25 | import AccessCodeSection from "./settings/AccessCodeSection";
26 |
27 | interface Props {
28 | settings: Settings;
29 | setSettings: React.Dispatch>;
30 | }
31 |
32 | function SettingsDialog({ settings, setSettings }: Props) {
33 | const handleThemeChange = (theme: EditorTheme) => {
34 | setSettings((s) => ({
35 | ...s,
36 | editorTheme: theme,
37 | }));
38 | };
39 |
40 | return (
41 |
42 |
43 |
44 |
45 |
46 |
47 | Settings
48 |
49 |
50 | {/* Access code */}
51 | {IS_RUNNING_ON_CLOUD && (
52 |
53 | )}
54 |
55 |
56 |
57 | DALL-E Placeholder Image Generation
58 |
59 | More fun with it but if you want to save money, turn it off.
60 |
61 |
62 |
66 | setSettings((s) => ({
67 | ...s,
68 | isImageGenerationEnabled: !s.isImageGenerationEnabled,
69 | }))
70 | }
71 | />
72 |
73 |
74 |
75 | OpenAI API key
76 |
77 | Only stored in your browser. Never stored on servers. Overrides
78 | your .env config.
79 |
80 |
81 |
82 |
87 | setSettings((s) => ({
88 | ...s,
89 | openAiApiKey: e.target.value,
90 | }))
91 | }
92 | />
93 |
94 | {!IS_RUNNING_ON_CLOUD && (
95 | <>
96 |
97 | OpenAI Base URL (optional)
98 |
99 | Replace with a proxy URL if you don't want to use the default.
100 |
101 |
102 |
103 |
108 | setSettings((s) => ({
109 | ...s,
110 | openAiBaseURL: e.target.value,
111 | }))
112 | }
113 | />
114 | >
115 | )}
116 |
117 |
118 |
119 | Screenshot by URL Config
120 |
121 |
122 |
133 |
134 |
135 |
141 | setSettings((s) => ({
142 | ...s,
143 | screenshotOneApiKey: e.target.value,
144 | }))
145 | }
146 | />
147 |
148 |
149 |
150 |
151 |
152 |
153 | Theme Settings
154 |
155 |
156 |
157 | App Theme
158 |
159 |
160 | {
163 | document
164 | .querySelector("div.mt-2")
165 | ?.classList.toggle("dark"); // enable dark mode for sidebar
166 | document.body.classList.toggle("dark");
167 | document
168 | .querySelector('div[role="presentation"]')
169 | ?.classList.toggle("dark"); // enable dark mode for upload container
170 | }}
171 | >
172 | Toggle dark mode
173 |
174 |
175 |
176 |
177 |
178 |
179 | Code Editor Theme - requires page refresh to update
180 |
181 |
182 |
183 |
187 | handleThemeChange(value as EditorTheme)
188 | }
189 | >
190 |
191 | {capitalize(settings.editorTheme)}
192 |
193 |
194 | Cobalt
195 | Espresso
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
204 |
205 |
206 | Save
207 |
208 |
209 |
210 | );
211 | }
212 |
213 | export default SettingsDialog;
214 |
--------------------------------------------------------------------------------
/frontend/src/components/Spinner.tsx:
--------------------------------------------------------------------------------
1 | function Spinner() {
2 | return (
3 |
4 |
11 |
15 |
19 |
20 |
Loading...
21 |
22 | );
23 | }
24 |
25 | export default Spinner;
26 |
--------------------------------------------------------------------------------
/frontend/src/components/TermsOfServiceDialog.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | AlertDialog,
4 | AlertDialogAction,
5 | AlertDialogContent,
6 | AlertDialogFooter,
7 | AlertDialogHeader,
8 | AlertDialogTitle,
9 | } from "./ui/alert-dialog";
10 | import { Input } from "./ui/input";
11 | import toast from "react-hot-toast";
12 | import { PICO_BACKEND_FORM_SECRET } from "../config";
13 |
14 | const LOGOS = ["microsoft", "amazon", "mit", "stanford", "bytedance", "baidu"];
15 |
16 | const TermsOfServiceDialog: React.FC<{
17 | open: boolean;
18 | onOpenChange: (open: boolean) => void;
19 | }> = ({ open, onOpenChange }) => {
20 | const [email, setEmail] = React.useState("");
21 |
22 | const onSubscribe = async () => {
23 | await fetch("https://backend.buildpicoapps.com/form", {
24 | method: "POST",
25 | headers: {
26 | "Content-Type": "application/json",
27 | },
28 | body: JSON.stringify({ email, secret: PICO_BACKEND_FORM_SECRET }),
29 | });
30 | };
31 |
32 | return (
33 |
34 |
35 |
36 |
37 | Enter your email to get started
38 |
39 |
40 |
41 |
42 | {
46 | setEmail(e.target.value);
47 | }}
48 | />
49 |
50 |
76 |
77 |
78 | {
80 | if (!email.trim() || !email.trim().includes("@")) {
81 | e.preventDefault();
82 | toast.error("Please enter your email");
83 | } else {
84 | onSubscribe();
85 | }
86 | }}
87 | >
88 | Agree & Continue
89 |
90 |
91 |
92 | {/* Logos */}
93 |
94 |
98 | {LOGOS.map((companyName) => (
99 |
107 | ))}
108 |
109 |
110 | Designers and engineers from these organizations use Screenshot to
111 | Code to build interfaces faster.
112 |
113 |
114 |
115 |
116 | );
117 | };
118 |
119 | export default TermsOfServiceDialog;
120 |
--------------------------------------------------------------------------------
/frontend/src/components/UrlInputSection.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { HTTP_BACKEND_URL } from "../config";
3 | import { Button } from "./ui/button";
4 | import { Input } from "./ui/input";
5 | import { toast } from "react-hot-toast";
6 |
7 | interface Props {
8 | screenshotOneApiKey: string | null;
9 | doCreate: (urls: string[]) => void;
10 | }
11 |
12 | export function UrlInputSection({ doCreate, screenshotOneApiKey }: Props) {
13 | const [isLoading, setIsLoading] = useState(false);
14 | const [referenceUrl, setReferenceUrl] = useState("");
15 |
16 | async function takeScreenshot() {
17 | if (!screenshotOneApiKey) {
18 | toast.error(
19 | "Please add a ScreenshotOne API key in the Settings dialog. This is optional - you can also drag/drop and upload images directly.",
20 | { duration: 8000 }
21 | );
22 | return;
23 | }
24 |
25 | if (!referenceUrl) {
26 | toast.error("Please enter a URL");
27 | return;
28 | }
29 |
30 | if (referenceUrl) {
31 | try {
32 | setIsLoading(true);
33 | const response = await fetch(`${HTTP_BACKEND_URL}/api/screenshot`, {
34 | method: "POST",
35 | body: JSON.stringify({
36 | url: referenceUrl,
37 | apiKey: screenshotOneApiKey,
38 | }),
39 | headers: {
40 | "Content-Type": "application/json",
41 | },
42 | });
43 |
44 | if (!response.ok) {
45 | throw new Error("Failed to capture screenshot");
46 | }
47 |
48 | const res = await response.json();
49 | doCreate([res.url]);
50 | } catch (error) {
51 | console.error(error);
52 | toast.error(
53 | "Failed to capture screenshot. Look at the console and your backend logs for more details."
54 | );
55 | } finally {
56 | setIsLoading(false);
57 | }
58 | }
59 | }
60 |
61 | return (
62 |
63 |
Or screenshot a URL...
64 |
setReferenceUrl(e.target.value)}
67 | value={referenceUrl}
68 | />
69 |
74 | {isLoading ? "Capturing..." : "Capture"}
75 |
76 |
77 | );
78 | }
79 |
--------------------------------------------------------------------------------
/frontend/src/components/evals/EvalsPage.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from "react";
2 | import { HTTP_BACKEND_URL } from "../../config";
3 | import RatingPicker from "./RatingPicker";
4 |
5 | interface Eval {
6 | input: string;
7 | output: string;
8 | }
9 |
10 | function EvalsPage() {
11 | const [evals, setEvals] = React.useState([]);
12 | const [ratings, setRatings] = React.useState([]);
13 |
14 | const total = ratings.reduce((a, b) => a + b, 0);
15 | const max = ratings.length * 4;
16 | const score = ((total / max) * 100 || 0).toFixed(2);
17 |
18 | useEffect(() => {
19 | if (evals.length > 0) return;
20 |
21 | fetch(`${HTTP_BACKEND_URL}/evals`)
22 | .then((res) => res.json())
23 | .then((data) => {
24 | setEvals(data);
25 | setRatings(new Array(data.length).fill(0));
26 | });
27 | }, [evals]);
28 |
29 | return (
30 |
31 | {/* Display total */}
32 |
33 |
34 | Total: {total} out of {max} ({score}%)
35 |
36 |
37 |
38 |
39 | {evals.map((e, index) => (
40 |
41 |
42 |
43 |
44 |
45 |
46 | {/* Put output into an iframe */}
47 |
52 |
53 |
54 |
55 | {
57 | const newRatings = [...ratings];
58 | newRatings[index] = rating;
59 | setRatings(newRatings);
60 | }}
61 | />
62 |
63 |
64 | ))}
65 |
66 |
67 | );
68 | }
69 |
70 | export default EvalsPage;
71 |
--------------------------------------------------------------------------------
/frontend/src/components/evals/RatingPicker.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | interface Props {
4 | onSelect: (rating: number) => void;
5 | }
6 |
7 | function RatingPicker({ onSelect }: Props) {
8 | const [selected, setSelected] = React.useState(null);
9 |
10 | const renderCircle = (number: number) => {
11 | const isSelected = selected === number;
12 | const bgColor = isSelected ? "bg-black" : "bg-gray-300";
13 | const textColor = isSelected ? "text-white" : "text-black";
14 |
15 | return (
16 | {
19 | setSelected(number);
20 | onSelect(number);
21 | }}
22 | >
23 | {number}
24 |
25 | );
26 | };
27 |
28 | return (
29 |
30 | {renderCircle(1)}
31 | {renderCircle(2)}
32 | {renderCircle(3)}
33 | {renderCircle(4)}
34 |
35 | );
36 | }
37 |
38 | export default RatingPicker;
39 |
--------------------------------------------------------------------------------
/frontend/src/components/history/HistoryDisplay.tsx:
--------------------------------------------------------------------------------
1 | import { History } from "./history_types";
2 | import toast from "react-hot-toast";
3 | import classNames from "classnames";
4 |
5 | import { Badge } from "../ui/badge";
6 | import { renderHistory } from "./utils";
7 | import {
8 | Collapsible,
9 | CollapsibleContent,
10 | CollapsibleTrigger,
11 | } from "../ui/collapsible";
12 | import { Button } from "../ui/button";
13 | import { CaretSortIcon } from "@radix-ui/react-icons";
14 |
15 | interface Props {
16 | history: History;
17 | currentVersion: number | null;
18 | revertToVersion: (version: number) => void;
19 | shouldDisableReverts: boolean;
20 | }
21 |
22 | export default function HistoryDisplay({
23 | history,
24 | currentVersion,
25 | revertToVersion,
26 | shouldDisableReverts,
27 | }: Props) {
28 | const renderedHistory = renderHistory(history, currentVersion);
29 |
30 | return renderedHistory.length === 0 ? null : (
31 |
32 |
Versions
33 |
34 | {renderedHistory.map((item, index) => (
35 |
36 |
37 |
47 |
50 | shouldDisableReverts
51 | ? toast.error(
52 | "Please wait for code generation to complete before viewing an older version."
53 | )
54 | : revertToVersion(index)
55 | }
56 | >
57 |
58 |
{item.summary}
59 | {item.parentVersion !== null && (
60 |
61 | (parent: {item.parentVersion})
62 |
63 | )}
64 |
65 |
v{index + 1}
66 |
67 |
68 |
69 |
70 | Toggle
71 |
72 |
73 |
74 |
75 | Full prompt: {item.summary}
76 |
77 | {item.type}
78 |
79 |
80 |
81 |
82 | ))}
83 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/frontend/src/components/history/history_types.ts:
--------------------------------------------------------------------------------
1 | export type HistoryItemType = "ai_create" | "ai_edit" | "code_create";
2 |
3 | type CommonHistoryItem = {
4 | parentIndex: null | number;
5 | code: string;
6 | };
7 |
8 | export type HistoryItem =
9 | | ({
10 | type: "ai_create";
11 | inputs: AiCreateInputs;
12 | } & CommonHistoryItem)
13 | | ({
14 | type: "ai_edit";
15 | inputs: AiEditInputs;
16 | } & CommonHistoryItem)
17 | | ({
18 | type: "code_create";
19 | inputs: CodeCreateInputs;
20 | } & CommonHistoryItem);
21 |
22 | export type AiCreateInputs = {
23 | image_url: string;
24 | };
25 |
26 | export type AiEditInputs = {
27 | prompt: string;
28 | };
29 |
30 | export type CodeCreateInputs = {
31 | code: string;
32 | };
33 |
34 | export type History = HistoryItem[];
35 |
36 | export type RenderedHistoryItem = {
37 | type: string;
38 | summary: string;
39 | parentVersion: string | null;
40 | isActive: boolean;
41 | };
42 |
--------------------------------------------------------------------------------
/frontend/src/components/history/utils.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "vitest";
2 | import { extractHistoryTree, renderHistory } from "./utils";
3 | import type { History } from "./history_types";
4 |
5 | const basicLinearHistory: History = [
6 | {
7 | type: "ai_create",
8 | parentIndex: null,
9 | code: "1. create",
10 | inputs: {
11 | image_url: "",
12 | },
13 | },
14 | {
15 | type: "ai_edit",
16 | parentIndex: 0,
17 | code: "2. edit with better icons",
18 | inputs: {
19 | prompt: "use better icons",
20 | },
21 | },
22 | {
23 | type: "ai_edit",
24 | parentIndex: 1,
25 | code: "3. edit with better icons and red text",
26 | inputs: {
27 | prompt: "make text red",
28 | },
29 | },
30 | ];
31 |
32 | const basicLinearHistoryWithCode: History = [
33 | {
34 | type: "code_create",
35 | parentIndex: null,
36 | code: "1. create",
37 | inputs: {
38 | code: "1. create",
39 | },
40 | },
41 | ...basicLinearHistory.slice(1),
42 | ];
43 |
44 | const basicBranchingHistory: History = [
45 | ...basicLinearHistory,
46 | {
47 | type: "ai_edit",
48 | parentIndex: 1,
49 | code: "4. edit with better icons and green text",
50 | inputs: {
51 | prompt: "make text green",
52 | },
53 | },
54 | ];
55 |
56 | const longerBranchingHistory: History = [
57 | ...basicBranchingHistory,
58 | {
59 | type: "ai_edit",
60 | parentIndex: 3,
61 | code: "5. edit with better icons and green, bold text",
62 | inputs: {
63 | prompt: "make text bold",
64 | },
65 | },
66 | ];
67 |
68 | const basicBadHistory: History = [
69 | {
70 | type: "ai_create",
71 | parentIndex: null,
72 | code: "1. create",
73 | inputs: {
74 | image_url: "",
75 | },
76 | },
77 | {
78 | type: "ai_edit",
79 | parentIndex: 2, // <- Bad parent index
80 | code: "2. edit with better icons",
81 | inputs: {
82 | prompt: "use better icons",
83 | },
84 | },
85 | ];
86 |
87 | test("should correctly extract the history tree", () => {
88 | expect(extractHistoryTree(basicLinearHistory, 2)).toEqual([
89 | "1. create",
90 | "use better icons",
91 | "2. edit with better icons",
92 | "make text red",
93 | "3. edit with better icons and red text",
94 | ]);
95 |
96 | expect(extractHistoryTree(basicLinearHistory, 0)).toEqual([
97 | "1. create",
98 | ]);
99 |
100 | // Test branching
101 | expect(extractHistoryTree(basicBranchingHistory, 3)).toEqual([
102 | "1. create",
103 | "use better icons",
104 | "2. edit with better icons",
105 | "make text green",
106 | "4. edit with better icons and green text",
107 | ]);
108 |
109 | expect(extractHistoryTree(longerBranchingHistory, 4)).toEqual([
110 | "1. create",
111 | "use better icons",
112 | "2. edit with better icons",
113 | "make text green",
114 | "4. edit with better icons and green text",
115 | "make text bold",
116 | "5. edit with better icons and green, bold text",
117 | ]);
118 |
119 | expect(extractHistoryTree(longerBranchingHistory, 2)).toEqual([
120 | "1. create",
121 | "use better icons",
122 | "2. edit with better icons",
123 | "make text red",
124 | "3. edit with better icons and red text",
125 | ]);
126 |
127 | // Errors
128 |
129 | // Bad index
130 | expect(() => extractHistoryTree(basicLinearHistory, 100)).toThrow();
131 | expect(() => extractHistoryTree(basicLinearHistory, -2)).toThrow();
132 |
133 | // Bad tree
134 | expect(() => extractHistoryTree(basicBadHistory, 1)).toThrow();
135 | });
136 |
137 | test("should correctly render the history tree", () => {
138 | expect(renderHistory(basicLinearHistory, 2)).toEqual([
139 | {
140 | isActive: false,
141 | parentVersion: null,
142 | summary: "Create",
143 | type: "Create",
144 | },
145 | {
146 | isActive: false,
147 | parentVersion: null,
148 | summary: "use better icons",
149 | type: "Edit",
150 | },
151 | {
152 | isActive: true,
153 | parentVersion: null,
154 | summary: "make text red",
155 | type: "Edit",
156 | },
157 | ]);
158 |
159 | // Current version is the first version
160 | expect(renderHistory(basicLinearHistory, 0)).toEqual([
161 | {
162 | isActive: true,
163 | parentVersion: null,
164 | summary: "Create",
165 | type: "Create",
166 | },
167 | {
168 | isActive: false,
169 | parentVersion: null,
170 | summary: "use better icons",
171 | type: "Edit",
172 | },
173 | {
174 | isActive: false,
175 | parentVersion: null,
176 | summary: "make text red",
177 | type: "Edit",
178 | },
179 | ]);
180 |
181 | // Render a history with code
182 | expect(renderHistory(basicLinearHistoryWithCode, 0)).toEqual([
183 | {
184 | isActive: true,
185 | parentVersion: null,
186 | summary: "Imported from code",
187 | type: "Imported from code",
188 | },
189 | {
190 | isActive: false,
191 | parentVersion: null,
192 | summary: "use better icons",
193 | type: "Edit",
194 | },
195 | {
196 | isActive: false,
197 | parentVersion: null,
198 | summary: "make text red",
199 | type: "Edit",
200 | },
201 | ]);
202 |
203 | // Render a non-linear history
204 | expect(renderHistory(basicBranchingHistory, 3)).toEqual([
205 | {
206 | isActive: false,
207 | parentVersion: null,
208 | summary: "Create",
209 | type: "Create",
210 | },
211 | {
212 | isActive: false,
213 | parentVersion: null,
214 | summary: "use better icons",
215 | type: "Edit",
216 | },
217 | {
218 | isActive: false,
219 | parentVersion: null,
220 | summary: "make text red",
221 | type: "Edit",
222 | },
223 | {
224 | isActive: true,
225 | parentVersion: "v2",
226 | summary: "make text green",
227 | type: "Edit",
228 | },
229 | ]);
230 | });
231 |
--------------------------------------------------------------------------------
/frontend/src/components/history/utils.ts:
--------------------------------------------------------------------------------
1 | import {
2 | History,
3 | HistoryItem,
4 | HistoryItemType,
5 | RenderedHistoryItem,
6 | } from "./history_types";
7 |
8 | export function extractHistoryTree(
9 | history: History,
10 | version: number
11 | ): string[] {
12 | const flatHistory: string[] = [];
13 |
14 | let currentIndex: number | null = version;
15 | while (currentIndex !== null) {
16 | const item: HistoryItem = history[currentIndex];
17 |
18 | if (item) {
19 | if (item.type === "ai_create") {
20 | // Don't include the image for ai_create
21 | flatHistory.unshift(item.code);
22 | } else if (item.type === "ai_edit") {
23 | flatHistory.unshift(item.code);
24 | flatHistory.unshift(item.inputs.prompt);
25 | } else if (item.type === "code_create") {
26 | flatHistory.unshift(item.code);
27 | }
28 |
29 | // Move to the parent of the current item
30 | currentIndex = item.parentIndex;
31 | } else {
32 | throw new Error("Malformed history: missing parent index");
33 | }
34 | }
35 |
36 | return flatHistory;
37 | }
38 |
39 | function displayHistoryItemType(itemType: HistoryItemType) {
40 | switch (itemType) {
41 | case "ai_create":
42 | return "Create";
43 | case "ai_edit":
44 | return "Edit";
45 | case "code_create":
46 | return "Imported from code";
47 | default: {
48 | const exhaustiveCheck: never = itemType;
49 | throw new Error(`Unhandled case: ${exhaustiveCheck}`);
50 | }
51 | }
52 | }
53 |
54 | function summarizeHistoryItem(item: HistoryItem) {
55 | const itemType = item.type;
56 | switch (itemType) {
57 | case "ai_create":
58 | return "Create";
59 | case "ai_edit":
60 | return item.inputs.prompt;
61 | case "code_create":
62 | return "Imported from code";
63 | default: {
64 | const exhaustiveCheck: never = itemType;
65 | throw new Error(`Unhandled case: ${exhaustiveCheck}`);
66 | }
67 | }
68 | }
69 |
70 | export const renderHistory = (
71 | history: History,
72 | currentVersion: number | null
73 | ) => {
74 | const renderedHistory: RenderedHistoryItem[] = [];
75 |
76 | for (let i = 0; i < history.length; i++) {
77 | const item = history[i];
78 | // Only show the parent version if it's not the previous version
79 | // (i.e. it's the branching point) and if it's not the first version
80 | const parentVersion =
81 | item.parentIndex !== null && item.parentIndex !== i - 1
82 | ? `v${(item.parentIndex || 0) + 1}`
83 | : null;
84 | const type = displayHistoryItemType(item.type);
85 | const isActive = i === currentVersion;
86 | const summary = summarizeHistoryItem(item);
87 | renderedHistory.push({
88 | isActive,
89 | summary: summary,
90 | parentVersion,
91 | type,
92 | });
93 | }
94 |
95 | return renderedHistory;
96 | };
97 |
--------------------------------------------------------------------------------
/frontend/src/components/settings/AccessCodeSection.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { Settings } from "../../types";
3 | import { Button } from "../ui/button";
4 | import { Input } from "../ui/input";
5 | import { Label } from "../ui/label";
6 | import useThrottle from "../../hooks/useThrottle";
7 | import { Progress } from "../ui/progress";
8 | import { PICO_BACKEND_FORM_SECRET } from "../../config";
9 |
10 | interface Props {
11 | settings: Settings;
12 | setSettings: React.Dispatch>;
13 | }
14 |
15 | interface UsageResponse {
16 | used_credits: number;
17 | total_credits: number;
18 | is_valid: boolean;
19 | }
20 |
21 | enum FetchState {
22 | EMPTY = "EMPTY",
23 | LOADING = "LOADING",
24 | INVALID = "INVALID",
25 | VALID = "VALID",
26 | }
27 |
28 | function AccessCodeSection({ settings, setSettings }: Props) {
29 | const [isLoading, setIsLoading] = useState(false);
30 | const [isValid, setIsValid] = useState(false);
31 | const [usedCredits, setUsedCredits] = useState(0);
32 | const [totalCredits, setTotalCredits] = useState(0);
33 | const throttledAccessCode = useThrottle(settings.accessCode || "", 500);
34 |
35 | const fetchState = (() => {
36 | if (!settings.accessCode) return FetchState.EMPTY;
37 | if (isLoading) return FetchState.LOADING;
38 | if (!isValid) return FetchState.INVALID;
39 | return FetchState.VALID;
40 | })();
41 |
42 | async function fetchUsage(accessCode: string) {
43 | const res = await fetch(
44 | "https://backend.buildpicoapps.com/screenshot_to_code/get_access_code_usage",
45 | {
46 | method: "POST",
47 | headers: {
48 | "Content-Type": "application/json",
49 | },
50 | body: JSON.stringify({
51 | access_code: accessCode,
52 | secret: PICO_BACKEND_FORM_SECRET,
53 | }),
54 | }
55 | );
56 | const usage = (await res.json()) as UsageResponse;
57 |
58 | if (!usage.is_valid) {
59 | setIsValid(false);
60 | } else {
61 | setIsValid(true);
62 | setUsedCredits(usage.used_credits);
63 | setTotalCredits(usage.total_credits);
64 | }
65 |
66 | setIsLoading(false);
67 | }
68 |
69 | useEffect(() => {
70 | // Don't do anything if access code is empty
71 | if (!throttledAccessCode) return;
72 |
73 | setIsLoading(true);
74 | setIsValid(true);
75 |
76 | // Wait for 500 ms before fetching usage
77 | setTimeout(async () => {
78 | await fetchUsage(throttledAccessCode);
79 | }, 500);
80 | }, [throttledAccessCode]);
81 |
82 | return (
83 |
84 |
85 | Access Code
86 |
87 |
88 |
94 | setSettings((s) => ({
95 | ...s,
96 | accessCode: e.target.value,
97 | }))
98 | }
99 | />
100 |
101 | {fetchState === "EMPTY" && (
102 |
109 | )}
110 |
111 | {fetchState === "LOADING" && (
112 |
113 | Loading...
114 |
115 | )}
116 |
117 | {fetchState === "INVALID" && (
118 | <>
119 |
120 | Invalid access code
121 |
122 | >
123 | )}
124 |
125 | {fetchState === "VALID" && (
126 | <>
127 |
128 |
136 | >
137 | )}
138 |
139 | );
140 | }
141 |
142 | export default AccessCodeSection;
143 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/accordion.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AccordionPrimitive from "@radix-ui/react-accordion"
3 | import { ChevronDownIcon } from "@radix-ui/react-icons"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const Accordion = AccordionPrimitive.Root
8 |
9 | const AccordionItem = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
18 | ))
19 | AccordionItem.displayName = "AccordionItem"
20 |
21 | const AccordionTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, children, ...props }, ref) => (
25 |
26 | svg]:rotate-180",
30 | className
31 | )}
32 | {...props}
33 | >
34 | {children}
35 |
36 |
37 |
38 | ))
39 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
40 |
41 | const AccordionContent = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef
44 | >(({ className, children, ...props }, ref) => (
45 |
50 | {children}
51 |
52 | ))
53 | AccordionContent.displayName = AccordionPrimitive.Content.displayName
54 |
55 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
56 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
3 |
4 | import { cn } from "@/lib/utils"
5 | import { buttonVariants } from "@/components/ui/button"
6 |
7 | const AlertDialog = AlertDialogPrimitive.Root
8 |
9 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
10 |
11 | const AlertDialogPortal = AlertDialogPrimitive.Portal
12 |
13 | const AlertDialogOverlay = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef
16 | >(({ className, ...props }, ref) => (
17 |
25 | ))
26 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
27 |
28 | const AlertDialogContent = React.forwardRef<
29 | React.ElementRef,
30 | React.ComponentPropsWithoutRef
31 | >(({ className, ...props }, ref) => (
32 |
33 |
34 |
42 |
43 | ))
44 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
45 |
46 | const AlertDialogHeader = ({
47 | className,
48 | ...props
49 | }: React.HTMLAttributes) => (
50 |
57 | )
58 | AlertDialogHeader.displayName = "AlertDialogHeader"
59 |
60 | const AlertDialogFooter = ({
61 | className,
62 | ...props
63 | }: React.HTMLAttributes) => (
64 |
71 | )
72 | AlertDialogFooter.displayName = "AlertDialogFooter"
73 |
74 | const AlertDialogTitle = React.forwardRef<
75 | React.ElementRef,
76 | React.ComponentPropsWithoutRef
77 | >(({ className, ...props }, ref) => (
78 |
83 | ))
84 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
85 |
86 | const AlertDialogDescription = React.forwardRef<
87 | React.ElementRef,
88 | React.ComponentPropsWithoutRef
89 | >(({ className, ...props }, ref) => (
90 |
95 | ))
96 | AlertDialogDescription.displayName =
97 | AlertDialogPrimitive.Description.displayName
98 |
99 | const AlertDialogAction = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
110 |
111 | const AlertDialogCancel = React.forwardRef<
112 | React.ElementRef,
113 | React.ComponentPropsWithoutRef
114 | >(({ className, ...props }, ref) => (
115 |
124 | ))
125 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
126 |
127 | export {
128 | AlertDialog,
129 | AlertDialogPortal,
130 | AlertDialogOverlay,
131 | AlertDialogTrigger,
132 | AlertDialogContent,
133 | AlertDialogHeader,
134 | AlertDialogFooter,
135 | AlertDialogTitle,
136 | AlertDialogDescription,
137 | AlertDialogAction,
138 | AlertDialogCancel,
139 | }
140 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/badge.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { cva, type VariantProps } from "class-variance-authority"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const badgeVariants = cva(
7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
8 | {
9 | variants: {
10 | variant: {
11 | default:
12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
13 | secondary:
14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
15 | destructive:
16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
17 | outline: "text-foreground",
18 | },
19 | },
20 | defaultVariants: {
21 | variant: "default",
22 | },
23 | }
24 | )
25 |
26 | export interface BadgeProps
27 | extends React.HTMLAttributes,
28 | VariantProps {}
29 |
30 | function Badge({ className, variant, ...props }: BadgeProps) {
31 | return (
32 |
33 | )
34 | }
35 |
36 | export { Badge, badgeVariants }
37 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Slot } from "@radix-ui/react-slot";
3 | import { cva, type VariantProps } from "class-variance-authority";
4 |
5 | import { cn } from "@/lib/utils";
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | );
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean;
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button";
46 | return (
47 |
52 | );
53 | }
54 | );
55 | Button.displayName = "Button";
56 |
57 | export { Button, buttonVariants };
58 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
3 | import { CheckIcon } from "@radix-ui/react-icons"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const Checkbox = React.forwardRef<
8 | React.ElementRef,
9 | React.ComponentPropsWithoutRef
10 | >(({ className, ...props }, ref) => (
11 |
19 |
22 |
23 |
24 |
25 | ))
26 | Checkbox.displayName = CheckboxPrimitive.Root.displayName
27 |
28 | export { Checkbox }
29 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/collapsible.tsx:
--------------------------------------------------------------------------------
1 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
2 |
3 | const Collapsible = CollapsiblePrimitive.Root
4 |
5 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
6 |
7 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
8 |
9 | export { Collapsible, CollapsibleTrigger, CollapsibleContent }
10 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as DialogPrimitive from "@radix-ui/react-dialog"
3 | import { Cross2Icon } from "@radix-ui/react-icons"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const Dialog = DialogPrimitive.Root
8 |
9 | const DialogTrigger = DialogPrimitive.Trigger
10 |
11 | const DialogPortal = DialogPrimitive.Portal
12 |
13 | const DialogClose = DialogPrimitive.Close
14 |
15 | const DialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
29 |
30 | const DialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, children, ...props }, ref) => (
34 |
35 |
36 |
44 | {children}
45 |
46 |
47 | Close
48 |
49 |
50 |
51 | ))
52 | DialogContent.displayName = DialogPrimitive.Content.displayName
53 |
54 | const DialogHeader = ({
55 | className,
56 | ...props
57 | }: React.HTMLAttributes) => (
58 |
65 | )
66 | DialogHeader.displayName = "DialogHeader"
67 |
68 | const DialogFooter = ({
69 | className,
70 | ...props
71 | }: React.HTMLAttributes) => (
72 |
79 | )
80 | DialogFooter.displayName = "DialogFooter"
81 |
82 | const DialogTitle = React.forwardRef<
83 | React.ElementRef,
84 | React.ComponentPropsWithoutRef
85 | >(({ className, ...props }, ref) => (
86 |
94 | ))
95 | DialogTitle.displayName = DialogPrimitive.Title.displayName
96 |
97 | const DialogDescription = React.forwardRef<
98 | React.ElementRef,
99 | React.ComponentPropsWithoutRef
100 | >(({ className, ...props }, ref) => (
101 |
106 | ))
107 | DialogDescription.displayName = DialogPrimitive.Description.displayName
108 |
109 | export {
110 | Dialog,
111 | DialogPortal,
112 | DialogOverlay,
113 | DialogTrigger,
114 | DialogClose,
115 | DialogContent,
116 | DialogHeader,
117 | DialogFooter,
118 | DialogTitle,
119 | DialogDescription,
120 | }
121 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/hover-card.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const HoverCard = HoverCardPrimitive.Root
7 |
8 | const HoverCardTrigger = HoverCardPrimitive.Trigger
9 |
10 | const HoverCardContent = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
14 |
24 | ))
25 | HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
26 |
27 | export { HoverCard, HoverCardTrigger, HoverCardContent }
28 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const labelVariants = cva(
8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
9 | )
10 |
11 | const Label = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef &
14 | VariantProps
15 | >(({ className, ...props }, ref) => (
16 |
21 | ))
22 | Label.displayName = LabelPrimitive.Root.displayName
23 |
24 | export { Label }
25 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as PopoverPrimitive from "@radix-ui/react-popover"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Popover = PopoverPrimitive.Root
7 |
8 | const PopoverTrigger = PopoverPrimitive.Trigger
9 |
10 | const PopoverContent = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
14 |
15 |
25 |
26 | ))
27 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
28 |
29 | export { Popover, PopoverTrigger, PopoverContent }
30 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as ProgressPrimitive from "@radix-ui/react-progress"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Progress = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, value, ...props }, ref) => (
10 |
18 |
22 |
23 | ))
24 | Progress.displayName = ProgressPrimitive.Root.displayName
25 |
26 | export { Progress }
27 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/scroll-area.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const ScrollArea = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, children, ...props }, ref) => (
10 |
15 |
16 | {children}
17 |
18 |
19 |
20 |
21 | ))
22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
23 |
24 | const ScrollBar = React.forwardRef<
25 | React.ElementRef,
26 | React.ComponentPropsWithoutRef
27 | >(({ className, orientation = "vertical", ...props }, ref) => (
28 |
41 |
42 |
43 | ))
44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
45 |
46 | export { ScrollArea, ScrollBar }
47 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/select.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import {
3 | CaretSortIcon,
4 | CheckIcon,
5 | ChevronDownIcon,
6 | ChevronUpIcon,
7 | } from "@radix-ui/react-icons"
8 | import * as SelectPrimitive from "@radix-ui/react-select"
9 |
10 | import { cn } from "@/lib/utils"
11 |
12 | const Select = SelectPrimitive.Root
13 |
14 | const SelectGroup = SelectPrimitive.Group
15 |
16 | const SelectValue = SelectPrimitive.Value
17 |
18 | const SelectTrigger = React.forwardRef<
19 | React.ElementRef,
20 | React.ComponentPropsWithoutRef
21 | >(({ className, children, ...props }, ref) => (
22 | span]:line-clamp-1",
26 | className
27 | )}
28 | {...props}
29 | >
30 | {children}
31 |
32 |
33 |
34 |
35 | ))
36 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
37 |
38 | const SelectScrollUpButton = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 |
51 |
52 | ))
53 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
54 |
55 | const SelectScrollDownButton = React.forwardRef<
56 | React.ElementRef,
57 | React.ComponentPropsWithoutRef
58 | >(({ className, ...props }, ref) => (
59 |
67 |
68 |
69 | ))
70 | SelectScrollDownButton.displayName =
71 | SelectPrimitive.ScrollDownButton.displayName
72 |
73 | const SelectContent = React.forwardRef<
74 | React.ElementRef,
75 | React.ComponentPropsWithoutRef
76 | >(({ className, children, position = "popper", ...props }, ref) => (
77 |
78 |
89 |
90 |
97 | {children}
98 |
99 |
100 |
101 |
102 | ))
103 | SelectContent.displayName = SelectPrimitive.Content.displayName
104 |
105 | const SelectLabel = React.forwardRef<
106 | React.ElementRef,
107 | React.ComponentPropsWithoutRef
108 | >(({ className, ...props }, ref) => (
109 |
114 | ))
115 | SelectLabel.displayName = SelectPrimitive.Label.displayName
116 |
117 | const SelectItem = React.forwardRef<
118 | React.ElementRef,
119 | React.ComponentPropsWithoutRef
120 | >(({ className, children, ...props }, ref) => (
121 |
129 |
130 |
131 |
132 |
133 |
134 | {children}
135 |
136 | ))
137 | SelectItem.displayName = SelectPrimitive.Item.displayName
138 |
139 | const SelectSeparator = React.forwardRef<
140 | React.ElementRef,
141 | React.ComponentPropsWithoutRef
142 | >(({ className, ...props }, ref) => (
143 |
148 | ))
149 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName
150 |
151 | export {
152 | Select,
153 | SelectGroup,
154 | SelectValue,
155 | SelectTrigger,
156 | SelectContent,
157 | SelectLabel,
158 | SelectItem,
159 | SelectSeparator,
160 | SelectScrollUpButton,
161 | SelectScrollDownButton,
162 | }
163 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as SeparatorPrimitive from "@radix-ui/react-separator"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Separator = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(
10 | (
11 | { className, orientation = "horizontal", decorative = true, ...props },
12 | ref
13 | ) => (
14 |
25 | )
26 | )
27 | Separator.displayName = SeparatorPrimitive.Root.displayName
28 |
29 | export { Separator }
30 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import * as SwitchPrimitives from "@radix-ui/react-switch";
3 |
4 | import { cn } from "@/lib/utils";
5 |
6 | const Switch = React.forwardRef<
7 | React.ElementRef,
8 | React.ComponentPropsWithoutRef
9 | >(({ className, ...props }, ref) => (
10 |
18 |
23 |
24 | ));
25 | Switch.displayName = SwitchPrimitives.Root.displayName;
26 |
27 | export { Switch };
28 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TabsPrimitive from "@radix-ui/react-tabs"
3 |
4 | import { cn } from "@/lib/utils"
5 |
6 | const Tabs = TabsPrimitive.Root
7 |
8 | const TabsList = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | TabsList.displayName = TabsPrimitive.List.displayName
22 |
23 | const TabsTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
35 | ))
36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
37 |
38 | const TabsContent = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 | ))
51 | TabsContent.displayName = TabsPrimitive.Content.displayName
52 |
53 | export { Tabs, TabsList, TabsTrigger, TabsContent }
54 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/frontend/src/config.ts:
--------------------------------------------------------------------------------
1 | // Default to false if set to anything other than "true" or unset
2 | export const IS_RUNNING_ON_CLOUD =
3 | import.meta.env.VITE_IS_DEPLOYED === "true" || false;
4 |
5 | export const WS_BACKEND_URL =
6 | import.meta.env.VITE_WS_BACKEND_URL || "ws://127.0.0.1:7001";
7 |
8 | export const HTTP_BACKEND_URL =
9 | import.meta.env.VITE_HTTP_BACKEND_URL || "http://127.0.0.1:7001";
10 |
11 | export const PICO_BACKEND_FORM_SECRET =
12 | import.meta.env.VITE_PICO_BACKEND_FORM_SECRET || null;
13 |
--------------------------------------------------------------------------------
/frontend/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const USER_CLOSE_WEB_SOCKET_CODE = 4333;
2 |
--------------------------------------------------------------------------------
/frontend/src/generateCode.ts:
--------------------------------------------------------------------------------
1 | import toast from "react-hot-toast";
2 | import { WS_BACKEND_URL } from "./config";
3 | import { USER_CLOSE_WEB_SOCKET_CODE } from "./constants";
4 | import { FullGenerationSettings } from "./types";
5 |
6 | const ERROR_MESSAGE =
7 | "Error generating code. Check the Developer Console AND the backend logs for details. Feel free to open a Github issue.";
8 |
9 | const CANCEL_MESSAGE = "Code generation cancelled";
10 |
11 | export function generateCode(
12 | wsRef: React.MutableRefObject,
13 | params: FullGenerationSettings,
14 | onChange: (chunk: string) => void,
15 | onSetCode: (code: string) => void,
16 | onStatusUpdate: (status: string) => void,
17 | onCancel: () => void,
18 | onComplete: () => void
19 | ) {
20 | const wsUrl = `${WS_BACKEND_URL}/generate-code`;
21 | console.log("Connecting to backend @ ", wsUrl);
22 |
23 | const ws = new WebSocket(wsUrl);
24 | wsRef.current = ws;
25 |
26 | ws.addEventListener("open", () => {
27 | ws.send(JSON.stringify(params));
28 | });
29 |
30 | ws.addEventListener("message", async (event: MessageEvent) => {
31 | const response = JSON.parse(event.data);
32 | if (response.type === "chunk") {
33 | onChange(response.value);
34 | } else if (response.type === "status") {
35 | onStatusUpdate(response.value);
36 | } else if (response.type === "setCode") {
37 | onSetCode(response.value);
38 | } else if (response.type === "error") {
39 | console.error("Error generating code", response.value);
40 | toast.error(response.value);
41 | }
42 | });
43 |
44 | ws.addEventListener("close", (event) => {
45 | console.log("Connection closed", event.code, event.reason);
46 | if (event.code === USER_CLOSE_WEB_SOCKET_CODE) {
47 | toast.success(CANCEL_MESSAGE);
48 | onCancel();
49 | } else if (event.code !== 1000) {
50 | console.error("WebSocket error code", event);
51 | toast.error(ERROR_MESSAGE);
52 | } else {
53 | onComplete();
54 | }
55 | });
56 |
57 | ws.addEventListener("error", (error) => {
58 | console.error("WebSocket error", error);
59 | toast.error(ERROR_MESSAGE);
60 | });
61 | }
62 |
--------------------------------------------------------------------------------
/frontend/src/hooks/usePersistedState.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction, useEffect, useState } from 'react';
2 |
3 | type PersistedState = [T, Dispatch>];
4 |
5 | function usePersistedState(defaultValue: T, key: string): PersistedState {
6 | const [value, setValue] = useState(() => {
7 | const value = window.localStorage.getItem(key);
8 |
9 | return value ? (JSON.parse(value) as T) : defaultValue;
10 | });
11 |
12 | useEffect(() => {
13 | window.localStorage.setItem(key, JSON.stringify(value));
14 | }, [key, value]);
15 |
16 | return [value, setValue];
17 | }
18 |
19 | export { usePersistedState };
20 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useThrottle.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | // Updates take effect immediately if the last update was more than {interval} ago.
4 | // Otherwise, updates are throttled to {interval}. The latest value is always sent.
5 | // The last update always gets executed, with potentially a {interval} delay.
6 | export function useThrottle(value: string, interval = 500) {
7 | const [throttledValue, setThrottledValue] = React.useState(value);
8 | const lastUpdated = React.useRef(null);
9 |
10 | React.useEffect(() => {
11 | const now = performance.now();
12 |
13 | if (!lastUpdated.current || now >= lastUpdated.current + interval) {
14 | lastUpdated.current = now;
15 | setThrottledValue(value);
16 | } else {
17 | const id = window.setTimeout(() => {
18 | lastUpdated.current = now;
19 | setThrottledValue(value);
20 | }, interval);
21 |
22 | return () => window.clearTimeout(id);
23 | }
24 | }, [value, interval]);
25 |
26 | return throttledValue;
27 | }
28 | export default useThrottle;
29 |
--------------------------------------------------------------------------------
/frontend/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | /* Image Scanning animation */
6 | .scanning::after {
7 | content: "";
8 | position: absolute;
9 | top: 0px;
10 | left: 0px;
11 | width: 5px;
12 | height: 100%;
13 | background-image: linear-gradient(
14 | to right,
15 | rgba(19, 161, 14, 0.2),
16 | /* Darker matrix green with full transparency */ rgba(19, 161, 14, 0.8)
17 | /* The same green with 80% opacity */
18 | );
19 | animation: scanning 3s ease-in-out infinite;
20 | }
21 |
22 | @keyframes scanning {
23 | 0%,
24 | 100% {
25 | transform: translateX(0px);
26 | }
27 | 50% {
28 | transform: translateX(340px);
29 | }
30 | }
31 |
32 | @layer base {
33 | :root {
34 | --background: 0 0% 100%;
35 | --foreground: 222.2 84% 4.9%;
36 |
37 | --card: 0 0% 100%;
38 | --card-foreground: 222.2 84% 4.9%;
39 |
40 | --popover: 0 0% 100%;
41 | --popover-foreground: 222.2 84% 4.9%;
42 |
43 | --primary: 222.2 47.4% 11.2%;
44 | --primary-foreground: 210 40% 98%;
45 |
46 | --secondary: 210 40% 96.1%;
47 | --secondary-foreground: 222.2 47.4% 11.2%;
48 |
49 | --muted: 210 40% 96.1%;
50 | --muted-foreground: 215.4 16.3% 46.9%;
51 |
52 | --accent: 210 40% 96.1%;
53 | --accent-foreground: 222.2 47.4% 11.2%;
54 |
55 | --destructive: 0 84.2% 60.2%;
56 | --destructive-foreground: 210 40% 98%;
57 |
58 | --border: 214.3 31.8% 91.4%;
59 | --input: 214.3 31.8% 91.4%;
60 | --ring: 222.2 84% 4.9%;
61 |
62 | --radius: 0.5rem;
63 | }
64 |
65 | body.dark {
66 | background-color: black;
67 | }
68 |
69 | div[role="presentation"].dark {
70 | background-color: #09090b !important;
71 | }
72 |
73 | iframe {
74 | background-color: white !important;
75 | }
76 |
77 | .dark {
78 | --background: 222.2 0% 0%;
79 | --foreground: 210 40% 98%;
80 |
81 | --card: 222.2 84% 4.9%;
82 | --card-foreground: 210 40% 98%;
83 |
84 | --popover: 222.2 84% 4.9%;
85 | --popover-foreground: 210 40% 98%;
86 |
87 | --primary: 210 40% 98%;
88 | --primary-foreground: 222.2 47.4% 11.2%;
89 |
90 | --secondary: 217.2 32.6% 17.5%;
91 | --secondary-foreground: 210 40% 98%;
92 |
93 | --muted: 217.2 32.6% 17.5%;
94 | --muted-foreground: 215 20.2% 65.1%;
95 |
96 | --accent: 217.2 32.6% 17.5%;
97 | --accent-foreground: 210 40% 98%;
98 |
99 | --destructive: 0 62.8% 30.6%;
100 | --destructive-foreground: 210 40% 98%;
101 |
102 | --border: 217.2 32.6% 17.5%;
103 | --input: 217.2 32.6% 17.5%;
104 | --ring: 212.7 26.8% 83.9%;
105 | }
106 | }
107 |
108 | @layer base {
109 | * {
110 | @apply border-border;
111 | }
112 | body {
113 | @apply bg-background text-foreground;
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/frontend/src/lib/stacks.ts:
--------------------------------------------------------------------------------
1 | // Keep in sync with backend (prompts/types.py)
2 | export enum Stack {
3 | HTML_TAILWIND = "html_tailwind",
4 | REACT_TAILWIND = "react_tailwind",
5 | BOOTSTRAP = "bootstrap",
6 | VUE_TAILWIND = "vue_tailwind",
7 | IONIC_TAILWIND = "ionic_tailwind",
8 | SVG = "svg",
9 | }
10 |
11 | export const STACK_DESCRIPTIONS: {
12 | [key in Stack]: { components: string[]; inBeta: boolean };
13 | } = {
14 | html_tailwind: { components: ["HTML", "Tailwind"], inBeta: false },
15 | react_tailwind: { components: ["React", "Tailwind"], inBeta: false },
16 | bootstrap: { components: ["Bootstrap"], inBeta: false },
17 | vue_tailwind: { components: ["Vue", "Tailwind"], inBeta: true },
18 | ionic_tailwind: { components: ["Ionic", "Tailwind"], inBeta: true },
19 | svg: { components: ["SVG"], inBeta: true },
20 | };
21 |
--------------------------------------------------------------------------------
/frontend/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export function capitalize(str: string) {
9 | return str.charAt(0).toUpperCase() + str.slice(1);
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App.tsx";
4 | import "./index.css";
5 | import { Toaster } from "react-hot-toast";
6 | import EvalsPage from "./components/evals/EvalsPage.tsx";
7 | import { BrowserRouter as Router, Routes, Route } from "react-router-dom";
8 |
9 | ReactDOM.createRoot(document.getElementById("root")!).render(
10 |
11 |
12 |
13 | } />
14 | } />
15 |
16 |
17 |
18 |
19 | );
20 |
--------------------------------------------------------------------------------
/frontend/src/types.ts:
--------------------------------------------------------------------------------
1 | import { Stack } from "./lib/stacks";
2 |
3 | export enum EditorTheme {
4 | ESPRESSO = "espresso",
5 | COBALT = "cobalt",
6 | }
7 |
8 | export interface Settings {
9 | openAiApiKey: string | null;
10 | openAiBaseURL: string | null;
11 | screenshotOneApiKey: string | null;
12 | isImageGenerationEnabled: boolean;
13 | editorTheme: EditorTheme;
14 | generatedCodeConfig: Stack;
15 | // Only relevant for hosted version
16 | isTermOfServiceAccepted: boolean;
17 | accessCode: string | null;
18 | }
19 |
20 | export enum AppState {
21 | INITIAL = "INITIAL",
22 | CODING = "CODING",
23 | CODE_READY = "CODE_READY",
24 | }
25 |
26 | export interface CodeGenerationParams {
27 | generationType: "create" | "update";
28 | image: string;
29 | resultImage?: string;
30 | history?: string[];
31 | isImportedFromCode?: boolean;
32 | }
33 |
34 | export type FullGenerationSettings = CodeGenerationParams & Settings;
35 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | extend: {
19 | colors: {
20 | button: "#ffd803",
21 | highlight: "#ffd803",
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: 0 },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: 0 },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | };
79 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | /* Bundler mode */
10 | "moduleResolution": "bundler",
11 | "allowImportingTsExtensions": true,
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "noEmit": true,
15 | "jsx": "react-jsx",
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "noFallthroughCasesInSwitch": true,
22 |
23 | "baseUrl": ".",
24 | "paths": {
25 | "@/*": ["./src/*"]
26 | }
27 | },
28 | "include": ["src"],
29 | "references": [{ "path": "./tsconfig.node.json" }]
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/vite.config.ts:
--------------------------------------------------------------------------------
1 | import path from "path";
2 | import { defineConfig, loadEnv } from "vite";
3 | import checker from "vite-plugin-checker";
4 | import react from "@vitejs/plugin-react";
5 | import { createHtmlPlugin } from "vite-plugin-html";
6 |
7 | // https://vitejs.dev/config/
8 | export default ({ mode }) => {
9 | process.env = { ...process.env, ...loadEnv(mode, process.cwd()) };
10 | return defineConfig({
11 | base: "",
12 | plugins: [
13 | react(),
14 | checker({ typescript: true }),
15 | createHtmlPlugin({
16 | inject: {
17 | data: {
18 | injectHead: process.env.VITE_IS_DEPLOYED
19 | ? ''
20 | : "",
21 | },
22 | },
23 | }),
24 | ],
25 | resolve: {
26 | alias: {
27 | "@": path.resolve(__dirname, "./src"),
28 | },
29 | },
30 | });
31 | };
32 |
--------------------------------------------------------------------------------
/sweep.yaml:
--------------------------------------------------------------------------------
1 | # Sweep AI turns bugs & feature requests into code changes (https://sweep.dev)
2 | # For details on our config file, check out our docs at https://docs.sweep.dev/usage/config
3 |
4 | # This setting contains a list of rules that Sweep will check for. If any of these rules are broken in a new commit, Sweep will create an pull request to fix the broken rule.
5 | rules:
6 | - "All docstrings and comments should be up to date."
7 | ['All new business logic should have corresponding unit tests.', 'Refactor large functions to be more modular.', 'Add docstrings to all functions and file headers.']
8 |
9 | # This is the branch that Sweep will develop from and make pull requests to. Most people use 'main' or 'master' but some users also use 'dev' or 'staging'.
10 | branch: 'main'
11 |
12 | # By default Sweep will read the logs and outputs from your existing Github Actions. To disable this, set this to false.
13 | gha_enabled: True
14 |
15 | # This is the description of your project. It will be used by sweep when creating PRs. You can tell Sweep what's unique about your project, what frameworks you use, or anything else you want.
16 | #
17 | # Example:
18 | #
19 | # description: sweepai/sweep is a python project. The main api endpoints are in sweepai/api.py. Write code that adheres to PEP8.
20 | description: ''
21 |
22 | # This sets whether to create pull requests as drafts. If this is set to True, then all pull requests will be created as drafts and GitHub Actions will not be triggered.
23 | draft: False
24 |
25 | # This is a list of directories that Sweep will not be able to edit.
26 | blocked_dirs: []
27 |
28 | # This is a list of documentation links that Sweep will use to help it understand your code. You can add links to documentation for any packages you use here.
29 | #
30 | # Example:
31 | #
32 | # docs:
33 | # - PyGitHub: ["https://pygithub.readthedocs.io/en/latest/", "We use pygithub to interact with the GitHub API"]
34 | docs: []
35 |
36 | # Sandbox executes commands in a sandboxed environment to validate code changes after every edit to guarantee pristine code. For more details, see the [Sandbox](./sandbox) page.
37 | sandbox:
38 | install:
39 | - trunk init
40 | check:
41 | - trunk fmt {file_path} || return 0
42 | - trunk check --fix --print-failures {file_path}
43 |
--------------------------------------------------------------------------------