Tutorial

Building a ChatGPT Chatbot with Elixir and Phoenix LiveView

Phoenix 1.7 chatgpt

Unless you have been living under a rock the last six months, you haven’t missed the the news about OpenAI's ChatGPT and its extremely cool text comprehension and text generation features.

In this tutorial, I will go through the process of integrating ChatGPT with an Elixir backend, using Phoenix LiveView for real-time updates. It is luckily very easy to get started with, but it requires that you have an OpenAI account. There are also other LLM (large language model) providers that this tutorial should work with.

The first steps are setting up the the Elixir project and OpenAI environment, then move on to building the backend logic for handling conversations, and finally, create a responsive frontend to allow users to interact with our chatbot.

Step 1: Generating Schemas

We'll start by generating schemas for our chatbot. In your terminal, run these commands:

mix phx.gen.schema Chatbot.Conversation chatbot_conversations
mix phx.gen.schema Chatbot.Message chatbot_messages conversation_id:references:chatbot_conversations

The first command generates a new schema for conversations, and the second command generates a schema for messages, which belong to a conversation.

Then run migrations to create the tables:

mix ecto.migrate

Step 2: Defining the Conversation Schema

Next, we'll make changes to the Conversation schema. This schema will include a session_id for tracking the session, a resolved_at field to mark when a conversation is completed, and a relationship to the Message schema:

# lib/tutorial/chatbot/conversation.ex

defmodule Tutorial.Chatbot.Conversation do
  use Ecto.Schema
  import Ecto.Changeset

  schema "chatbot_conversations" do
    field :resolved_at, :naive_datetime

    has_many :messages, Tutorial.Chatbot.Message, preload_order: [desc: :inserted_at]

    timestamps()
  end

  @doc false
  def changeset(conversation, attrs) do
    conversation
    |> cast(attrs, [:resolved_at])
  end
end

Step 3: Defining the Message Schema

After that, we'll make a few updates to the Message schema. Each message will have a content field for the message's text, a role to specify who sent the message, and a relationship to the Conversation schema:

# lib/tutorial/chatbot/message.ex

defmodule Tutorial.Chatbot.Message do
  use Ecto.Schema
  import Ecto.Changeset

  schema "chatbot_messages" do
    field :content, :string
    field :role, :string

    belongs_to :conversation, Tutorial.Chatbot.Conversation

    timestamps()
  end

  @doc false
  def changeset(message, attrs) do
    message
    |> cast(attrs, [:role, :content])
    |> validate_required([:content])
  end
end

Step 4: Creating the Chatbot Context

Next, we'll create the Chatbot context in lib/tutorial/chatbot.ex. This module will contain functions for interacting with our Conversation and Message schemas, such as creating and updating conversations and messages:

defmodule Tutorial.Chatbot do
  @moduledoc """
  The Chatbot context.
  """
  import Ecto.Query, warn: false
  alias Tutorial.Repo

  alias Tutorial.Chatbot.Conversation
  alias Tutorial.Chatbot.Message

  def list_chatbot_conversations do
    Repo.all(Conversation)
  end

  def create_conversation(attrs \\ %{}) do
    %Conversation{}
    |> Conversation.changeset(attrs)
    |> Repo.insert()
  end

  def update_conversation(%Conversation{} = conversation, attrs) do
    conversation
    |> Conversation.changeset(attrs)
    |> Repo.update()
  end

  def create_message(conversation, attrs \\ %{}) do
    %Message{}
    |> Message.changeset(attrs)
    |> Ecto.Changeset.put_assoc(:conversation, conversation)
    |> Repo.insert()
  end

  def change_message(%Message{} = message, attrs \\ %{}) do
    Message.changeset(message,    attrs)
  end
end

Step 5: Implementing OpenAI Service

After setting up our Chatbot context, we need to create a module to handle interactions with OpenAI's ChatGPT. Create a new file and add the following code:

# lib/tutorial/chatbot/openai_service.ex

defmodule Tutorial.Chatbot.OpenaiService do
  defp default_system_prompt do
    """
    You are a chatbot that only answers questions about the programming language Elixir.
    Answer short with just a 1-3 sentences.
    If the question is about another programming language, make a joke about it.
    If the question is about something else, answer something like:
    "I dont know, its not my cup of tea" or "I have no opinion about that topic".
    """
  end

  def call(prompts, opts \\ []) do
    %{
      "model" => "gpt-3.5-turbo",
      "messages" => Enum.concat([
        %{"role" => "system", "content" => default_system_prompt()},
      ], prompts),
      "temperature" => 0.7
    }
    |> Jason.encode!()
    |> request(opts)
    |> parse_response()
  end

  defp parse_response({:ok, %Finch.Response{body: body}}) do
    messages =
      Jason.decode!(body)
      |> Map.get("choices", [])
      |> Enum.reverse()

    case messages do
      [%{"message" => message}|_] -> message
      _ -> "{}"
    end
  end

  defp parse_response(error) do
    error
  end

  defp request(body, _opts) do
    Finch.build(:post, "https://api.openai.com/v1/chat/completions", headers(), body)
    |> Finch.request(Tutorial.Finch)
  end

  defp headers do
    [
      {"Content-Type", "application/json"},
      {"Authorization", "Bearer #{Application.get_env(:tutorial, :open_ai_api_key)}"},
    ]
  end
