Building a Full-Stack ChatGPT app
In this post, we’ll walk through putting together a full-stack chat app, and add some features.
We’ll use React and Vite, though any React framework works. We’ll use Convex as the backend, where our server-side functions will run, and where we’ll store our app’s data.
To see this in action, the code is here. You can clone it and run it yourself with a few configurations in the README, but read on to see a step by step guide on building your own. Also, the published version uses the "authed" branch in the repo, in case you want to see how simple it is to add auth.
To run this yourself, you’ll need to make an OpenAI account and get an API key.
If you’re familiar with Convex, you can skip ahead to step 2.
Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.
0. Bootstrap a Vite React app
If you don’t already have an app, we can make one:
npm create vite@latest
I picked convex-chatgpt
as the project name, React
as the framework, and Javascript
as the variant.
At this point, if we run:
npm install
npm run dev
we have a locally running webapp.
Let’s change src/App.jsx
to list messages and have a form to submit messages:
App.jsx
import {useState} from "react";
import "./App.css";
function App() {
const messages = [
{author: "user", body: "Hello, world"},
];
const sendMessage = body =>
console.log("Trying to send: " + body);
const [newMessageText, setNewMessageText] =
useState("");
return (
<div className="App">
{messages.map((message, i) => (
<p key={i}>
<span>{message.author}: </span>
<span style={{ whiteSpace: "pre-wrap" }}>
{message.body ?? "..."}
</span>
</p>
))}
<form onSubmit={(e) => {
e.preventDefault();
setNewMessageText("");
sendMessage(newMessageText);
}}>
<input
value={newMessageText}
onChange={e => setNewMessageText(e.target.value)}
placeholder="Write a message…"
/>
<input type="submit" value="Send" disabled={!newMessageText} />
</form>
</div>
);
}
export default App;
At this point, we have an app that shows a static list of messages and logs to the console when trying to send a message.
1. Add Convex
This is similar to the Convex quickstart. In a new terminal (leave the other one running npm run dev
):
npm install convex
npx convex dev
If you haven’t used convex before, you’ll be prompted to log in, create an account, etc. I picked convex-chatgpt
as the project name. npx convex dev
will deploy these functions to your new Convex backend, and as you edit the functions, it will automatically re-deploy them. It will also ask you to save the deployment URL into your .env
and .env.local
files: these point at your convex backends for your production and development deployments. We’ll just be working with the development deployment here.
Let’s add the ability to send messages and list messages. We will add functions that run in Convex (on a server) that will read and write to the database with a query and mutation.
Add convex/messages.ts
:
import { query, mutation } from "./_generated/server";
export const list = query(async (ctx) => {
return await ctx.db.query("messages").collect();
});
export const send = mutation(async (ctx, { body }) => {
await ctx.db.insert("messages", {
body,
author: "user",
});
const botMessageId = await ctx.db.insert("messages", {
author: "assistant",
});
});
To access Convex from inside React, you’ll need to add a <ConvexProvider>
context at the top level of your app. Edit main.jsx
:
...
import { ConvexProvider, ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<ConvexProvider client={convex}>
<App />
</ConvexProvider>
</React.StrictMode>,
);
We can then use these functions from App.jsx
:
function App() {
const messages = useQuery(api.messages.list) || [];
const sendMessage = useMutation(api.messages.send);
...
Great! Now we have messages being written to and from the database. Try it out! Those new to Convex will be surprised to see that adding new messages will automatically result in the useQuery(api.messages.list)
hook returning new messages. This is part of the magic of Convex. Learn more about it here.
You can check out your data in the dashboard: npx convex dashboard
.
You may note that the query and mutation are named by the filename:function
. See more about this here.
You’ll notice every time we send a message, there’s a “…” message from the assistant. Next let’s update that message from chatGPT.
2. Send a message to the ChatGPT API
So far we’ve been working with a query and mutation, which are Convex functions that interact with the database. In order to interact with external services, we need to do that in an “action” - which is a Convex function that isn’t inside a deterministic environment and isn’t part of a database transaction. This frees us to do things with side effects, such as making requests to OpenAI’s API. We’ll do this in a few steps.
Fetching messages to send to ChatGPT
In our send
mutation, we can grab the latest 10 messages to send to ChatGPT using a database query:
export const send = mutation(async (ctx, { body }) => {
//... insert messages to the table
const messages = await ctx.db
.query("messages")
.order("desc")
.filter((q) => q.neq(q.field("body"), undefined))
.take(21);
messages.reverse();
return { messages, botMessageId };
});
This orders messages in descending order (sorted by creation time unless you use a different index), filters out messages with no body (for instance, the new placeholder system message), and takes the first 21 (which are the 21 most recent messages). It then reverses the list so it’s ordered in ascending time order. I picked 21 so that we’ll have 10 pairs of user/bot messages, followed by the latest prompt. It returns the messages to be used as input to the chat completion API, which we'll add next.
Creating the action
We’ll use the openai
npm package. You can install it with:
npm install openai
Make a new file: convex/openai.js
:
"use node";
import { Configuration, OpenAIApi } from "openai";
import { action } from "../_generated/server";
export const chat = action(async (ctx, { body }) => {
const { messages, botMessageId } = await ctx.runMutation(api.messages.send, { body });
const fail = async (reason) => throw new Error(reason);
// Grab the API key from environment variables
// Specify this in your dashboard: `npx convex dashboard`
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
await fail("Add your OPENAI_API_KEY as an env variable");
}
const configuration = new Configuration({ apiKey });
const openai = new OpenAIApi(configuration);
const openaiResponse = await openai.createChatCompletion({
model: "gpt-3.5-turbo",
messages: [
{
role: "system",
content: instructions,
},
...messages.map(({ body, author }) => ({
role: author,
content: body,
})),
],
});
if (openaiResponse.status !== 200) {
await fail("OpenAI error: " + openaiResponse.statusText);
}
const body = openaiResponse.data.choices[0].message.content;
console.log("Response: " + body);
});
This will first send the messages with the send mutation we modified, getting in return the list of messages and message ID to update with the bot’s message. It will then make a request to the gpt-3.5-turbo
model, passing in one system message with instructions (hard-coded for now), followed by each message. We’re turning our body & author fields into “role” and “content”. See their docs here for more details on the API.
Add your API key to the backend
To authenticate requests to OpenAI, you’ll need to add your API key to your Convex backend so your action (which runs on Convex’s servers) has access to it. You can set this on the command line with:
npx convex env set OPENAI_API_KEY <your key>
npx convex env --prod set OPENAI_API_KEY <your key>
Or in the dashboard. While you’re there, you can add it as an env variable to both your production and development deployments by toggling to "Production" in the dropdown in the top of the page.
Triggering the action from the UI
We can change our call to useQuery(api.messages.send)
to
const sendMessage = useAction(api.openai.chat);
Note the action is addressed by the path to the file (openai
) and the function (chat
). The API is the same (taking one argument, body
), so it’s a drop-in replacement!
At this point, if you run it, it will send a request to ChatGPT and console.log the response. Convex prints Convex function logs to the browser’s console, though you can also see them in the dashboard, to prove to yourself it’s running in the cloud. Now let’s show them in the UI.
Updating the message with the reply
In order to get the response into the chat message, we’ll want to update the empty placeholder message we added. When we called our first mutation, the messages are committed to the database and the UI can show the new messages before doing the slow call to OpenAI. Once we get the response from OpenAI, we update the bot’s message with a new mutation update
(below) and the UI will update automatically via the existingapi.messages.list
query. We’ll add the new update
mutation in the convex/messages.js
file:
// An `internalMuation` can only be called from other server functions.
export const update = internalMutation(async (ctx, { messageId, patch }) => {
await ctx.db.patch(messageId, patch);
});
This patches the specified message. You could pass in {body: "hi"}
to change the message’s body to “hi,” for example. Since the first mutation returned the botMessageId
, we can use that from the action in convex/openai.js
:
export const chat = action(async (ctx, { body }) => {
const { messages, botMessageId } = await ctx.runMutation(internal.messages.send, { body });
// Call OpenAI here
await runMutation(ap.messages.update, {
messageId: botMessageId,
patch: {
body: openaiResponse.data.choices[0].message.content,
// Track how many tokens we're using for various messages
usage: openaiResponse.data.usage,
updatedAt: Date.now(),
// How long it took OpenAI
ms: Number(openaiResponse.headers["openai-processing-ms"]),
}
});
});
While we’re here, we can store a bunch of interesting data into the message along with the body. Convex’s database is flexible enough to store strings, numbers, javascript objects, arrays, etc. while also letting you nail down a schema later to get typescript types, autocomplete, etc.
🎉 Now you have chatGPT replying to your messages!
Summary
In this post, we made a full-stack web app to chat with OpenAI’s ChatGPT API. Let us know in Discord what you think, and if you’d like to see some extensions:
- Customizing the identity you’re talking to.
- Moderating the identities and messages to prevent harmful content.
- Create new message threads. (See the code for how it's done!)
Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.