Tutorial
Building a ChatGPT Chatbot with Elixir and Phoenix LiveView
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&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&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&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&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&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&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.