paint-brush
How to Develop a Chatbot with React and OpenAIby@hacker7182015
485 reads
485 reads

How to Develop a Chatbot with React and OpenAI

by Hassan DjirdehFebruary 28th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

In the [last article] 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. 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.
featured image - How to Develop a Chatbot with React and OpenAI
Hassan Djirdeh HackerNoon profile picture


In the last article, 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.


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 Webpack but with faster build/start times and a few additional improvements.


To get started with 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 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 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


The original article was sent out by the frontend fresh newsletter.