19 changed files with 279 additions and 561 deletions
@ -0,0 +1,159 @@
@@ -0,0 +1,159 @@
|
||||
import type { ExcalidrawImperativeAPI } from "../../packages/excalidraw/types"; |
||||
import { |
||||
DiagramToCodePlugin, |
||||
exportToBlob, |
||||
getTextFromElements, |
||||
MIME_TYPES, |
||||
TTDDialog, |
||||
} from "../../packages/excalidraw"; |
||||
import { getDataURL } from "../../packages/excalidraw/data/blob"; |
||||
import { safelyParseJSON } from "../../packages/excalidraw/utils"; |
||||
|
||||
export const AIComponents = ({ |
||||
excalidrawAPI, |
||||
}: { |
||||
excalidrawAPI: ExcalidrawImperativeAPI; |
||||
}) => { |
||||
return ( |
||||
<> |
||||
<DiagramToCodePlugin |
||||
generate={async ({ frame, children }) => { |
||||
const appState = excalidrawAPI.getAppState(); |
||||
|
||||
const blob = await exportToBlob({ |
||||
elements: children, |
||||
appState: { |
||||
...appState, |
||||
exportBackground: true, |
||||
viewBackgroundColor: appState.viewBackgroundColor, |
||||
}, |
||||
exportingFrame: frame, |
||||
files: excalidrawAPI.getFiles(), |
||||
mimeType: MIME_TYPES.jpg, |
||||
}); |
||||
|
||||
const dataURL = await getDataURL(blob); |
||||
|
||||
const textFromFrameChildren = getTextFromElements(children); |
||||
|
||||
const response = await fetch( |
||||
`${ |
||||
import.meta.env.VITE_APP_AI_BACKEND |
||||
}/v1/ai/diagram-to-code/generate`,
|
||||
{ |
||||
method: "POST", |
||||
headers: { |
||||
Accept: "application/json", |
||||
"Content-Type": "application/json", |
||||
}, |
||||
body: JSON.stringify({ |
||||
texts: textFromFrameChildren, |
||||
image: dataURL, |
||||
theme: appState.theme, |
||||
}), |
||||
}, |
||||
); |
||||
|
||||
if (!response.ok) { |
||||
const text = await response.text(); |
||||
const errorJSON = safelyParseJSON(text); |
||||
|
||||
if (!errorJSON) { |
||||
throw new Error(text); |
||||
} |
||||
|
||||
if (errorJSON.statusCode === 429) { |
||||
return { |
||||
html: `<html>
|
||||
<body style="margin: 0; text-align: center"> |
||||
<div style="display: flex; align-items: center; justify-content: center; flex-direction: column; height: 100vh; padding: 0 60px"> |
||||
<div style="color:red">Too many requests today,</br>please try again tomorrow!</div> |
||||
</br> |
||||
</br> |
||||
<div>You can also try <a href="${ |
||||
import.meta.env.VITE_APP_PLUS_LP |
||||
}/plus?utm_source=excalidraw&utm_medium=app&utm_content=d2c" target="_blank" rel="noreferrer noopener">Excalidraw+</a> to get more requests.</div> |
||||
</div> |
||||
</body> |
||||
</html>`,
|
||||
}; |
||||
} |
||||
|
||||
throw new Error(errorJSON.message || text); |
||||
} |
||||
|
||||
try { |
||||
const { html } = await response.json(); |
||||
|
||||
if (!html) { |
||||
throw new Error("Generation failed (invalid response)"); |
||||
} |
||||
return { |
||||
html, |
||||
}; |
||||
} catch (error: any) { |
||||
throw new Error("Generation failed (invalid response)"); |
||||
} |
||||
}} |
||||
/> |
||||
|
||||
<TTDDialog |
||||
onTextSubmit={async (input) => { |
||||
try { |
||||
const response = await fetch( |
||||
`${ |
||||
import.meta.env.VITE_APP_AI_BACKEND |
||||
}/v1/ai/text-to-diagram/generate`,
|
||||
{ |
||||
method: "POST", |
||||
headers: { |
||||
Accept: "application/json", |
||||
"Content-Type": "application/json", |
||||
}, |
||||
body: JSON.stringify({ prompt: input }), |
||||
}, |
||||
); |
||||
|
||||
const rateLimit = response.headers.has("X-Ratelimit-Limit") |
||||
? parseInt(response.headers.get("X-Ratelimit-Limit") || "0", 10) |
||||
: undefined; |
||||
|
||||
const rateLimitRemaining = response.headers.has( |
||||
"X-Ratelimit-Remaining", |
||||
) |
||||
? parseInt( |
||||
response.headers.get("X-Ratelimit-Remaining") || "0", |
||||
10, |
||||
) |
||||
: undefined; |
||||
|
||||
const json = await response.json(); |
||||
|
||||
if (!response.ok) { |
||||
if (response.status === 429) { |
||||
return { |
||||
rateLimit, |
||||
rateLimitRemaining, |
||||
error: new Error( |
||||
"Too many requests today, please try again tomorrow!", |
||||
), |
||||
}; |
||||
} |
||||
|
||||
throw new Error(json.message || "Generation failed..."); |
||||
} |
||||
|
||||
const generatedResponse = json.generatedResponse; |
||||
if (!generatedResponse) { |
||||
throw new Error("Generation failed..."); |
||||
} |
||||
|
||||
return { generatedResponse, rateLimit, rateLimitRemaining }; |
||||
} catch (err: any) { |
||||
throw new Error("Request failed"); |
||||
} |
||||
}} |
||||
/> |
||||
</> |
||||
); |
||||
}; |
||||
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
import { useLayoutEffect } from "react"; |
||||
import { useApp } from "../App"; |
||||
import type { GenerateDiagramToCode } from "../../types"; |
||||
|
||||
export const DiagramToCodePlugin = (props: { |
||||
generate: GenerateDiagramToCode; |
||||
}) => { |
||||
const app = useApp(); |
||||
|
||||
useLayoutEffect(() => { |
||||
app.setPlugins({ |
||||
diagramToCode: { generate: props.generate }, |
||||
}); |
||||
}, [app, props.generate]); |
||||
|
||||
return null; |
||||
}; |
||||
@ -1,18 +0,0 @@
@@ -1,18 +0,0 @@
|
||||
.excalidraw { |
||||
.MagicSettings { |
||||
.Island { |
||||
height: 100%; |
||||
display: flex; |
||||
flex-direction: column; |
||||
} |
||||
} |
||||
|
||||
.MagicSettings-confirm { |
||||
padding: 0.5rem 1rem; |
||||
} |
||||
|
||||
.MagicSettings__confirm { |
||||
margin-top: 2rem; |
||||
margin-right: auto; |
||||
} |
||||
} |
||||
@ -1,160 +0,0 @@
@@ -1,160 +0,0 @@
|
||||
import { useState } from "react"; |
||||
import { Dialog } from "./Dialog"; |
||||
import { TextField } from "./TextField"; |
||||
import { MagicIcon, OpenAIIcon } from "./icons"; |
||||
import { FilledButton } from "./FilledButton"; |
||||
import { CheckboxItem } from "./CheckboxItem"; |
||||
import { KEYS } from "../keys"; |
||||
import { useUIAppState } from "../context/ui-appState"; |
||||
import { InlineIcon } from "./InlineIcon"; |
||||
import { Paragraph } from "./Paragraph"; |
||||
|
||||
import "./MagicSettings.scss"; |
||||
import TTDDialogTabs from "./TTDDialog/TTDDialogTabs"; |
||||
import { TTDDialogTab } from "./TTDDialog/TTDDialogTab"; |
||||
|
||||
export const MagicSettings = (props: { |
||||
openAIKey: string | null; |
||||
isPersisted: boolean; |
||||
onChange: (key: string, shouldPersist: boolean) => void; |
||||
onConfirm: (key: string, shouldPersist: boolean) => void; |
||||
onClose: () => void; |
||||
}) => { |
||||
const [keyInputValue, setKeyInputValue] = useState(props.openAIKey || ""); |
||||
const [shouldPersist, setShouldPersist] = useState<boolean>( |
||||
props.isPersisted, |
||||
); |
||||
|
||||
const appState = useUIAppState(); |
||||
|
||||
const onConfirm = () => { |
||||
props.onConfirm(keyInputValue.trim(), shouldPersist); |
||||
}; |
||||
|
||||
if (appState.openDialog?.name !== "settings") { |
||||
return null; |
||||
} |
||||
|
||||
return ( |
||||
<Dialog |
||||
onCloseRequest={() => { |
||||
props.onClose(); |
||||
props.onConfirm(keyInputValue.trim(), shouldPersist); |
||||
}} |
||||
title={ |
||||
<div style={{ display: "flex" }}> |
||||
Wireframe to Code (AI){" "} |
||||
<div |
||||
style={{ |
||||
display: "flex", |
||||
alignItems: "center", |
||||
justifyContent: "center", |
||||
padding: "0.1rem 0.5rem", |
||||
marginLeft: "1rem", |
||||
fontSize: 14, |
||||
borderRadius: "12px", |
||||
background: "var(--color-promo)", |
||||
color: "var(--color-surface-lowest)", |
||||
}} |
||||
> |
||||
Experimental |
||||
</div> |
||||
</div> |
||||
} |
||||
className="MagicSettings" |
||||
autofocus={false} |
||||
> |
||||
{/* <h2 |
||||
style={{ |
||||
margin: 0, |
||||
fontSize: "1.25rem", |
||||
paddingLeft: "2.5rem", |
||||
}} |
||||
> |
||||
AI Settings |
||||
</h2> */} |
||||
<TTDDialogTabs dialog="settings" tab={appState.openDialog.tab}> |
||||
{/* <TTDDialogTabTriggers> |
||||
<TTDDialogTabTrigger tab="text-to-diagram"> |
||||
<InlineIcon icon={brainIcon} /> Text to diagram |
||||
</TTDDialogTabTrigger> |
||||
<TTDDialogTabTrigger tab="diagram-to-code"> |
||||
<InlineIcon icon={MagicIcon} /> Wireframe to code |
||||
</TTDDialogTabTrigger> |
||||
</TTDDialogTabTriggers> */} |
||||
{/* <TTDDialogTab className="ttd-dialog-content" tab="text-to-diagram"> |
||||
TODO |
||||
</TTDDialogTab> */} |
||||
<TTDDialogTab |
||||
// className="ttd-dialog-content"
|
||||
tab="diagram-to-code" |
||||
> |
||||
<Paragraph> |
||||
For the diagram-to-code feature we use{" "} |
||||
<InlineIcon icon={OpenAIIcon} /> |
||||
OpenAI. |
||||
</Paragraph> |
||||
<Paragraph> |
||||
While the OpenAI API is in beta, its use is strictly limited — as |
||||
such we require you use your own API key. You can create an{" "} |
||||
<a |
||||
href="https://platform.openai.com/login?launch" |
||||
rel="noopener noreferrer" |
||||
target="_blank" |
||||
> |
||||
OpenAI account |
||||
</a> |
||||
, add a small credit (5 USD minimum), and{" "} |
||||
<a |
||||
href="https://platform.openai.com/api-keys" |
||||
rel="noopener noreferrer" |
||||
target="_blank" |
||||
> |
||||
generate your own API key |
||||
</a> |
||||
. |
||||
</Paragraph> |
||||
<Paragraph> |
||||
Your OpenAI key does not leave the browser, and you can also set |
||||
your own limit in your OpenAI account dashboard if needed. |
||||
</Paragraph> |
||||
<TextField |
||||
isRedacted |
||||
value={keyInputValue} |
||||
placeholder="Paste your API key here" |
||||
label="OpenAI API key" |
||||
onChange={(value) => { |
||||
setKeyInputValue(value); |
||||
props.onChange(value.trim(), shouldPersist); |
||||
}} |
||||
selectOnRender |
||||
onKeyDown={(event) => event.key === KEYS.ENTER && onConfirm()} |
||||
/> |
||||
<Paragraph> |
||||
By default, your API token is not persisted anywhere so you'll need |
||||
to insert it again after reload. But, you can persist locally in |
||||
your browser below. |
||||
</Paragraph> |
||||
|
||||
<CheckboxItem checked={shouldPersist} onChange={setShouldPersist}> |
||||
Persist API key in browser storage |
||||
</CheckboxItem> |
||||
|
||||
<Paragraph> |
||||
Once API key is set, you can use the <InlineIcon icon={MagicIcon} />{" "} |
||||
tool to wrap your elements in a frame that will then allow you to |
||||
turn it into code. This dialog can be accessed using the{" "} |
||||
<b>AI Settings</b> <InlineIcon icon={OpenAIIcon} />. |
||||
</Paragraph> |
||||
|
||||
<FilledButton |
||||
className="MagicSettings__confirm" |
||||
size="large" |
||||
label="Confirm" |
||||
onClick={onConfirm} |
||||
/> |
||||
</TTDDialogTab> |
||||
</TTDDialogTabs> |
||||
</Dialog> |
||||
); |
||||
}; |
||||
@ -1,105 +0,0 @@
@@ -1,105 +0,0 @@
|
||||
import { THEME } from "../constants"; |
||||
import type { Theme } from "../element/types"; |
||||
import type { DataURL } from "../types"; |
||||
import type { OpenAIInput, OpenAIOutput } from "./ai/types"; |
||||
|
||||
export type MagicCacheData = |
||||
| { |
||||
status: "pending"; |
||||
} |
||||
| { status: "done"; html: string } |
||||
| { |
||||
status: "error"; |
||||
message?: string; |
||||
code: "ERR_GENERATION_INTERRUPTED" | string; |
||||
}; |
||||
|
||||
const SYSTEM_PROMPT = `You are a skilled front-end developer who builds interactive prototypes from wireframes, and is an expert at CSS Grid and Flex design.
|
||||
Your role is to transform low-fidelity wireframes into working front-end HTML code. |
||||
|
||||
YOU MUST FOLLOW FOLLOWING RULES: |
||||
|
||||
- Use HTML, CSS, JavaScript to build a responsive, accessible, polished prototype |
||||
- Leverage Tailwind for styling and layout (import as script <script src="https://cdn.tailwindcss.com"></script>) |
||||
- Inline JavaScript when needed |
||||
- Fetch dependencies from CDNs when needed (using unpkg or skypack) |
||||
- Source images from Unsplash or create applicable placeholders |
||||
- Interpret annotations as intended vs literal UI |
||||
- Fill gaps using your expertise in UX and business logic |
||||
- generate primarily for desktop UI, but make it responsive. |
||||
- Use grid and flexbox wherever applicable. |
||||
- Convert the wireframe in its entirety, don't omit elements if possible. |
||||
|
||||
If the wireframes, diagrams, or text is unclear or unreadable, refer to provided text for clarification. |
||||
|
||||
Your goal is a production-ready prototype that brings the wireframes to life. |
||||
|
||||
Please output JUST THE HTML file containing your best attempt at implementing the provided wireframes.`;
|
||||
|
||||
export async function diagramToHTML({ |
||||
image, |
||||
apiKey, |
||||
text, |
||||
theme = THEME.LIGHT, |
||||
}: { |
||||
image: DataURL; |
||||
apiKey: string; |
||||
text: string; |
||||
theme?: Theme; |
||||
}) { |
||||
const body: OpenAIInput.ChatCompletionCreateParamsBase = { |
||||
model: "gpt-4-vision-preview", |
||||
// 4096 are max output tokens allowed for `gpt-4-vision-preview` currently
|
||||
max_tokens: 4096, |
||||
temperature: 0.1, |
||||
messages: [ |
||||
{ |
||||
role: "system", |
||||
content: SYSTEM_PROMPT, |
||||
}, |
||||
{ |
||||
role: "user", |
||||
content: [ |
||||
{ |
||||
type: "image_url", |
||||
image_url: { |
||||
url: image, |
||||
detail: "high", |
||||
}, |
||||
}, |
||||
{ |
||||
type: "text", |
||||
text: `Above is the reference wireframe. Please make a new website based on these and return just the HTML file. Also, please make it for the ${theme} theme. What follows are the wireframe's text annotations (if any)...`, |
||||
}, |
||||
{ |
||||
type: "text", |
||||
text, |
||||
}, |
||||
], |
||||
}, |
||||
], |
||||
}; |
||||
|
||||
let result: |
||||
| ({ ok: true } & OpenAIOutput.ChatCompletion) |
||||
| ({ ok: false } & OpenAIOutput.APIError); |
||||
|
||||
const resp = await fetch("https://api.openai.com/v1/chat/completions", { |
||||
method: "POST", |
||||
headers: { |
||||
"Content-Type": "application/json", |
||||
Authorization: `Bearer ${apiKey}`, |
||||
}, |
||||
body: JSON.stringify(body), |
||||
}); |
||||
|
||||
if (resp.ok) { |
||||
const json: OpenAIOutput.ChatCompletion = await resp.json(); |
||||
result = { ...json, ok: true }; |
||||
} else { |
||||
const json: OpenAIOutput.APIError = await resp.json(); |
||||
result = { ...json, ok: false }; |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
Loading…
Reference in new issue