Bright ideas and techniques for building with Convex.
Profile image
Ian Macartney
a year ago

Building a Full-Stack ChatGPT app

Building a fullstack chat-gpt application

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, and a running version is here (for now). 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.

Build in minutes, scale forever.

Convex is the backend application platform with everything you need to build your project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started

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:

Build in minutes, scale forever.

Convex is the backend application platform with everything you need to build your project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started