Building a coffee delivery chatbot with React, ChatGPT and TypeChat šŸ¤–

dev_bre

Andy

Posted on July 24, 2023

Building a coffee delivery chatbot with React, ChatGPT and TypeChat šŸ¤–

TL;DR

In this article I am going to show how to build a coffee delivery service in React and NodeJS, using a new AI-based library TypeChat.


Before we startā€¦ I have a favour to ask. šŸ¤—

I am building an open source feature flags platform and if you could star the project on github it would mean the world to me! It would help me keep writing articles like this as well!

https://github.com/switchfeat-com/switchfeat


What is TypeChat? šŸ¤”

TypeChat is a new experimental library built by Microsoft Typescript team which allows to translate natural language strings into type safe intents using the OpenAI infrastructure.

For example, providing a text like: ā€œI would like a cappucino with a pack of sugarā€, TypeChat can translate that string into the following structured JSON object which can be parsed and processed by our API much easier then manually tokenizing the string ourselves.

{
  "items": [
    {
      "type": "lineitem",
      "product": {
        "type": "LatteDrinks",
        "name": "cappuccino",
        "options": [
          {
            "type": "Sweeteners",
            "name": "sugar",
            "optionQuantity": "regular"
          }
        ]
      },
      "quantity": 1
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Setting things up

First things first, we need to setup the usual boilerplate of create-react-app and Express. I am not going to go too much into the details of this, just because itā€™s boring and doesnā€™t really add much to this article.


Setup React app

Lets quickly create two folders, client and server and start installig our dependancies.

mkdir client server

In the client folder, letā€™s install create-react-app:

cd client & npx create-react-app my-react-app --template typescript

Once the installation is complete, letā€™s install the react-router-dom to manage the routing much easier:

npm install react-router-dom heroicons


Setup the Express server

Letā€™s move to the server folder and install Express and some other depedancies we are going use:

cd server & npm init -y

npm install express cors typescript @types/node @types/express types/cors dotenv

Once everything has been install, letā€™s initialize typescript:

npx tsc --init

Letā€™s create the index.ts file, which is going to contain the main server API logic:

touch index.ts

Thatā€™s the easiest Express configuration you can have in typescript:

import express, { Express } from 'express';
import cors from "cors"; 

const app: Express = express();
const port = 4000;

app.use(express.urlencoded({ extended: false }));
app.use(express.json());
app.use(cors);

app.get("/api", (req, res) => {
    res.json({
        message: "Food ordering chatbot",
    });
});

app.listen(port, () => {
    console.log(`āš”ļø[server]: Server is running on port 4000`);
});
Enter fullscreen mode Exit fullscreen mode

In package.json of the sever, we need to add a script to run it:

"scripts": {
    "start": "node index.js"
  }
Enter fullscreen mode Exit fullscreen mode

To finish our setup, letā€™s compile typescript and run the server:

tsc & npm run start

Cool! letā€™s more to something much cooler shall we!?


Building the UI

We are going to use TailwindCSS to manage our styling which makes things much easier to manage. Letā€™s start from changing the App.tsx file to this, which uses the React Router to render components on the page.

import './output.css';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { ChatBot } from './components/Chatbot';

export const App = () => {
  return (
    <BrowserRouter>
      <Routes>
        <Route path='/' element={<ChatBot />} />
      </Routes>
    </BrowserRouter>
  );
}
Enter fullscreen mode Exit fullscreen mode

Letā€™s create the ChatBot component which is going to show the chatbot user interface and send API requests to our Express server.

Here is the markup of the ChatBot component:

return (
<div className="flex min-h-full flex-1 flex-col justify-center overflow-hidden">
    <div className="divide-y divide-gray-200 overflow-hidden  flex  flex-col 
                    justify-between">
        <div className="w-full h-full px-4 py-5 sm:p-6 mb-32">
            <ul className="mb-8  h-full">
                {chatSession.map((x, index) =>
                (x.role !== 'system' ?
                    <div key={index}>
                        <li>
                            <div className="relative pb-8">
                                {index !== chatSession.length - 1 ? (
                                    <span className="absolute left-4 top-4 -ml-px h-full w-0.5 bg-gray-200" aria-hidden="true" />
                                ) : null}
                                <div className="relative flex space-x-3">
                                    <div>
                                        <span className={classNames('h-8 w-8 rounded-full flex items-center justify-center ring-8 ring-white', x.role === 'user' ? 'bg-slate-600' : 'bg-orange-500')}>
                                            {x.role === 'user' && <UserIcon className="h-5 w-5 text-white" aria-hidden="true" />}
                                            {x.role === 'assistant' && <RocketLaunchIcon className="h-5 w-5 text-white" aria-hidden="true" />}
                                        </span>
                                    </div>
                                    <div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1">
                                        <div>
                                            <p className="text-md text-gray-500" style={{ whiteSpace: "pre-wrap" }}>
                                                {x.content}
                                            </p>
                                        </div>
                                    </div>
                                </div>
                            </div>
                        </li>
                    </div> : <div key={index}></div>))
                {isProcessing && (
                <li key={'assistant-msg'}>
                    <div className="relative pb-8">
                        <div className="relative flex space-x-3">
                            <div>
                                <span className={classNames('h-8 w-8 rounded-full flex items-center justify-center ring-8 ring-white', 'bg-orange-500')}>
                                    <RocketLaunchIcon className="h-5 w-5 text-white" aria-hidden="true" />
                                </span>
                            </div>
                            <div className="flex min-w-0 flex-1 justify-between space-x-4 pt-1">
                                <div>
                                    <p ref={processingMessage} className="text-md text-gray-500" style={{ whiteSpace: "pre-wrap" }}>
                                        {currentAssistantMessage}
                                    </p>
                                </div>
                            </div>
                        </div>
                    </div>
                </li>)
                <div ref={messagesEndRef} />
                {isProcessing && (
                    <button type="button" className="inline-flex items-center px-4 py-2 font-semibold leading-6 text-sm shadow rounded-md text-white bg-indigo-500 hover:bg-indigo-400 transition ease-in-out duration-150 cursor-not-allowed" disabled>
                        <svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
                            <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
                            <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
                        </svg>
                        Processing...
                    </button>
                )}
            </ul>
        </div>
        <div className=" w-full bottom-0 mt-2 flex rounded-md   px-4 py-5 sm:p-6 bg-slate-50 fixed">
            <div className="relative flex flex-grow items-stretch focus-within:z-10 w-full">
                <input
                    ref={chatInput}
                    type="text"
                    name="textValue"
                    className="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6"
                    placeholder="your order here..."
                />
            </div>
            <button
                onClick={processOrderRequest}
                type="button"
                className="relative -ml-px inline-flex items-center gap-x-1.5 rounded-r-md px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
            >
                <BarsArrowUpIcon className="-ml-0.5 h-5 w-5 text-gray-400" aria-hidden="true" />
                Send
            </button>
        </div>
    </div>
</div>);
Enter fullscreen mode Exit fullscreen mode

There is not too much going on here, most of the code is all about updating chat history and add a button to send the order request to our API. Here it is how this component will look like on the page:


Sending the order request to the API

Add a POST route to the express server which accepts a string containing the order in natural language, processes the order and returns the order which has been processed back to the user.

const processOrderRequest = async () => {

        setIsProcessing(true);
        if (!chatInput.current) {
            setIsProcessing(false);
            return;
        } 

        chatSession.push({ role: "user", content: chatInput.current.value });
        const tempArr: ChatMessage[] = [];
        for (const t of chatSession) {
            tempArr.push(t);
        }
        setChatSession([...tempArr]);

        fetch("http://localhost:4000/api/order-request", {
            method: "POST",
            body: JSON.stringify({ newMessage: chatInput.current.value }),
            headers: {
                "Content-Type": "application/json",
            },
        })
            .then((res) => res.json())
            .then((result) => {
                const order: any[] = [];
                result.items.forEach((x: any) => {
                    const options = x.product.options ? x.product.options.map((opt: any) => { return {name: opt.name, quantity: opt.optionQuantity}; }) : [];
                    order.push({product: x.product.name, options: options, size: x.product.size});
                });
                const orderString: string[] = [];
                order.forEach((x: any) => {
                    orderString.push(`${x.size} ${x.product} with ${x.options.map((x: any) => `${x.quantity === undefined ? "" : x.quantity} ${x.name}`).join(" and ")}`);
                });
                const resp = `šŸŽ‰šŸŽ‰ Thanks for your order! šŸŽ‰šŸŽ‰ \n\n ā˜•> ${orderString.join("\n ā˜•> ")}`;
                tempArr.push({ role: "assistant", content: resp });
                setChatSession([...tempArr]);
                setCurrentAssistantMessage("");
                if (processingMessage.current)
                    processingMessage.current.innerText = "";
                if (chatInput.current)
                    chatInput.current.value = "";
            })
            .catch((err) => console.error(err))
            .finally(() => { setIsProcessing(false); });
    };
Enter fullscreen mode Exit fullscreen mode

The function above grabs the string in natural language from the input field, updates the chat history and sends the request to our API. Once we get the results, it parses the data and adds a new message into the chat history with the order which has been confirmed.


Setting up TypeChat on the Server

Here you will learn how to setup TypeChat and start sending requests to OpenAI. Let's move back to the server folder and let's install the library from NPM:

npm install typechat

Once installed, we have to configure the schema that TypeChat will use to cast the AI response into the right types. For the purpose of this article, I have shamelessly used the schema definition from the official TypeChat example repo and moved into our project. Here it is the link. The main part of this schema is definition of the Cart type:

export interface Cart {
    items: (LineItem | UnknownText)[];
}

// Use this type for order items that match nothing else
export interface UnknownText {
    type: 'unknown',
    text: string; // The text that wasn't understood
}

export interface LineItem {
    type: 'lineitem',
    product: Product;
    quantity: number;
}
Enter fullscreen mode Exit fullscreen mode

We will instruct TypeChat to use this specific model to translate the string coming from the request into a Cart object, which is basically a list of products items that the customer wants to buy.

Now we need to setup the OpenAI api key that TypeChat uses behind the scenes to execute the requests. The apiKey needs to stay private (do not share your apiKey with anyone!), but needs to be stored in the .env file in the server folder of the project. It will have this structure:

OPENAI_MODEL="gpt-3.5-turbo"
OPENAI_API_KEY="<YOUR OPENAI APIKEY>"
Enter fullscreen mode Exit fullscreen mode

The following snippet adds a new POST endpoint to the Express app which receives the order request from the UI, uses TypeChat to extract an intent out of that string and finally processes the order:

const model = createLanguageModel(process.env);
const schema = fs.readFileSync(path.join(__dirname, "coffeOrdersSchema.ts"), "utf8");
const translator = createJsonTranslator<Cart>(model, schema, "Cart");

app.post("/api/order-request", async (req, res) => {
    const { newMessage } = req.body;
    console.log(newMessage);

    if (!newMessage || newMessage === "") {
        console.log("missing order");
        res.json({error: "missing order"});
        return;
    }

    // query TypeChat to translate this into an intent
    const response: Result<Cart> = await translator.translate(newMessage as string);

    if (!response.success) {
        console.log(response.message);
        res.json({error: response.message});
        return;
    }

    await processOrder(response.data);

    res.json({
        items: response.data.items
    });
});
Enter fullscreen mode Exit fullscreen mode

For simplicity, our processOrder function is going to be a simple console.log(), but in reality it could be sending the order to a processing queue or to any other background process.

const processOrder = async (cart: Cart) => {
    // add this to a queue or any other background process
    console.log(JSON.stringify(cart, undefined, 2));
};
Enter fullscreen mode Exit fullscreen mode

Big congrats! šŸŽ‰

If you made it this far, you managed to build a fully working chatbot which can take coffe orders. This system is flexible enough that you could apply the same architecture and logic to any other type of online ordering services like groceries, clothes, restaurants.. you name it! Just changing the schema, TypeChat will be able to generate structured responses based on your needs.

In the next section you will learn how to use SwitchFeat to evaluate the current user access to the premium features for this chatbot.


Using feature flags to show premium features

In this section we are going to use SwitchFeat API, to evaluate if a given user has access to the premium features of this chatbot, like fast delivery or weekends delivery.

At this current stage SwitchFeat doesnā€™t have a dedicated SDK yet, so we are going to use a simple fetch to contact the API and evaluate the current user request.

Letā€™s change our component to track the current user data into a state variable. This information should come from a database or any datastore and could be stored in a Context Provider to be shared across multiple components. But for the purpose of this article letā€™s keep it simple.

const [userContext, setUserContext] =
          useState<{}>({ 
                 username: 'a@switchfeat.com', 
                 isPremium: true 
          });
Enter fullscreen mode Exit fullscreen mode

Add another state variable which is going to define if the premium features should be shown to the current user or not:

const [showPremium, setShowPremium] = useState<boolean>(false);
Enter fullscreen mode Exit fullscreen mode

Finally add the following fetch request to the component:

 useEffect(() => {
    const formData = new FormData();
    formData.append('flagKey', "premium-delivery");
    formData.append('flagContext', JSON.stringify(userContext));
    formData.append('correlationId', uuidv4());

    const evaluateFlag = () => {
      fetch(`http://localhost:4000/api/sdk/flag/`, {
        method: "POST",
        headers: {
          Accept: "application/json",
          "Access-Control-Allow-Credentials": "true",
          "Access-Control-Allow-Origin": "true"
        },
        body: formData
      }).then(resp => {
        return resp.json();
      }).then(respJson => {
        setShowPremium(respJson.data.match);
      }).catch(error => { console.log(error); });
    };
    evaluateFlag();
  }, []);
Enter fullscreen mode Exit fullscreen mode

The snippet above sends an API request to SwitchFeat, checks if the current user is a premium user and evaluates if they are allowed to have premium features.

Finally just add this nippet wherever you want your PremiumFeatures component should be rendered.

{showPremium && <PremiumFeatures />}


Why using a feature flag for this?

You already know if a user is premium right!? Trueā€¦ butā€¦ what if you want to disable that feature regardless?

There are multiple scenarios where you would want to pause the premium delivery (or any other feature), for scarcity of drivers, not enough premium orders available, etc. A simple logic which switches on/off that features with a single click in realtime, is really powerful and avoids building ad-hoc changes spread across the codebase.


Create your flag in SwitchFeat

First we need to create a new user segment to group all the users which are paying for the Premium service:

create segment

Then we need to create a new flag which gates the access to the premium feature using this segment:

create flag

Here is our new shiny flag which we can use in our API requests:

flags view

Now changing the status of the switch in SwitchFeat, you will be able to activate or deactivate the premium features without any code change or redeployment. Look at the video below.

Well done everyone! you managed to finish this article and get an idea of what TypeChat is and why is so cool to use it. I pushed the entire codebase of this article on Github if you want to check it out.


So.. Can you help? šŸ˜‰

I hope this article was somehow interesting. If you could give aĀ star to my repoĀ would really make my day!

https://github.com/switchfeat-com/switchfeat

šŸ’– šŸ’Ŗ šŸ™… šŸš©
dev_bre
Andy

Posted on July 24, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related