Engineering

First Student-Taught Project: Building a ChatGPT Clone with JavaScript and Clack

Published

Make a nicely formatted ChatBot in your terminal with JavaScript and Clack. A great introduction to using APIs to build command line applications.

header image

ChatGPT is great and provides a quick way to gain a little bit of knowledge about practically anything. However, if you are like me, you spend a lot of time in your terminal; not having to alt-tab to a browser would be a huge timesaver.1

In this tutorial, we are going to make a command line application which clones OpenAI’s ChatGPT using the official API to give the same chat functionality in your terminal.

This take-home lab is designed to give you a bit of experience with:

Steps

Install Node

Make sure you have node package manager npm and node installed on your system. If you do not have it installed, you can download it from the official website.

Create a Node project

First, open up a terminal and create a new directory for your project. Then change into that directory. Next, create a new npm project which will create a special package.json file which stores dependencies and other information needed to run, build and publish your project.

mkdir chat-gpt-cli
cd chat-gpt-cli
npm init -y
code .

Install the required dependencies

Writing your own code is hard. However, node has a huge ecosystem of packages which can make your life easier. In this case, we are going to use:

npm install -D cleye @clack/prompts openai

Create a file for your entrypoint

index.js is the entrypoint for your node program. However, index.js by default is not set up to parse command-line arguments. Handling these arguments requires cleye.3

import { cli } from "cleye";
import { outro, text, spinner } from "@clack/prompts";
import OpenAI from "openai";

const argv = cli({
  name: "index.js",
  parameters: ["[arguments...]"],
});

let initial_prompt = argv._.arguments.join(" ");
Arguments are an array of strings. Because spaces define separate arguments, joining separate words together with a space is required to get back to string form.

Editing your package.json

Node does not like to use import statements unless you tell it to. To do this, you need to add a type field to your package.json file.

{
  "type": "module"
}

Getting Your API Key

We are going to do this a little bit backwards and explain how to get API keys into your program securely. To do this, we need to leverage environment variables. While scary-sounding, your environment variables look something like this:

Example Code:

export USERNAME=johndoe
export GITHUBAPIKEY=n83ncxz9m39a012

In my opinion, using upper case for environment variables is less readable when compared to snake_case or camelCase. Using all caps is merely convention through.

These are effectively “common variables” which other programs can access. Reading an environment variable is easy and something which is accessible to all programs.

echo $GITHUBAPIKEY
const github_api_key = process.env.GITHUBAPIKEY;

Why not just store it in your code?

Why not just put the key in your code? First, having keys in your code can made updating them kind of cumbersome (changing environment variables remotely can be really quick). Second, security. You could accidentally commit your code to a public repository. One of your team members could go rouge and post it online. You could accidentally use the production api key in testing and accidentally delete your user’s data.

Committing your API key accidentally is such a large problem on sites like GitHub that automated companies will search your code for “high entropy strings” and automatically notify you if detected.

Creating a OpenAI account

Third-party service setup can be a bit tedious, but actually going through the motions is good practice. Got to playground.openai.com and setup an account if you have not made one already.

Cost to use chat gpt in browser

This technically costs money, but this particular API is comically cheap. My cost to use gpt-3.5-turbo has been less than $0.01 for about 150 requests.

Set a limit

Do this. No one talks about the accidentally leaving a web service running to poverty pipeline. I though that $5 was reasonably fo me and adjusting this is easy in the future. If immediately losing access is a problem, setting a soft limit will give you an email when you are about to reach it.

Setting a limit

I once left an AWS Free Tier S3 Instance on and it ended up costing me several hundred dollars.

Create an API key

Go to the OpenAI API keys page and create a new key.

Creating an api key

Save this key to a file called .env in the main directory of your project. Then, add the following line which will allow you to access the key in your code.

export OPENAI_API_KEY=/* Your AI key */
Fun Fact: Files or folders with a leading `.` are not shown the the user immediately on MacOS or Linux. This is why the folder `.git` stores all of the information about a repository and `.env` can store environment values. In addition, programs like **Visual Studio Code** and **NetBrains** will create hidden `.` prefixed configuration files.

Using your API key

Set your API key to a local variable when the user runs your command. This is where the special process.env object comes in. Process is a global object which is available by default to all node programs. ‘env’ is a property of process which is a dictionary (key-value pairs) of all of your environment variables.

const openai = new OpenAI({
  apiKey: process.env["OPENAI_API_KEY"],
});

One of ChatGPT’s killer features is the ability to answer new questions with the context of older ones. When we call the API, we not only provide a single question, but also a list of prior ones.

const chatHistory = []; // when the conversation starts, no history is present

Code architecture

ChatGPT is — essentially — a series of prompts and responses. Therefore, instead of having a large for loop which handles each prompt, we could alternatively make a prompt function which handles this behavior for us.

The prompt should first ask the user for some text. This is where clack can make both our output and code look much nicer.

async function prompt() {
  const userPromptText = await text({
    message: "What do you want to say?",
    placeholder: `send a message (type 'exit' to quit)`,
    validate: (value) => {
      if (!value) return "please enter a valid prompt";
    },
  });

  // more code to come
}

