Topics covered: Shopify connections, AI + vector embeddings, HTTP routes, React frontends
Time to build: ~30 minutes
Large Language Model (LLM) APIs allow developers to build apps that can understand and generate text.
In this tutorial, you will build a product recommendation chatbot for a Shopify store using OpenAI's API and Gadget. The chatbot will utilize OpenAI's text embedding API to generate vector embeddings for product descriptions, which will then be stored in your Gadget database. These embeddings will help to identify the products that best match a shopper's chat message. With Gadget, you can easily sync data from Shopify, build the backend for generating recommendations, and create the chatbot UI.
To get the most out of this tutorial, you will need:
- a Shopify Partners account
- a development store
- an OpenAI account and API Key that can be used to make requests to the OpenAI API
You can fork this Gadget project and try it out yourself.
You will still need to set up the Shopify Connection after forking. Read on to learn how to connect Gadget to a Shopify store!
Want to learn more about building AI apps before getting started? Watch our online learning session where Harry explains LLMs, vector embeddings, and more!
Your first step will be to set up a Gadget project and connect to a Shopify store via the Shopify connection. Create a new Gadget application at gadget.new.
Our first step is going to be setting up a custom Shopify application in the Partners dashboard.
Both the Shopify store Admin and the Shopify Partner Dashboard have an Apps section. Ensure that you are on the Shopify Partner Dashboard before continuing.
Now we get to select what Shopify scopes we give our application access to, while also picking what Shopify data models we want to import into our Gadget app.
Now we want to connect our Gadget app to our custom app in the Partners dashboard.
Now you can install your app on a store from the Partners dashboard. Do not sync data yet! You're going to add some code to generate vector embeddings for your products before the sync is run.
You are going to use the OpenAI API to generate vector embeddings for product descriptions. These embeddings will be used to perform a semantic search to find the products that best match a shopper's chat message. You will use the OpenAI client to make requests to the OpenAI API. The first thing you need to do is set up the OpenAI client and use it to generate embeddings.
Learn more about OpenAI, LLMs, and vector embeddings
LLMs, vector embeddings, and LangChain are relatively new technologies, and there are many resources available to learn more about them. Here are some resources to get you started:
- Building AI apps in Gadget
- Sorting by vector fields Gadget API docs
- OpenAI docs
- LangChain JS docs
We will use an environment variable to store the OpenAI API key. This will allow us to use the same code in development and production without having to hardcode the key. You need to get an OpenAI API key from the OpenAI platform before you can continue.
To set an environment variable in Gadget:
Now that your environment variable is set, you want to set up the OpenAI client in your Gadget app. The OpenAI client is a wrapper around the OpenAI API that makes it easier to make requests to the API. You can install the OpenAI client using yarn in Gadget.
The OpenAI client will be installed in your Gadget app's package.json file.
Now you can initialize the OpenAI client. You are going to reuse this client in a couple of code files, so you can initialize the client in a separate file and import the client into code effects. You're going to use OpenAI's text-embedding-ada-002 model to generate vector embeddings for product descriptions. You can read more about this model in the OpenAI API docs.
This file sets up the OpenAI client and exports it so that you can import it into other files. It also exports a utility function that you will use to get the vector embedding for a message. These embeddings will be used to compare incoming chat messages to product names and descriptions and find the products that best match the shopper's query.
Before you add code to create the embeddings from product descriptions, you need a place to store the generated embeddings. You can add a vector field to the Shopify Product model to store the embeddings.
The vector field types store a vector, or array, of floats. It is useful for storing embeddings and will allow you to perform vector operations like cosine similarity, which helps you find the most similar products to a given chat message.
To add a vector field to the Shopify Product model:
Now you are set up to store embeddings for products! The next step is adding code to generate these embeddings.
Now you can add some code to create vector embeddings for all products in your store. You will want to run this code when Shopify fires a products/create or products/update webhook. To do this, you will create a code effect that runs when a Shopify Product is created or updated.
In this snippet, the internal API is used to update the Shopify Product model and set the Description Embedding field. The internal API needs to be used because the Shopify Product model does not have Gadget API set as a trigger on this action by default. You can read more about the internal API in the Gadget docs.
To also run this code when a product is updated:
Now that the code is in place to generate vector embeddings for products, you can sync existing Shopify products into your Gadget app's database. To do this:
Product and product image data will be synced from Shopify to your Gadget app's database. The code effect you added will run for each product and generate a vector embedding for the product. You can see these vector embeddings by going to the Data page for the Shopify Product model. The vector embeddings will be stored in the descriptionEmbedding field.
To complete your app backend, you will use cosine similarity on the stored vector embeddings to extract products that are closely related to a shopper's query. These products, along with a prompt, will be passed into LangChain, which will then use an OpenAI model to respond to the shopper's question. In addition, you will include product information to display recommended products by LangChain and provide a link to the store page for these products.
You will also stream the response from LangChain to the shopper's chat window. This will allow you to show the shopper that the chatbot is typing while it is generating a response.
To start, install the LangChain and zod npm packages. The zod package will be used to provide a parser to LangChain for reliably extracting structured data from the LLM response.
Now you are ready to add some more code. You will start by adding a new HTTP route to handle incoming chat messages. To add a new HTTP route to your Gadget backend:
Your app now has a new HTTP route that will be triggered when a POST request is made to /chat. You can add code to this file to handle incoming chat messages.
This is the complete code file for POST-chat.js. You can copy and paste this code into the file you just created. A step-by-step explanation of the code is below.
Step-by-step instructions for building this route are below.
The first thing you need to do when building this route is to set up LangChain. LangChain needs a couple of things defined before it can be used to respond to a user's chat message, including a parser to format the response from OpenAI, a prompt template that contains some text defining the purpose of the prompt and variables that will be passed in, and finally, the OpenAI model that will be used.
Begin with setting up a StructuredOutputParser. This parser will format the response from OpenAI into a structured JSON object that can be utilized by the frontend to display the response to the user. The parser uses zod to structure the response, which will consist of a string answer and an array of product IDs productIds recommend to shoppers.
Now that you have a parser, you can set up the prompt template. The prompt template is a string that contains the text that will be used to prompt the OpenAI model to respond to the user's chat message. The prompt template can also include variables that will be passed in when the prompt is invoked. In this case, the prompt template includes a variable for the user's question and a variable for the products that will be passed in as initial recommendations. Formatting instructions created with the parser are also passed into the prompt.
Once the parser and prompt are both defined, you can set up LangChain's OpenAI model and the chain that will be called in the route.
The temperature parameter is set to 0 to ensure that the response from OpenAI is deterministic. The streaming parameter is set to true to ensure that the response from OpenAI is streamed in as it is generated, rather than waiting for the entire response to be generated before returning it. The chain is defined using the model, prompt, and parser.
LangChain model selection
The OpenAI model and LLMChain are used in this tutorial as an example of how to use chains and prompt templates with LangChain. LangChain has a variety of different models, including chat-specific models, that might be worth investigating for your app.
Now that LangChain is set up, you can define the route parameters. The route will accept a message from the shopper as part of a request body. The message will be passed into the prompt template.
To define this message parameter, you can use the schema option in the route module's options object. The schema option is used to define the JSON schema for the request body.
Define the parameter at the bottom of routes/POST-chat.js:
Now you can start to write the actual route code that runs when a shopper asks a question.
The first thing that your route will do is create an embedding vector for the shopper's question and use that vector to find similar products. The embedMessage function created earlier will be used to embed the shopper's question.
Gadget includes a cosineSimilarityTo operator that can be used to sort the results of a read query by cosine similarity to a given vector. The cosineSimilarityTo operator is used in the sort parameter of the findMany query to sort the results by cosine similarity to the embedded message. The first parameter is used to limit the number of results returned to 4.
In other words, the query will return the 4 products that are most similar to the shopper's question.
A product string is then created from the list of returned products:
You are now ready to invoke the chain and stream the response back to the shopper.
Now that the chain is defined, you can invoke it and stream the response back to the shopper. The call method of the chain is used to invoke the chain. The call method takes two parameters: an object containing the input variables for the prompt and an array of callback handlers.
You define a new Readable stream and immediately send it as a response using await reply.send(stream);. Once you have done this, any additional data pushed to the stream will be sent back to the route called.
Finally, chain.call() is invoked to generate a response to the shopper's question, and takes the request.body.message and productString as input for the shopper's question and the products with the closest descriptions matching the question, respectively.
The ConsoleCallbackHandler will output the response from LangChain to the Gadget Logs, so you can see the exact input and output from LangChain. An additional callback is defined using handleLLMNewToken to stream the response from LangChain to the shopper. The handleLLMNewToken callback is invoked every time a new token is generated by LangChain. The token will contain a fragment of the complete response, which will be pushed to the stream and returned to the shopper.
Some additional token handling is also done using tokenText. In the parser, a JSON object was defined for a response. You do not want to push the JSON object keys to the shopper, so they are filtered out using tokenText.includes('"answer": "') && !tokenText.includes('",\n'). The token is then pushed to the stream.
The stream is closed using stream.push(null);. In the case of an error, stream.destroy() is called to close the stream.
This will stream the entire chat response back to the shopper. But this isn't all you want to return, you also want additional product info so you can display product listings and provide links to the products from your frontend. To do this, you can use the productIds returned from the chain. Not all product IDs that were passed in will be used in the response, so it is important to use the IDs returned from the chain and not the IDs grabbed using the cosine similarity operation.
The returned productIds can be used as a filter on the shopifyProduct model to grab the product details for the recommended products. The await api.shopifyProduct.findMany() is a read operation that will return the fields defined in the select GraphQL query. The product title and handle are returned, as well as the product image source and the product's shop domain. The filter GraphQL query will filter the results to only include products with an ID that is in the productIds array.
Once the product details are returned, they are also sent to the route's caller via the stream.
Your route is now complete! Now all that is needed is a frontend app that allows shoppers to ask a question and displays the response along with product recommendations. You're going to use Gadget's hosted React frontends to build this UI.
All Gadget apps come with hosted Vite frontends that can be used to build your UI. You can use these frontends as a starting point for your UI, or you can build your UI from scratch. For this tutorial, you're going to use the hosted React frontend to build a chat widget that can be embedded on any page of your Shopify store.
In your Gadget app's frontend folder, create a new file called Chat.jsx. This file will contain the React component for the chat widget. The complete code for this file is below, but you'll walk through each piece of the code in the following sections.
The first thing to set up when building the chat widget is the useFetch hook. This hook is provided by Gadget and is used to make requests to the backend HTTP route. The useFetch hook takes two arguments: the backend route to call and an options object. The options object is used to configure the request. In this case, you're setting the request method to POST and the content-type header to application/json. You're also setting the stream option to true. This option tells the useFetch hook to return a stream that can be used to read the response from the backend route. The useFetch hook returns an object containing response and fetching info, and a function that can be called to make the actual request to your chat route. This function is named sendChat.
React state is also defined above to manage the text that the shopper enters into the chat widget, as well as the streamed chat response and recommended product info. The next thing to add is a <form> that makes use of this state. A shopper will use the form to ask a question. The form's onSubmit callback will handle the streamed response from the backend route using Node's built-in streaming tooling.
This code will send the user's message to the backend route and then read the response from the stream. The response will be a JSON string that contains the chat response and any recommended products. The while loop will read the response from the stream until the stream is done. The while loop will also handle the response by setting the chat response and recommended products in React state.
The next thing to do is render the chat response and recommended products in the chat widget. The chat response is rendered in a <p> tag, and the recommended products are rendered in an <a> tag. The <a> tag will link to the product's page on the shop's storefront.
Adding loading and error messaging is also nice. This can be done by rendering a <span> with a loading message when the fetching variable is true, and rendering a <p> with an error message when the error variable is true.
Now you can add the chat widget to your app's frontend. Because this is not an embedded Shopify app, you can simplify frontend/App.jsx with the following code that imports the chat widget and renders it at your app's default route:
This isn't an admin-embedded Shopify app, so you can use the Provider from @gadgetinc/react instead of the App Bridge Provider. The Provider enables you to make authenticated requests with your Gadget app's api client defined in frontend/api.js.
You can overwrite the default code in frontend/main.jsx with the following:
Finally, add some styles to make the chat widget look nice. You can copy-paste the following css into frontend/App.css:
To view your app:
Congrats! You have built a chatbot that uses the OpenAI API to find the best products to recommend to shoppers. Test it out by asking a question. The chatbot should respond with a list of products that are relevant to the question.
Have questions about the tutorial? Join Gadget's developer Discord to ask Gadget employees and join the Gadget developer community!
Want to learn more about building AI apps in Gadget? Check out our building AI apps documentation.