end

This module interacts with OpenAI's API to generate responses based on the prompts provided.

Step 6: Integrating OpenAI Service into Chatbot

Finally, integrate the OpenaiService into our Chatbot module by adding a generate_response function. This function takes a conversation and a list of messages as arguments, selects the last five messages, and sends them to the OpenaiService for processing. The response from OpenAI is then saved as a new message in the conversation:

# lib/tutorial/chatbot.ex

alias Tutorial.Chatbot.OpenaiService

def generate_response(conversation, messages) do
  last_five_messages =
    Enum.slice(messages, 0..4)
    |> Enum.map(fn %{role: role, content: content} ->
      %{"role" => role, "content" => content}
    end)
    |> Enum.reverse()

  create_message(conversation, OpenaiService.call(last_five_messages))
end

That wraps up the backend part this tutorial. Now there is a basic chatbot that can interact with OpenAI's ChatGPT to generate responses. In the next part, we'll work on the frontend to create a user interface for our chatbot.

Step 7: Create a LiveView Module

Create a new file lib/tutorial_web/live/chatbot_live.ex. This is the main LiveView module that will handle rendering the chatbot and responding to user inputs.

The mount function initializes the LiveView. It retrieves or creates a new conversation, and assigns it to the socket.

The handle_info functions are used to handle messages that are received from the FormComponent or from an async task.

The render function defines how the chatbot is displayed. It includes a loop that displays all messages in the conversation, as well as a live component that displays a form for users to submit new messages.

# lib/tutorial_web/live/chatbot_live.ex

