In the , we built a Node.js/Express server that exposes an endpoint. When this endpoint is triggered and we include a text , the endpoint interacts with OpenAI's API to generate and return a continuation of that text. last article /ask prompt /completions When we tested this with an example prompt like , the API returned a valid answer to us. "How is the weather in Dubai?" Today, we're going to build a User Interface (i.e. UI) that resembles a chatbot where the user can type a question and receive an answer from the Node.js backend API we created. Table of contents Scaffolding a react app Creating the markup & styles Capturing the prompt value Triggering the API Proxying the request Testing our app Closing thoughts Scaffolding a React app We'll be building the UI of our app with the React JavaScript library. To get started, we'll first want to scaffold a React development environment quickly and we'll do this with the help of . Vite I have plans on writing an email that does a bit more of a deep-dive into Vite but in summary, Vite is a build tool and development server that is designed to optimize the development experience of modern web applications. Think but with faster build/start times and a few additional improvements. Webpack To get started with scaffolding our React app, we'll follow the section of Vite, and we'll run the following in our terminal. Getting Started documentation npm create vite@latest We'll then be given a few prompts to fill. We'll state that we'll want our project to be named and we'll want it to be a React/JavaScript app. custom_chat_gpt_frontend $ npm create vite@latest ✔ Project name: custom_chat_gpt_frontend ✔ Select a framework: › React ✔ Select a variant: › JavaScript We can then navigate into the project directory and run the following to install the project dependencies. npm install When the project dependencies have finished installing, we'll run our front-end server with: npm run dev We'll then be presented with the running scaffolded application at . http://localhost:5173/ Creating the markup & styles We'll begin our work by first focusing on building the markup (i.e. HTML/JSX) and styles (i.e. CSS) of our app. In the scaffolded React application, we'll notice a bunch of files and directories have been created for us. We'll be working entirely within the directory. To get things started, we'll modify the autogenerated code in our component to simply return "Hello world!". src/ src/App.jsx import "./App.css"; function App() { return <h2>Hello world!</h2>; } export default App; We'll remove the scaffolded CSS styles in our file and only have the following. src/index.css html, body, #root { height: 100%; font-size: 14px; font-family: arial, sans-serif; margin: 0; } And in the file, we'll remove all the initially provided CSS classes. src/App.css /* App.css CSS styles to go here */ /* ... */ Saving our changes, we'll be presented with a "Hello world!" message. We won't spend a lot of time in this email breaking down our UI is styled. To summarize quickly, our final app will only contain a single input field section that both captures what the user types and the returned answer from the API. how We'll style the UI of our app with standard CSS. We'll paste the following CSS into our file which will contain all the CSS we'll need. src/App.css .app { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; background-color: rgba(0, 0, 0, 0.1); } .app-container { width: 1000px; max-width: 100%; padding: 0 20px; text-align: center; } .spotlight__wrapper { border-radius: 12px; border: 1px solid #dfe1e5; margin: auto; max-width: 600px; background-color: #fff; } .spotlight__wrapper:hover, .spotlight__wrapper:focus { background-color: #fff; box-shadow: 0 1px 6px rgb(32 33 36 / 28%); border-color: rgba(223, 225, 229, 0); } .spotlight__input { display: block; height: 56px; width: 80%; border: 0; border-radius: 12px; outline: none; font-size: 1.2rem; color: #000; background-position: left 17px center; background-repeat: no-repeat; background-color: #fff; background-size: 3.5%; padding-left: 60px; } .spotlight__input::placeholder { line-height: 1.5em; } .spotlight__answer { min-height: 115px; line-height: 1.5em; letter-spacing: 0.1px; padding: 10px 30px; display: flex; align-items: center; justify-content: center; } .spotlight__answer p::after { content: ""; width: 2px; height: 14px; position: relative; top: 2px; left: 2px; background: black; display: inline-block; animation: cursor-blink 1s steps(2) infinite; } @keyframes cursor-blink { 0% { opacity: 0; } } We'll now move towards establishing the markup/JSX of our component. In the file, we'll update the component to first return a few wrapper elements. <App /> src/App.jsx <div /> import "./App.css"; function App() { return ( <div className="app"> <div className="app-container"> <div className="spotlight__wrapper"> /* ... */ </div> </div> </div> ); } export default App; Within our wrapper elements, we'll place an element and a element to represent the input section and the answer section respectively. <input /> <div /> import "./App.css"; import lens from "./assets/lens.png"; function App() { return ( <div className="app"> <div className="app-container"> <div className="spotlight__wrapper"> <input type="text" className="spotlight__input" placeholder="Ask me anything..." style={{ backgroundImage: `url(${lens})`, }} /> <div className="spotlight__answer"> Dubai is a desert city and has a warm and sunny climate throughout </div> </div> </div> </div> ); } export default App; For the element, we're adding an inline style property where the value is the image of a magnifying glass that we've saved in our directory. You can find a copy of this image . <input /> backgroundImage .png src/assets/ here With our changes saved, we'll now be presented with the UI of the app the way we expected it to look. Capturing the prompt value Our next step is to capture the value the user is typing. This needs to be done since we intend to send this value to the API when the input has been submitted. We'll capture the user input value in a state property labeled and we'll initialize it with . prompt prompt undefined import { useState } from "react"; import "./App.css"; import lens from "./assets/lens.png"; function App() { const [prompt, updatePrompt] = useState(undefined); return ( /* ... */ ); } export default App; When the user types into the element, we'll update the state value by using the event handler. <input /> prompt onChange() import { useState } from "react"; import "./App.css"; import lens from "./assets/lens.png"; function App() { const [prompt, updatePrompt] = useState(undefined); return ( <div className="app"> <div className="app-container"> <div className="spotlight__wrapper"> <input // ... onChange={(e) => updatePrompt(e.target.value)} /> // ... </div> </div> </div> ); } export default App; We want the input to be "submitted" at the moment the user presses the "Enter" key. To do this, we'll use the event handler and have it trigger a function we'll create. onKeyDown() sendPrompt() In the function, we'll return early if the user enters a key that is not the key. Otherwise, we'll the state value. sendPrompt() "Enter" console.log() prompt import { useState } from "react"; import "./App.css"; import lens from "./assets/lens.png"; function App() { const [prompt, updatePrompt] = useState(undefined); const sendPrompt = async (event) => { if (event.key !== "Enter") { return; } console.log('prompt', prompt) } return ( <div className="app"> <div className="app-container"> <div className="spotlight__wrapper"> <input // ... onChange={(e) => updatePrompt(e.target.value)} onKeyDown={(e) => sendPrompt(e)} /> // ... </div> </div> </div> ); } export default App; Now, if we type something into the input and press the "Enter" key, we'll be presented with that input value in our console. Triggering the API The final step of our implementation is triggering the API when the user presses the "Enter" key after typing a prompt in the input. We'll want to capture two other state properties that will reflect the information of our API request — the state of our request and the returned from a successful request. We'll initialize with and with . loading answer loading false answer undefined import { useState } from "react"; import "./App.css"; import lens from "./assets/lens.png"; function App() { const [prompt, updatePrompt] = useState(undefined); const [loading, setLoading] = useState(false); const [answer, setAnswer] = useState(undefined); const sendPrompt = async (event) => { // ... } return ( // ... ); } export default App; In our function, we'll use a statement to handle errors that may occur from the asynchronous request to our API. sendPrompt() try/catch const sendPrompt = async (event) => { if (event.key !== "Enter") { return; } try { } catch (err) { } } At the beginning of the block, we'll set the state property to . We'll then prepare our request options and then use the native browser method to trigger our request. We'll make our request hit an endpoint labeled (we'll explain why in a second). try loading true fetch() api/ask const sendPrompt = async (event) => { if (event.key !== "Enter") { return; } try { setLoading(true); const requestOptions = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt }), }; const res = await fetch("/api/ask", requestOptions); } catch (err) { } } If the response is not successful, we'll throw an error (and it). Otherwise, we'll capture the response value and update our state property with it. console.log() answer This makes our function in its complete state look like the following: sendPrompt() const sendPrompt = async (event) => { if (event.key !== "Enter") { return; } try { setLoading(true); const requestOptions = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt }), }; const res = await fetch("/api/ask", requestOptions); if (!res.ok) { throw new Error("Something went wrong"); } const { message } = await res.json(); setAnswer(message); } catch (err) { console.error(err, "err"); } finally { setLoading(false); } }; Before we move towards testing that our request works as expected, we'll add a few more changes to our component. When our state property is , we'll want the input to be disabled and we'll also want to display a spinning indicator in place of the magnifying lens image (to convey to the user that the request is "loading"). loading true We'll display a spinning indicator by conditionally dictating the value of the style of the element based on the status of the value. We'll use this that we'll save int our directory. backgroundImage <input /> loading spinner GIF src/assets/ import { useState } from "react"; import "./App.css"; import loadingGif from "./assets/loading.gif"; import lens from "./assets/lens.png"; function App() { // ... return ( <div className="app"> <div className="app-container"> <div className="spotlight__wrapper"> <input // ... disabled={loading} style={{ backgroundImage: loading ? `url(${loadingGif})` : `url(${lens})`, }} // ... /> // ... </div> </div> </div> ); } In the answer section of our markup, we'll conditionally add a paragraph tag that contains the value if it is defined. {answer} import { useState } from "react"; import "./App.css"; import loadingGif from "./assets/loading.gif"; import lens from "./assets/lens.png"; function App() { // ... return ( <div className="app"> <div className="app-container"> <div className="spotlight__wrapper"> // ... <div className="spotlight__answer">{answer && <p>{answer}</p>}</div> </div> </div> </div> ); } The last thing we'll want to do is have the state value set back to if the user ever clears the input. We'll do this with the help of the React Hook. {answer} undefined useEffect() import { useState, useEffect } from "react"; // ... function App() { const [prompt, updatePrompt] = useState(undefined); const [loading, setLoading] = useState(false); const [answer, setAnswer] = useState(undefined); useEffect(() => { if (prompt != null && prompt.trim() === "") { setAnswer(undefined); } }, [prompt]); // ... return ( // ... ); } export default App; That's all the changes we'll make to our component! There's one small thing we have to do before we can test our app. <App /> Proxying the request In our Vite React project, we want to make API requests to a backend server running on a different origin (i.e. a different port at ) than the one the web application is served from ( ). However, due to the enforced by web browsers, such requests can be blocked for security reasons. localhost:5000 localhost:5173 same-origin policy To get around this when working within a development environment, we can set up a reverse proxy on the frontend server (i.e. our Vite server) to forward requests to the backend server, effectively making the backend server's API available on the same origin as the frontend application. Vite allows us to do this by modifying the value in the Vite configuration file (which is ). server.proxy vite.config.js In the file that already exists in our project, we'll specify the proxy to be the endpoint. The endpoint will get forwarded to . vite.config.js /api /api http://localhost:5000 // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], server: { proxy: { "/api": { target: "http://localhost:5000", changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, ""), }, }, }, }); Now, when our front end makes a request to , it gets forwarded to the backend server running at . /api/ask http://localhost:5000/ask Testing our app We've finished building our simple chatbot app. Let's test our work! First, we need to have our Node/Express server from the last tutorial running. We'll navigate into that project directory and run to get that going. node index.js $ custom_chat_gpt: node index.js We'll save our changes in our front-end app, and restart the front-end server. $ custom_chat_gpt_frontend: npm run dev In the UI of our front-end app, we'll provide a prompt and press "Enter". There should be a brief loading period before the answer is then populated and shown to us! We can even try and ask our chatbot something more specific like . "What are the best doughnuts in Toronto Canada?" Funny enough, when I search for the Castro's Lounge bakery here in Toronto, I get a , not a bakery. And Glazed & Confused Donuts appears to be in — not Toronto. It looks like there's room to fine-tune our chatbot a bit better — we'll talk about this in our last tutorial email of this series, next week 🙂. bar and live-music venue Syracuse, New York Closing thoughts You can find the source code for this article at . frontend-fresh/articles_source_code/custom_chat_gpt_frontend/ To control the length of the information returned from OpenAI's endpoint, you can modify the setting field in the OpenAI configuration (see example ). /completions max_tokens here The original article was sent out by the newsletter. frontend fresh