Tutorial - Build a chatbot with React and OpenAI
Hassan Djirdeh
Posted on February 21, 2023
This article is the second article and tutorial sent on the frontendfresh.com newsletter. Subscribe to the Front-end Fresh newsletter to get front-end engineering tips, tutorials, and projects sent to your inbox on a weekly basis!
Last week, we built a Node.js/Express server that exposes an /ask
endpoint. When this endpoint is triggered and we include a text prompt
, the endpoint interacts with OpenAI's /completions
API to generate and return a continuation of that text.
When we tested this with an example prompt like "How is the weather in Dubai?"
, the API returned a valid answer to us.
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.
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 Webpack but with faster build/start times and a few additional improvements.
To get started in scaffolding our React app, we'll follow the Getting Started documentation section of Vite, and we'll run the following in our terminal.
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 custom_chat_gpt_frontend
and we'll want it to be a React/JavaScript app.
$ 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 src/
directory. To get things started, we'll modify the autogenerated code in our src/App.jsx
component to simply return "Hello world!".
import "./App.css";
function App() {
return <h2>Hello world!</h2>;
}
export default App;
We'll remove the scaffolded CSS styles in our src/index.css
file and only have the following.
html,
body,
#root {
height: 100%;
font-size: 14px;
font-family: arial, sans-serif;
margin: 0;
}
And in the src/App.css
file, we'll remove all the initially provided CSS classes.
/* 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 how 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.
We'll style the UI of our app with standard CSS. We'll paste the following CSS into our src/App.css
file which will contain all the CSS we'll need.
.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 <App />
component. In the src/App.jsx
file, we'll update the component to first return a few wrapper <div />
elements.
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 <input />
element and a <div />
element to represent the input section and the answer section respectively.
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 <input />
element, we're adding an inline backgroundImage
style property where the value is the .png
image of a magnifying glass that we've saved in our src/assets/
directory. You can find a copy of this image 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 prompt
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 prompt
and we'll initialize it with 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 <input />
element, we'll update the state prompt
value by using the onChange()
event handler.
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 onKeyDown()
event handler and have it trigger a sendPrompt()
function we'll create.
In the sendPrompt()
function, we'll return early if the user enters a key that is not the "Enter"
key. Otherwise, we'll console.log()
the prompt
state value.
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 to 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 loading
state of our request and the answer
returned from a successful request. We'll initialize loading
with false
and answer
with 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 sendPrompt()
function, we'll use a try/catch
statement to handle errors that may occur from the asynchronous request to our API.
const sendPrompt = async (event) => {
if (event.key !== "Enter") {
return;
}
try {
} catch (err) {
}
}
At the beginning of the try
block, we'll set the state loading
property to true
. We'll then prepare our request options and then use the native browser fetch()
method to trigger our request. We'll make our request hit an endpoint labeled api/ask
(we'll explain why in a second).
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 console.log()
it). Otherwise, we'll capture the response value and update our answer
state property with it.
This makes our sendPrompt()
function in its complete state look like the following:
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 loading
state property is true
, 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").
We'll display a spinning indicator by conditionally dictating the value of the backgroundImage
style of the <input />
element based on the status of the loading
value. We'll use this spinner GIF that we'll save int our src/assets/
directory.
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 {answer}
value if it is defined.
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 {answer}
state value set back to undefined
if the user ever clears the input. We'll do this with the help of the React useEffect()
Hook.
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 <App />
component! There's one small thing we have to do before we can test our 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 localhost:5000
) than the one the web application is served from (localhost:5173
). However, due to the same-origin policy enforced by web browsers, such requests can be blocked for security reasons.
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 server.proxy
value in the Vite configuration file (which is vite.config.js
).
In the vite.config.js
file that already exists in our project, we'll specify the proxy to be the /api
endpoint. The /api
endpoint will get forwarded to 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 /api/ask
, it gets forwarded to the backend server running at 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 node index.js
to get that going.
$ 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 bar and live-music venue, not a bakery. And Glazed & Confused Donuts appears to be in Syracuse, New York — 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 🙂.
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
/completions
endpoint, you can modify themax_tokens
setting field in the OpenAI configuration (see example here). - Subscribe to https://www.frontendfresh.com/ for more tutorials like this to hit your inbox on a weekly basis!
That's it for today! 🙂
— Hassan (@djirdehh)
Posted on February 21, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024