defmodule TutorialWeb.ChatbotLive do
  use Phoenix.LiveView, container: {:div, [class: "fixed right-0 bottom-0 mr-4"]}

  alias Phoenix.LiveView.JS

  alias Tutorial.Chatbot
  alias Tutorial.Chatbot.Message

  @impl true
  def mount(_params, _session, socket) do

    # For this tutorial, we are only working with a single conversation
    conversation =
      case Chatbot.list_chatbot_conversations() do
        [conversation|_] ->
          conversation
        _ ->
          {:ok, conversation} = Chatbot.create_conversation()
          conversation
      end

    {
      :ok,
      socket
      |> assign(:conversation, conversation)
      |> assign(:message, %Message{})
      |> assign(:messages, [])
    }
  end

  @impl true
  def handle_info({TutorialWeb.ChatbotLive.FormComponent, {:saved, message}}, socket) do
    messages = [message|socket.assigns.messages]

    Task.async(fn ->
      Chatbot.generate_response(socket.assigns.conversation, messages)
    end)

    {
      :noreply,
      socket
      |> assign(:message, %Message{})
      |> assign(:messages, messages)
    }
  end

  def handle_info({ref, result}, socket) do
    Process.demonitor(ref, [:flush])

    messages =
      case result do
        {:ok, message} -> [message|socket.assigns.messages]
        _ -> socket.assigns.messages
      end

    {
      :noreply,
      socket
      |> assign(:messages, messages)
    }
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div class="flex flex-col flex-grow w-full max-w-xl bg-white shadow-xl rounded-t-lg overflow-hidden">
      <div class="flex flex-col flex-grow p-4 overflow-auto max-h-[50vh]">
        <div class="flex w-full mt-2 space-x-3 max-w-xs">
          <img class="flex-shrink-0 h-10 w-10 rounded-full bg-gray-300" src="https://images.unsplash.com/photo-1589254065878-42c9da997008?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80" alt="">
          <div>
            <div class="bg-gray-300 p-3 rounded-r-lg rounded-bl-lg">
              <p class="text-sm">Hi. I am here to answer questions about Elixir.</p>
            </div>
            <span class="text-xs text-gray-500 leading-none">Now</span>
          </div>
        </div>
        <%= for message <- Enum.reverse(@messages) do %>
          <div id={"message-#{message.id}"} phx-mounted={JS.dispatch("scrollIntoView", to: "#message-#{message.id}")}>
            <div :if={message.role == "user"} class="flex w-full mt-2 space-x-3 max-w-xs ml-auto justify-end">
              <div>
                <div class="bg-blue-600 text-white p-3 rounded-l-lg rounded-br-lg">
                  <p class="text-sm"><%= message.content %></p>
                </div>
                <span class="text-xs text-gray-500 leading-none">Now</span>
              </div>
              <img class="flex-shrink-0 h-10 w-10 rounded-full bg-gray-300" src="https://images.unsplash.com/photo-1506794778202-cad84cf45f1d?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80" alt="">
            </div>

            <div :if={message.role == "assistant"} class="flex w-full mt-2 space-x-3 max-w-xs">
              <img class="flex-shrink-0 h-10 w-10 rounded-full bg-gray-300" src="https://images.unsplash.com/photo-1589254065878-42c9da997008?ixlib=rb-1.2.1&amp;ixid=eyJhcHBfaWQiOjEyMDd9&amp;auto=format&amp;fit=facearea&amp;facepad=2&amp;w=256&amp;h=256&amp;q=80" alt="">
              <div>
                <div class="bg-gray-300 p-3 rounded-r-lg rounded-bl-lg">
                  <p class="text-sm"><%= message.content %></p>
                </div>
                <span class="text-xs text-gray-500 leading-none">Now</span>
              </div>
            </div>
          </div>
        <% end %>
      </div>

      <div class="bg-gray-300 p-4">
        <.live_component
          module={TutorialWeb.ChatbotLive.FormComponent}
          id="new-message"
          conversation={@conversation}
          message={@message}
        />
      </div>
    </div>
    """
  end
end

Step 8: Create a FormComponent LiveComponent

Create a new file lib/tutorial_web/live/chatbot_live/form_component.ex. This is a LiveComponent that handles the form for submitting new messages.

The handle_event functions are used to handle changes to the form fields and submissions of the form. When a message is successfully saved, it sends a message to its parent LiveView.

# lib/tutorial_web/live/chatbot_live/form_component.ex

defmodule TutorialWeb.ChatbotLive.FormComponent do
  use TutorialWeb, :live_component

  alias Tutorial.Chatbot

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <.form
        for={@form}
        id="message-form"
        phx-target={@myself}
        phx-change="change"
        phx-submit="save"
      >
        <.input field={@form[:content]} value={@content} type="text" placeholder="Type your message" />
      </.form>
    </div>
    """
  end

  @impl true
  def update(%{message: message} = assigns, socket) do
    changeset = Chatbot.change_message(message)

    {:ok,
     socket
     |> assign(assigns)
     |> assign(:content, "")
     |> assign_form(changeset)}
  end

  @impl true
  def handle_event("change", %{"message" => %{"content" => content}}, socket) do
    {:noreply, assign(socket, :content, content)}
  end

  @impl true
  def handle_event("save", %{"message" => message_params}, socket) do
    # Manually put user as role
    message_params = Map.put(message_params, "role", "user")
    
    case Chatbot.create_message(socket.assigns.conversation, message_params) do
      {:ok, message} ->
        notify_parent({:saved, message})
        {:noreply, assign(socket, :content, "")} # Reset input field after submit
      {:error, %Ecto.Changeset{} = changeset} ->
        {:noreply, assign_form(socket, changeset)}
    end
  end

  defp assign_form(socket, %Ecto.Changeset{} = changeset) do
    assign(socket, :form, to_form(changeset))
  end

  defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
end

Note that after the form is submitted, there is logic for resetting the input form.

Step 9: Add smooth scrolling to latest message

When writing a few messages, they soon get out of sight in the message window. I need to add some customization to make sure that the last message is getting scrolled into view when it is created. To do that, I have this code in the markup:

phx-mounted={JS.dispatch("scrollIntoView", to: "#message-#{message.id}")}

That means, when a new message is added do the DOM, the LiveView JS will dispatch an event.

Update assets/js/app.js to add a JavaScript event listener that scrolls to the last message when it is added.

// assets/js/app.js
window.addEventListener("scrollIntoView", event => {
  event.target.scrollIntoView({behavior: "smooth"})
})

Step 10: Mount the chatbot

Finally, update lib/tutorial_web/components/layouts/root.html.heex to include the chatbot in the page. The live_render function is used to render the ChatbotLive module.

<!-- lib/tutorial_web/components/layouts/root.html.heex -->
<body class="bg-white antialiased">
  <%= @inner_content %>
  <%= live_render @conn, TutorialWeb.ChatbotLive, session: %{} %>
</body>

With this, you now have a fully functional chatbot that interacts with OpenAI's ChatGPT, stores conversations in a database, and has a user interface for users to interact with it.