Now, we have the user’s prompt. Before doing anything else, a quick check to make sure the prompt is not exit is needed before going on.

  if(userPromptText === 'exit'){ // javascript is weird and users triple equals to check for equality. Note using this can lead to some weird bugs
    outro("By, thanks for chatting with us")
    process.exit(0); // 0 means the program did not crash
  })

Now we are ready to start making a request. Using a spinner before going any further provides a visual indication that the program has not hung. After this, we must add the userPromptText to the chat history. Then, we must make a call to OpenAI to get the response.

// still in prompt

const waitSpinner = spinner();
waitSpinner.start("Thinking...");

chatHistory.push({
  role: "user",
  content: userPromptText,
});

const generatedText = await getResponse({
  prompt: chatHistory, // the history is the prompt
  openAIKEY,
});

Because ChatGPT takes a few seconds to generate a response, the API gives a stream of data which is shown to the user incrementally (think about how ChatGPT gives you a word-by-word response). However, dealing with streams in this context is more complicated than necessary, so you are encourage this pre-provided snippet of code.4

async function getResponse(chatHistory) {
  const completion = await openai.chat.completions.create({
    model: "gpt-3.5-turbo",
    messages: chatHistory,
  });
  return res.data.choices[0].message.content;
}

In your process code, you want to first stop your spinner with a message. Then write the text you received as a string to standard output. Then, use a recursive call — a function which calls itself — to continue the chat loop.

waitSpinner.stop("text completed");

console.log(generatedText);

console.log("\n\n"); // adding a few new lines

prompt(); // recursive call

// end of prompt

Putting it all together

In case I lost you there, here is the full code for index.js. Alternatively, you are able to see the full project on Github.

import { cli } from "cleye";
import { outro, text, spinner } from "@clack/prompts";
import OpenAI from "openai";

const openai = new OpenAI({
  apiKey: process.env["OPENAI_API_KEY"],
});

const argv = cli({
  name: "index.js",
  parameters: ["[arguments...]"],
});

const chatHistory = [];

let initialPrompt = argv._.arguments.join(" ");

// TODO: Maybe you could find something to do with the initial prompt

async function getResponse(chatHistory) {
  const completion = await openai.chat.completions.create({
    model: "gpt-3.5-turbo",
    messages: chatHistory,
  });

  return completion.choices[0].message.content;
}

async function promise() {
  const userPromptText = await text({
    message: "What do you want to say?",
    placeholder: `send a message (type 'exit' to quit)`,
    validate: (value) => {
      if (!value) return "please enter a valid prompt";
    },
  });

  if (userPromptText === "exit") {
    outro("By, thanks for chatting with us");
    process.exit(0);
  }
  const waitSpinner = spinner();
  waitSpinner.start("Thinking...");

  chatHistory.push({
    role: "user",
    content: userPromptText,
  });

  const generatedText = await getResponse(chatHistory);

  waitSpinner.stop("Generation finished");

  console.log(generatedText);
  console.log("\n\n");

  chatHistory.push({
    role: "system",
    content: generatedText,
  });

  promise();
}

promise();

Summary

This was a very busy activity, but through this, you got initial exposure to many things. Namely:

However, this is not a complete program as there are some UI / UX steps we can take to make the output more human readable. A couple of these problems represent good first step to improve your program.

  1. Using clack to display the results of the model
  2. Rendering code block which have \“ code ```` surrounding to be drawn separately. This behavior comes from the Markdown Syntax Guide
  1. Saving chatHistory to a file using the JSON file format and adding the ability to save and load previous conversations. a. require('fs') and then using fs.writeFileSync(location, text) b. fs.readDir(folder, (err, files) => {/* Process them */}) will give you a list of files in a directory as a list c. JSON.stringify(object) will turn an object into a string d. JSON.loads(object) will turn a string into an object e. clack has a multiselect option which takes a list of objects with labels and values and allows you to accept an option from them. You could use multiselect to select an old conversation to load. f. console.clear() will clear the console and can help clean things up after a conversation is loaded.

References

  1. This tutorial was made for my Machine-Learning with TensorFlow JS course originally. I’m republishing it here for greater accessibility.
  2. The inspiration for this tutorial came from BuilderIO’s AI Shell which is a great project and open-source alternative to GitHub Copilot X.
  3. If you want to see a completed version of this project, check out the GitHub Repo.

Footnotes

  1. The hero image image presented is from Google’s Gemini (formerly Bard)

  2. OpenAI has a REST API which is a series of endpoints for starting chats, creating accounts and more. However, OpenAI also has a node module which wraps the API into a nice set of functions for you and automatically handles the minor processing which you would have to otherwise implement. It is quite common for popular APIs to have wrappers in sometimes multiple languages (python, javascript, java are common). Reusing their pre-built wrapper code can be a great way to save time.

  3. Cleye is a pun as is phonetically similar to the acronym for command line interface, CLI.

  4. If you are up for the challenge, you can learn more about streaming from the guide How To Stream Completions


Previous post

Five Tips I Use to Keep My Jupyter Notebooks Efficient

Next post

Binary to Hex conversions to pratice game


Stay in touch

Subscribe to my RSS feed to stay updated

RSS

Have any questions

Feel free to contact me! I will answer any and all inquires

Email