diff --git a/Troubleshooting.md b/Troubleshooting.md index 0704859..20fa815 100644 --- a/Troubleshooting.md +++ b/Troubleshooting.md @@ -1,4 +1,4 @@ -### Getting an OpenAI API key +### Getting an OpenAI API key with GPT4-Vision model access 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: @@ -10,6 +10,7 @@ You don't need a ChatGPT Pro account. Screenshot to code uses API keys from your 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 285636973-da38bd4d-8a78-4904-8027-ca67d729b933 +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. Some users have also reported that it can take upto 30 minutes after your credit purchase for the GPT4 vision model to be activated. diff --git a/backend/access_token.py b/backend/access_token.py new file mode 100644 index 0000000..e61ef12 --- /dev/null +++ b/backend/access_token.py @@ -0,0 +1,27 @@ +import json +import os +import httpx + + +async def validate_access_token(access_code: str): + async with httpx.AsyncClient() as client: + url = ( + "https://backend.buildpicoapps.com/screenshot_to_code/validate_access_token" + ) + data = json.dumps( + { + "access_code": access_code, + "secret": os.environ.get("PICO_BACKEND_SECRET"), + } + ) + headers = {"Content-Type": "application/json"} + + response = await client.post(url, content=data, headers=headers) + response_data = response.json() + + if response_data["success"]: + print("Access token is valid.") + return True + else: + print(f"Access token validation failed: {response_data['failure_reason']}") + return False diff --git a/backend/image_generation.py b/backend/image_generation.py index 080334f..ad21772 100644 --- a/backend/image_generation.py +++ b/backend/image_generation.py @@ -5,8 +5,8 @@ from openai import AsyncOpenAI from bs4 import BeautifulSoup -async def process_tasks(prompts, api_key): - tasks = [generate_image(prompt, api_key) for prompt in prompts] +async def process_tasks(prompts, api_key, base_url): + tasks = [generate_image(prompt, api_key, base_url) for prompt in prompts] results = await asyncio.gather(*tasks, return_exceptions=True) processed_results = [] @@ -20,8 +20,8 @@ async def process_tasks(prompts, api_key): return processed_results -async def generate_image(prompt, api_key): - client = AsyncOpenAI(api_key=api_key) +async def generate_image(prompt, api_key, base_url): + client = AsyncOpenAI(api_key=api_key, base_url=base_url) image_params = { "model": "dall-e-3", "quality": "standard", @@ -60,7 +60,7 @@ def create_alt_url_mapping(code): return mapping -async def generate_images(code, api_key, image_cache): +async def generate_images(code, api_key, base_url, image_cache): # Find all images soup = BeautifulSoup(code, "html.parser") images = soup.find_all("img") @@ -87,7 +87,7 @@ async def generate_images(code, api_key, image_cache): return code # Generate images - results = await process_tasks(prompts, api_key) + results = await process_tasks(prompts, api_key, base_url) # Create a dict mapping alt text to image URL mapped_image_urls = dict(zip(prompts, results)) diff --git a/backend/llm.py b/backend/llm.py index b52c3c9..fdb1ba0 100644 --- a/backend/llm.py +++ b/backend/llm.py @@ -6,9 +6,12 @@ MODEL_GPT_4_VISION = "gpt-4-vision-preview" async def stream_openai_response( - messages, api_key: str, callback: Callable[[str], Awaitable[None]] + messages, + api_key: str, + base_url: str | None, + callback: Callable[[str], Awaitable[None]], ): - client = AsyncOpenAI(api_key=api_key) + client = AsyncOpenAI(api_key=api_key, base_url=base_url) model = MODEL_GPT_4_VISION diff --git a/backend/main.py b/backend/main.py index b8e3f4f..2808a6d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,6 +1,5 @@ # Load environment variables first from dotenv import load_dotenv -from pydantic import BaseModel load_dotenv() @@ -16,6 +15,7 @@ from mock import mock_completion from image_generation import create_alt_url_mapping, generate_images from prompts import assemble_prompt from routes import screenshot +from access_token import validate_access_token app = FastAPI(openapi_url=None, docs_url=None, redoc_url=None) @@ -81,13 +81,27 @@ async def stream_code(websocket: WebSocket): # Get the OpenAI API key from the request. Fall back to environment variable if not provided. # If neither is provided, we throw an error. - if params["openAiApiKey"]: - openai_api_key = params["openAiApiKey"] - print("Using OpenAI API key from client-side settings dialog") + openai_api_key = None + if "accessCode" in params and params["accessCode"]: + print("Access code - using platform API key") + if await validate_access_token(params["accessCode"]): + openai_api_key = os.environ.get("PLATFORM_OPENAI_API_KEY") + else: + await websocket.send_json( + { + "type": "error", + "value": "Invalid access code or you're out of credits. Please try again.", + } + ) + return else: - openai_api_key = os.environ.get("OPENAI_API_KEY") - if openai_api_key: - print("Using OpenAI API key from environment variable") + if params["openAiApiKey"]: + openai_api_key = params["openAiApiKey"] + print("Using OpenAI API key from client-side settings dialog") + else: + openai_api_key = os.environ.get("OPENAI_API_KEY") + if openai_api_key: + print("Using OpenAI API key from environment variable") if not openai_api_key: print("OpenAI API key not found") @@ -99,6 +113,22 @@ async def stream_code(websocket: WebSocket): ) return + # Get the OpenAI Base URL from the request. Fall back to environment variable if not provided. + openai_base_url = None + # Disable user-specified OpenAI Base URL in prod + if not os.environ.get("IS_PROD"): + if "openAiBaseURL" in params and params["openAiBaseURL"]: + openai_base_url = params["openAiBaseURL"] + print("Using OpenAI Base URL from client-side settings dialog") + else: + openai_base_url = os.environ.get("OPENAI_BASE_URL") + if openai_base_url: + print("Using OpenAI Base URL from environment variable") + + if not openai_base_url: + print("Using official OpenAI URL") + + # Get the image generation flag from the request. Fall back to True if not provided. should_generate_images = ( params["isImageGenerationEnabled"] if "isImageGenerationEnabled" in params @@ -137,6 +167,7 @@ async def stream_code(websocket: WebSocket): completion = await stream_openai_response( prompt_messages, api_key=openai_api_key, + base_url=openai_base_url, callback=lambda x: process_chunk(x), ) @@ -149,7 +180,10 @@ async def stream_code(websocket: WebSocket): {"type": "status", "value": "Generating images..."} ) updated_html = await generate_images( - completion, api_key=openai_api_key, image_cache=image_cache + completion, + api_key=openai_api_key, + base_url=openai_base_url, + image_cache=image_cache, ) else: updated_html = completion diff --git a/frontend/index.html b/frontend/index.html index f747064..2a7fa0e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -21,6 +21,34 @@ <%- injectHead %> Screenshot to Code + + + + + + + + + + + + + + + +
diff --git a/frontend/package.json b/frontend/package.json index 0d180b6..476a72d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", diff --git a/frontend/public/brand/twitter-summary-card.png b/frontend/public/brand/twitter-summary-card.png new file mode 100644 index 0000000..6fcedbf Binary files /dev/null and b/frontend/public/brand/twitter-summary-card.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 060a6f3..58e07e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -48,17 +48,19 @@ function App() { const [settings, setSettings] = usePersistedState( { openAiApiKey: null, + openAiBaseURL: null, screenshotOneApiKey: null, isImageGenerationEnabled: true, editorTheme: EditorTheme.COBALT, isTermOfServiceAccepted: false, + accessCode: null, }, "setting" ); const [outputSettings, setOutputSettings] = useState({ css: CSSOption.TAILWIND, - js: JSFrameworkOption.VANILLA, components: UIComponentOption.HTML, + js: JSFrameworkOption.NO_FRAMEWORK, }); const [shouldIncludeResultImage, setShouldIncludeResultImage] = useState(false); @@ -172,35 +174,33 @@ function App() { }; return ( -
- {IS_RUNNING_ON_CLOUD && } +
+ {IS_RUNNING_ON_CLOUD && } {IS_RUNNING_ON_CLOUD && ( )} -
-
-
+
+

Screenshot to Code

- {appState === AppState.INITIAL && ( -

- Drag & drop a screenshot to get started. -

- )} - {appState === AppState.INITIAL && ( - - )} + - {IS_RUNNING_ON_CLOUD && !settings.openAiApiKey && } + {IS_RUNNING_ON_CLOUD && + !(settings.openAiApiKey || settings.accessCode) && ( + + )} {(appState === AppState.CODING || appState === AppState.CODE_READY) && ( @@ -213,7 +213,10 @@ function App() { {executionConsole.slice(-1)[0]}
-
@@ -230,26 +233,32 @@ function App() { value={updateInstruction} />
-
+
Include screenshot of current version?
- +
+ + +
+
+

Code Settings

+

+ Customize your code output +

+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + )} +
); } diff --git a/frontend/src/components/PicoBadge.tsx b/frontend/src/components/PicoBadge.tsx index bdd0083..70a4f20 100644 --- a/frontend/src/components/PicoBadge.tsx +++ b/frontend/src/components/PicoBadge.tsx @@ -1,4 +1,6 @@ -export function PicoBadge() { +import { Settings } from "../types"; + +export function PicoBadge({ settings }: { settings: Settings }) { return ( <> - -
+
- an open source project by Pico -
-
+ > + an open source project by Pico +
+ + )} + {settings.accessCode && ( + +
+ email support +
+
+ )} ); } diff --git a/frontend/src/components/SettingsDialog.tsx b/frontend/src/components/SettingsDialog.tsx index 215b0ea..1afa02e 100644 --- a/frontend/src/components/SettingsDialog.tsx +++ b/frontend/src/components/SettingsDialog.tsx @@ -15,6 +15,13 @@ import { Label } from "./ui/label"; import { Input } from "./ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger } from "./ui/select"; import { capitalize } from "../lib/utils"; +import { IS_RUNNING_ON_CLOUD } from "../config"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "./ui/accordion"; interface Props { settings: Settings; @@ -38,6 +45,31 @@ function SettingsDialog({ settings, setSettings }: Props) { Settings + + {/* Access code */} + {IS_RUNNING_ON_CLOUD && ( +
+ + + + setSettings((s) => ({ + ...s, + accessCode: e.target.value, + })) + } + /> +
+ )} +
diff --git a/frontend/src/components/ui/popover.tsx b/frontend/src/components/ui/popover.tsx new file mode 100644 index 0000000..bbba7e0 --- /dev/null +++ b/frontend/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/lib/utils" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/frontend/src/components/ui/select.tsx b/frontend/src/components/ui/select.tsx index cdfb8ce..1dcfce0 100644 --- a/frontend/src/components/ui/select.tsx +++ b/frontend/src/components/ui/select.tsx @@ -159,4 +159,4 @@ export { SelectSeparator, SelectScrollUpButton, SelectScrollDownButton, -} +} \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css index ab05eb6..9d965f3 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -61,9 +61,21 @@ --radius: 0.5rem; } + + body.dark { + background-color: black; + } + + div[role="presentation"].dark { + background-color: #09090b !important; + } + + iframe { + background-color: white !important; + } .dark { - --background: 222.2 84% 4.9%; + --background: 222.2 0% 0%; --foreground: 210 40% 98%; --card: 222.2 84% 4.9%; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 6daa7bf..2e771a7 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -7,6 +7,6 @@ import { Toaster } from "react-hot-toast"; ReactDOM.createRoot(document.getElementById("root")!).render( - + ); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0963f32..2fe2aec 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -9,7 +9,7 @@ export enum CSSOption { } export enum JSFrameworkOption { - VANILLA = "vanilla", + NO_FRAMEWORK = "vanilla", REACT = "react", VUE = "vue", } @@ -27,10 +27,12 @@ export interface OutputSettings { export interface Settings { openAiApiKey: string | null; + openAiBaseURL: string | null; screenshotOneApiKey: string | null; isImageGenerationEnabled: boolean; editorTheme: EditorTheme; isTermOfServiceAccepted: boolean; // Only relevant for hosted version + accessCode: string | null; // Only relevant for hosted version } export enum AppState { diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 6edff02..16ffdbe 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -805,6 +805,28 @@ "@babel/runtime" "^7.13.10" "@radix-ui/react-primitive" "1.0.3" +"@radix-ui/react-popover@^1.0.7": + version "1.0.7" + resolved "https://registry.yarnpkg.com/@radix-ui/react-popover/-/react-popover-1.0.7.tgz#23eb7e3327330cb75ec7b4092d685398c1654e3c" + integrity sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ== + dependencies: + "@babel/runtime" "^7.13.10" + "@radix-ui/primitive" "1.0.1" + "@radix-ui/react-compose-refs" "1.0.1" + "@radix-ui/react-context" "1.0.1" + "@radix-ui/react-dismissable-layer" "1.0.5" + "@radix-ui/react-focus-guards" "1.0.1" + "@radix-ui/react-focus-scope" "1.0.4" + "@radix-ui/react-id" "1.0.1" + "@radix-ui/react-popper" "1.1.3" + "@radix-ui/react-portal" "1.0.4" + "@radix-ui/react-presence" "1.0.1" + "@radix-ui/react-primitive" "1.0.3" + "@radix-ui/react-slot" "1.0.2" + "@radix-ui/react-use-controllable-state" "1.0.1" + aria-hidden "^1.1.1" + react-remove-scroll "2.5.5" + "@radix-ui/react-popper@1.1.3": version "1.1.3" resolved "https://registry.yarnpkg.com/@radix-ui/react-popper/-/react-popper-1.1.3.tgz#24c03f527e7ac348fabf18c89795d85d21b00b42"