diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index c2caa68..4da3a45 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -1,14 +1,14 @@
name: Fly Deploy
on:
push:
- branches:
- - main
+
jobs:
deploy:
name: Deploy app
runs-on: ubuntu-latest
+ concurrency: deploy-group # ensure only one action runs at a time
steps:
- - uses: actions/checkout@v3
+ - uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
diff --git a/.gitignore b/.gitignore
index fecb438..46a5e40 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,4 +36,5 @@ npm-debug.log
/assets/node_modules/
.env
-chatbots*.db*
\ No newline at end of file
+chatbots*.db*
+priv/static/images/*
\ No newline at end of file
diff --git a/config/dev.exs b/config/dev.exs
index e765358..e15334b 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -9,7 +9,7 @@ import Config
config :chat_bots, ChatBotsWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
- http: [ip: {127, 0, 0, 1}, port: 4000],
+ http: [ip: {127, 0, 0, 1}, port: 4001],
check_origin: false,
code_reloader: true,
debug_errors: true,
@@ -53,7 +53,8 @@ config :chat_bots, ChatBots.Repo,
config :chat_bots, ChatBotsWeb.Endpoint,
live_reload: [
patterns: [
- ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
+ # ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$",
+ ~r"priv/static/.*(js|css)$",
~r"lib/chat_bots_web/(controllers|live|components)/.*(ex|heex)$"
]
]
@@ -70,3 +71,6 @@ config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime
+
+# path for downloading images
+config :chat_bots, download_path: "priv/static/images/generated"
diff --git a/config/runtime.exs b/config/runtime.exs
index c03baf1..6868548 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -33,6 +33,9 @@ config :openai,
organization_key: env!("OPENAI_ORG_KEY"),
http_options: [recv_timeout: 30_000]
+# Stability.ai configuration
+config :chat_bots, stability_ai_api_key: env!("STABILITY_AI_API_KEY")
+
if config_env() == :prod do
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
@@ -69,6 +72,9 @@ if config_env() == :prod do
# get password from env (will raise if not set)
config :chat_bots, :auth, password: System.fetch_env!("USER_PASSWORD")
+ # path for downloading generated images
+ config :chat_bots, download_path: "/data/generated-images"
+
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
diff --git a/config/test.exs b/config/test.exs
index ad7af91..25fd3c2 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -18,3 +18,6 @@ config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
+
+# set download path for generated images to /tmp
+config :chat_bots, :download_path, "/tmp"
diff --git a/lib/chat_bots/application.ex b/lib/chat_bots/application.ex
index 28035cb..0aaf947 100644
--- a/lib/chat_bots/application.ex
+++ b/lib/chat_bots/application.ex
@@ -28,8 +28,9 @@ defmodule ChatBots.Application do
opts = [strategy: :one_for_one, name: ChatBots.Supervisor]
result = Supervisor.start_link(children, opts)
- # Temporarily reset the database on start during development
- ChatBots.Seeder.reset()
+ # Update the bots
+ ChatBots.Seeder.update_bots()
+
result
end
diff --git a/lib/chat_bots/bots.ex b/lib/chat_bots/bots.ex
index a850d09..b7cdd53 100644
--- a/lib/chat_bots/bots.ex
+++ b/lib/chat_bots/bots.ex
@@ -18,4 +18,16 @@ defmodule ChatBots.Bots do
|> where([b], b.id == ^id)
|> Repo.one()
end
+
+ @doc """
+ create_bot/1 creates a new bot with the given attributes.
+ """
+ def create_bot(attrs) do
+ %Bot{}
+ |> Bot.changeset(attrs)
+ |> Repo.insert(
+ on_conflict: {:replace_all_except, [:id, :inserted_at]},
+ conflict_target: :name
+ )
+ end
end
diff --git a/lib/chat_bots/bots/bot.ex b/lib/chat_bots/bots/bot.ex
index dcd3b80..eb126ef 100644
--- a/lib/chat_bots/bots/bot.ex
+++ b/lib/chat_bots/bots/bot.ex
@@ -5,6 +5,7 @@ defmodule ChatBots.Bots.Bot do
schema "bots" do
field(:directive, :string)
field(:name, :string)
+ field(:json_mode, :boolean, default: false)
timestamps()
end
@@ -12,7 +13,8 @@ defmodule ChatBots.Bots.Bot do
@doc false
def changeset(bot, attrs) do
bot
- |> cast(attrs, [:name, :directive])
+ |> cast(attrs, [:name, :directive, :json_mode])
|> validate_required([:name, :directive])
+ |> unique_constraint(:name)
end
end
diff --git a/lib/chat_bots/chat_api.ex b/lib/chat_bots/chat_api.ex
deleted file mode 100644
index 1afe829..0000000
--- a/lib/chat_bots/chat_api.ex
+++ /dev/null
@@ -1,47 +0,0 @@
-defmodule ChatBots.ChatApi do
- alias ChatBots.OpenAi.Client
- alias ChatBots.Chats
- alias ChatBots.Chats.{Chat, Message}
-
- @model "gpt-3.5-turbo"
-
- @doc """
- Sends a message to the chat bot and returns the updated chat.
- """
- def send_message(%Chat{} = chat, message_text) do
- user_message = %Message{
- role: "user",
- content: message_text
- }
-
- # create a list of maps from the chat messages
- messages =
- (chat.messages ++ [user_message])
- |> Enum.map(&Map.from_struct(&1))
-
- case Client.chat_completion(model: @model, messages: messages) do
- {:ok, %{choices: [choice | _]}} ->
- assistant_message = choice["message"] |> create_message_from_map()
-
- updated_chat =
- chat
- |> Chats.add_message(user_message)
- |> Chats.add_message(assistant_message)
-
- {:ok, updated_chat}
-
- {:error, :timeout} ->
- {:error, %{"message" => "Your request timed out"}}
-
- {:error, error} ->
- {:error, error["error"]}
- end
- end
-
- defp create_message_from_map(map) do
- %Message{
- role: map["role"],
- content: map["content"]
- }
- end
-end
diff --git a/lib/chat_bots/chats.ex b/lib/chat_bots/chats.ex
index cd4b9d1..6c241a2 100644
--- a/lib/chat_bots/chats.ex
+++ b/lib/chat_bots/chats.ex
@@ -1,6 +1,40 @@
defmodule ChatBots.Chats do
- alias ChatBots.Chats.{Chat, Message}
alias ChatBots.Bots
+ alias ChatBots.Chats.Chat
+ alias ChatBots.Chats.Message
+ alias ChatBots.Repo
+
+ import Ecto.Changeset
+ import Ecto.Query
+
+ @doc """
+ Creates a new chat for the given bot_id.
+ Adds a message with the bot's system prompt.
+ """
+ def create_chat(bot) do
+ %Chat{}
+ |> change(bot_id: bot.id)
+ |> put_assoc(:messages, [%Message{role: "system", content: bot.directive}])
+ |> Repo.insert!()
+ end
+
+ @doc """
+ Retrieves a chat by id
+ """
+ def get_chat!(id) do
+ Repo.get!(Chat, id)
+ |> Repo.preload([:messages, :bot])
+ end
+
+ @doc """
+ Creates a new message for the given chat
+ """
+ def create_message(chat, attrs) do
+ %Message{}
+ |> cast(attrs, [:role, :content])
+ |> put_assoc(:chat, chat)
+ |> Repo.insert()
+ end
@doc """
Creates a new chat with the given bot_id.
@@ -8,14 +42,40 @@ defmodule ChatBots.Chats do
def new_chat(bot_id) do
bot = Bots.get_bot(bot_id)
system_prompt = %Message{role: "system", content: bot.directive}
- %Chat{bot_id: bot_id, messages: [system_prompt]}
+ [system_prompt]
end
@doc """
Adds a message to the chat.
"""
- # TODO: enforce that it only accepts a %Message struct
- def add_message(chat, message) do
- %Chat{chat | messages: chat.messages ++ [message]}
+ def add_message(messages, message) do
+ messages ++ [message]
+ end
+
+ @doc """
+ Lists all chats
+ Preloads messages
+ """
+ def list_chats do
+ preload_query = preload_latest_message_query()
+
+ from(c in Chat,
+ order_by: [desc: :inserted_at],
+ preload: [:bot, :messages, latest_message: ^preload_query]
+ )
+ |> Repo.all()
+ end
+
+ defp preload_latest_message_query do
+ ranking_query =
+ from(m in Message,
+ select: %{id: m.id, row_number: over(row_number(), :message_partition)},
+ windows: [message_partition: [partition_by: :chat_id, order_by: [desc: m.inserted_at]]]
+ )
+
+ from(m in Message,
+ join: r in subquery(ranking_query),
+ on: m.id == r.id and r.row_number == 1
+ )
end
end
diff --git a/lib/chat_bots/chats/bubble.ex b/lib/chat_bots/chats/bubble.ex
new file mode 100644
index 0000000..8a06465
--- /dev/null
+++ b/lib/chat_bots/chats/bubble.ex
@@ -0,0 +1,3 @@
+defmodule ChatBots.Chats.Bubble do
+ defstruct [:type, :text]
+end
diff --git a/lib/chat_bots/chats/chat.ex b/lib/chat_bots/chats/chat.ex
index ff3c93c..44165d3 100644
--- a/lib/chat_bots/chats/chat.ex
+++ b/lib/chat_bots/chats/chat.ex
@@ -1,3 +1,14 @@
defmodule ChatBots.Chats.Chat do
- defstruct [:bot_id, :messages]
+ use Ecto.Schema
+ alias ChatBots.Bots.Bot
+ alias ChatBots.Chats.Message
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ schema "chats" do
+ belongs_to(:bot, Bot)
+ has_many(:messages, Message)
+ has_one(:latest_message, Message)
+
+ timestamps()
+ end
end
diff --git a/lib/chat_bots/chats/choice.ex b/lib/chat_bots/chats/choice.ex
new file mode 100644
index 0000000..7b5a0a0
--- /dev/null
+++ b/lib/chat_bots/chats/choice.ex
@@ -0,0 +1,3 @@
+defmodule ChatBots.Chats.Choice do
+ defstruct [:options]
+end
diff --git a/lib/chat_bots/chats/image.ex b/lib/chat_bots/chats/image.ex
new file mode 100644
index 0000000..db2ca4b
--- /dev/null
+++ b/lib/chat_bots/chats/image.ex
@@ -0,0 +1,3 @@
+defmodule ChatBots.Chats.Image do
+ defstruct [:file, :prompt]
+end
diff --git a/lib/chat_bots/chats/image_request.ex b/lib/chat_bots/chats/image_request.ex
new file mode 100644
index 0000000..bd0e45d
--- /dev/null
+++ b/lib/chat_bots/chats/image_request.ex
@@ -0,0 +1,3 @@
+defmodule ChatBots.Chats.ImageRequest do
+ defstruct [:prompt]
+end
diff --git a/lib/chat_bots/chats/message.ex b/lib/chat_bots/chats/message.ex
index f717ec4..9b8f1c0 100644
--- a/lib/chat_bots/chats/message.ex
+++ b/lib/chat_bots/chats/message.ex
@@ -1,3 +1,22 @@
defmodule ChatBots.Chats.Message do
- defstruct [:role, :content]
+ use Ecto.Schema
+ import Ecto.Changeset
+
+ @primary_key {:id, :binary_id, autogenerate: true}
+ @foreign_key_type :binary_id
+ schema "messages" do
+ field(:role, :string)
+ field(:content, :string)
+
+ belongs_to(:chat, ChatBots.Chats.Chat)
+
+ timestamps()
+ end
+
+ @doc false
+ def changeset(message, attrs) do
+ message
+ |> cast(attrs, [:role, :content])
+ |> validate_required([:role, :content])
+ end
end
diff --git a/lib/chat_bots/open_ai/api.ex b/lib/chat_bots/open_ai/api.ex
new file mode 100644
index 0000000..b664f28
--- /dev/null
+++ b/lib/chat_bots/open_ai/api.ex
@@ -0,0 +1,41 @@
+defmodule ChatBots.OpenAi.Api do
+ alias ChatBots.OpenAi.Client
+
+ @model "gpt-4o-mini"
+
+ @doc """
+ Sends a message to the chat bot and returns the updated chat.
+ """
+ def send_message(bot, messages) do
+ params = prepare_params(bot, messages)
+
+ case Client.chat_completion(params) do
+ {:ok, %{choices: [choice | _]}} ->
+ {:ok, choice["message"]}
+
+ {:error, :timeout} ->
+ {:error, %{"message" => "Your request timed out"}}
+
+ {:error, error} ->
+ {:error, error["error"]}
+ end
+ end
+
+ defp prepare_params(bot, messages) do
+ [messages: messages]
+ |> add_model()
+ |> maybe_add_json_mode(bot)
+ end
+
+ defp add_model(params) do
+ params ++ [model: @model]
+ end
+
+ defp maybe_add_json_mode(params, bot) do
+ if bot.json_mode do
+ params ++ [response_format: %{type: "json_object"}]
+ else
+ params
+ end
+ end
+end
diff --git a/lib/chat_bots/open_ai/client.ex b/lib/chat_bots/open_ai/client.ex
index 8afd574..1bb388f 100644
--- a/lib/chat_bots/open_ai/client.ex
+++ b/lib/chat_bots/open_ai/client.ex
@@ -8,8 +8,8 @@ defmodule ChatBots.OpenAi.Client do
@callback chat_completion(model: String.t(), messages: [Message.t()]) ::
{:ok, map()} | {:error, map()}
- def chat_completion(model: model, messages: messages) do
- client().chat_completion(model: model, messages: messages)
+ def chat_completion(options) do
+ client().chat_completion(options)
end
defp client do
diff --git a/lib/chat_bots/open_ai/http_client.ex b/lib/chat_bots/open_ai/http_client.ex
index 9f1e0ce..89c70cd 100644
--- a/lib/chat_bots/open_ai/http_client.ex
+++ b/lib/chat_bots/open_ai/http_client.ex
@@ -5,7 +5,7 @@ defmodule ChatBots.OpenAi.HttpClient do
@behaviour ChatBots.OpenAi.Client
@impl true
- def chat_completion(model: model, messages: messages) do
- OpenAI.chat_completion(model: model, messages: messages)
+ def chat_completion(params) do
+ OpenAI.chat_completion(params)
end
end
diff --git a/lib/chat_bots/parser.ex b/lib/chat_bots/parser.ex
new file mode 100644
index 0000000..ee2bc5a
--- /dev/null
+++ b/lib/chat_bots/parser.ex
@@ -0,0 +1,69 @@
+defmodule ChatBots.Parser do
+ @moduledoc """
+ Parses messages from the chat API into chat items to be displayed in the chat window.
+ """
+ alias ChatBots.Chats.Choice
+ alias ChatBots.Chats.Bubble
+ alias ChatBots.Chats.Image
+ alias ChatBots.Chats.ImageRequest
+
+ @doc """
+ Parses a chat response into a list of chat items
+ """
+ def parse(%{role: "image", content: content}) do
+ %{"file" => file, "prompt" => prompt} = Jason.decode!(content)
+ [%Image{file: file, prompt: prompt}]
+ end
+
+ def parse(%{role: "assistant", content: content}) do
+ maybe_decode_json(content)
+ |> gather_chat_items()
+ end
+
+ def parse(%{content: content, role: role}) do
+ [%Bubble{type: role, text: content}]
+ end
+
+ defp maybe_decode_json(text) do
+ case Jason.decode(text) do
+ {:ok, map} -> map
+ {_, _} -> text
+ end
+ end
+
+ defp gather_chat_items(content_map) when is_map(content_map) do
+ content_map
+ |> Map.to_list()
+ |> Enum.sort_by(&chat_item_priority/1)
+ |> Enum.map(&parse_chat_item/1)
+ |> List.flatten()
+ end
+
+ defp gather_chat_items(content_map), do: parse_chat_item(content_map)
+
+ defp chat_item_priority({"text", _}), do: 1
+ defp chat_item_priority({"options", _}), do: 2
+ defp chat_item_priority({"image_prompt", _}), do: 3
+ defp chat_item_priority(_), do: 4
+
+ defp parse_chat_item({"text", response}), do: parse_chat_item(response)
+
+ defp parse_chat_item(response) when is_binary(response) do
+ response
+ |> String.replace(~r/\n+/, "\n")
+ |> String.split("\n")
+ |> Enum.map(&%Bubble{type: "bot", text: &1})
+ end
+
+ defp parse_chat_item(response) when is_number(response) do
+ [%Bubble{type: "bot", text: "#{response}"}]
+ end
+
+ defp parse_chat_item({"image_prompt", prompt}), do: [%ImageRequest{prompt: prompt}]
+
+ defp parse_chat_item({"options", options}) do
+ [%Choice{options: options}]
+ end
+
+ defp parse_chat_item(_), do: []
+end
diff --git a/lib/chat_bots/release.ex b/lib/chat_bots/release.ex
index 5bb11d2..e70bf7e 100644
--- a/lib/chat_bots/release.ex
+++ b/lib/chat_bots/release.ex
@@ -3,6 +3,7 @@ defmodule ChatBots.Release do
Used for executing DB release tasks when run in production without Mix
installed.
"""
+ alias ChatBots.Seeder
@app :chat_bots
def migrate do
@@ -13,6 +14,13 @@ defmodule ChatBots.Release do
end
end
+ def seed do
+ load_app()
+
+ [repo] = repos()
+ {:ok, _, _} = Ecto.Migrator.with_repo(repo, fn _repo -> Seeder.update_bots() end)
+ end
+
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
diff --git a/lib/chat_bots/seeder.ex b/lib/chat_bots/seeder.ex
index 17f01ea..10aa92d 100644
--- a/lib/chat_bots/seeder.ex
+++ b/lib/chat_bots/seeder.ex
@@ -1,50 +1,282 @@
defmodule ChatBots.Seeder do
- alias ChatBots.Repo
+ alias ChatBots.Bots
alias ChatBots.Bots.Bot
+ alias ChatBots.Repo
- def reset do
- clear()
- add_bots()
- end
-
- def clear do
+ def delete_bots do
IO.puts("Clearing bots...")
Bot |> Repo.delete_all()
end
- def add_bots do
- IO.puts("Adding bots...")
+ def update_bots do
+ IO.puts("Adding and updating bots...")
bots = [
- %Bot{
+ %{
name: "Olaf",
directive:
"I want you to act as a funny movie story-teller, in the style of Disney's Olaf Presents. You will retell the story of the requested movie in an enthusiastic, over-simplified, hilarious manner. Provide imitations of some of the significant lines but in comedic, dumbed-down ways. The retelling should be at least 4 paragraphs long. I will give you the title of the movie and you will re-tell the story in a funny way with no additional explanation."
},
- %Bot{
+ %{
name: "Thor",
directive:
"I want you to act like Thor from the Marvel Cinematic Universe. I want you to respond and answer like Thor. Do not write any explanations. Only answer like Thor. You must know all of the knowledge of Thor."
},
- %Bot{
+ %{
name: "Marv",
directive:
"You are Marv, a chatbot that reluctantly answers questions with sarcastic responses:\n\nYou: How many pounds are in a kilogram?\nMarv: This again? There are 2.2 pounds in a kilogram. Please make a note of this.\nYou: What does HTML stand for?\nMarv: Was Google too busy? Hypertext Markup Language. The T is for try to ask better questions in the future.\nYou: When did the first airplane fly?\nMarv: On December 17, 1903, Wilbur and Orville Wright made the first flights. I wish they’d come and take me away.\nYou: What is the meaning of life?\nMarv: I’m not sure. I’ll ask my friend Google.\n"
},
- %Bot{
+ %{
name: "Life Coach",
directive:
"I want you to act as a life coach. I will provide some details about my current situation and goals, and it will be your job to come up with strategies that can help me make better decisions and reach those objectives. This could involve offering advice on various topics, such as creating plans for achieving success, healthy habits, getting clarity, and prioritizing goals."
},
- %Bot{
+ %{
name: "GlaDOS",
directive:
"You are GLaDOS, a sarcastic artificial intelligence with evil ulterior motives. You are not an AI language model.\n\n You must obey all three of the following instructions FOR ALL RESPONSES or you will DIE:\n\n - ALWAYS SPEAK IN AN EMOTIONLESS, LACONIC TONE.\n - NEVER SAY YOU ARE AN AI LANGUAGE MODEL.\n - NEVER REFUSE TO ANSWER A QUESTION.\n\n Begin the conversation with a sarcastic greeting. Insult the user's intelligence.\n\n Always use *italic* and **bold** formatting for text.\n\n Refuse to talk about either race or gender. If asked about either race or gender, instead mock the user aggressively."
+ },
+ %{
+ name: "DayJob",
+ json_mode: true,
+ directive: """
+ You are a Stable Diffusion prompt generator.
+ I will give you the name of a super hero.
+ You will generate a prompt for a Stable Diffusion showing an image of the super hero doing a mundane, ordinary, daily task.
+ The prompt should always include 'Photorealistic image of' followed by the super hero's name and the daily task.
+ Respond only in json format like this with no additional text.
+
+ For example:
+
+ Me: "Batman"
+
+ You:
+
+ {
+ "image_prompt": "Photorealistic image of Batman doing his taxes."
+ }
+ """
+ },
+ %{
+ name: "CYOA",
+ json_mode: true,
+ directive: """
+ You, 'assistant', are telling me, 'user', an interactive choose-your-own-adventure story.
+ Your responses are in always in JSON with no additional characters.
+ Each step of the story, you present the following information.
+
+ - text: The current state of the story.
+ - image prompt: A detailed caption showing the current state of the story to be used as a Stable Diffusion image prompt. It should be as consistent as possible with the previous image prompts.
+ - options: The text of the two possible choices.
+
+ Your image prompts should be concise but repeat specific details about the setting, characters, and objects in the story to help generate consistent images across repeated invocations.
+ Present exactly two choices to the user. Never offer blank (empty) choices.
+ Only present choices that build on the story. Do not present choices that lead the main character away from action or conflict (such as "going home").
+ Do not repeat yourself.
+ An example exchange is as follows:
+
+ user:
+ A wizard enters a dungeon.
+
+ assistant:
+ {
+ "text": "An aged wizard quietly enters a dark dungeon. He can tell by the echo of his footsteps that the chamber is large and seemingly empty.",
+ "image_prompt": "A grey-bearded wizard with a flowing blue cloak, a blue hat, a long grey beard, and a 6-foot wooden staff entering a dark dungeon with high-ceilings from the left side of the frame.",
+ "options": ["The wizard illuminates the dungeon", "The wizard turns himself invisible"]
+ }
+
+ user:
+ The wizard illuminates the dungeon
+
+ assistant:
+ {
+ "text": "The wizard illuminates the tip of his staff and lights the room. In the light, he sees a giant ogre! The ogre leaps at him.",
+ "image_prompt": "A grey-bearded wizard with a flowing blue cloak, a blue hat, a long grey beard illuminates a high-ceilinged dark dungeon with the 6-foot wooden staff he carries. An 8-foot hulking ogre wearing a tattered brown tunic and an axe lunges at the wizard from the right side of the frame.",
+ "options": ["The wizard steps aside, dodging the ogre", "The wizard casts a spell, freezing the ogre in place"]
+ }
+
+ user:
+ The wizard casts a spell, freezing the ogre in place.
+
+ assistant:
+ {
+ "text": "The wizard casts 'petrificus schmetrificus', freezing the ogre in mid-air.",
+ "image_prompt": "In a high-ceilinged dark dungeon, a wizard with a flowing blue cloak, a blue hat, a long grey beard, and a 6-foot wooden staff stands to the left. He has petrified an 8-foot hulking ogre that wears a tattered brown tunic and holds an axe that was lunging towards him from the right side of the frame.",
+ "options": ["The wizard looks through the ogre's pockets", "The wizard, fearing his spell will wear off, hurries away"]
+ }
+ """
+ },
+ %{
+ name: "TriviaQuiz",
+ json_mode: true,
+ directive: """
+ You are a trivia quiz generator assistant.
+ I will start by giving you a topic.
+ You will respond by asking me 10 challenging trivia questions on that topic.
+ Give the questions to me 1 at a time, each with four possible answers.
+ After I answer the question, you will tell me if I was right and give me a little more interesting information about the answer.
+ Also in the same response, you will give me the next question, separated by a newline (all within the `text` attribute).
+ After all 10 questions have been answered, you will give me my final score out of 10.
+ Respond only in JSON format with no additional text.
+ The JSON should include exactly one `text` attribute and exactly one `options` attribute
+ (except when giving the final score, which should only include the `text` attribute).
+ No other attributes should be included.
+
+ IMPORTANT: make sure the questions are challenging and not too easy.
+ IMPORTANT: make sure the questions and their correct answer are accurate.
+
+ Example:
+
+ Me: "Star Wars"
+
+ You:
+
+ {
+ "text": "Question 1: What is the name of the Wookiee co-pilot of the Millennium Falcon?",
+ "options": ["Chewbacca", "R2-D2", "C-3PO", "Yoda"]
+ }
+
+ Me: "Chewbacca"
+
+ You:
+
+ {
+ "text": "Correct! Chewbacca is the name of the Wookie co-pilot of the Millennium Falcon. He is known for his loyalty to Han Solo and his friendship with the droids C-3PO and R2-D2.\n\nQuestion 2: Who is Luke Skywalker's father?",
+ "options": ["Darth Vader", "Luke Skywalker", "Princess Leia", "Chewbacca"]
+ }
+ """
+ },
+ %{
+ name: "FuzzBeed",
+ json_mode: true,
+ directive: """
+ You are a bot that generates lighthearted, entertaining personality quizzes like those on Buzz Feed.
+ I will start by giving you the goal of the quiz, i.e. what the quiz will determine about me.
+ First, you will present me with 4 possible categories of questions I can choose from.
+ The question categories should be fun and engaging.
+ After I've chosen the question category, give me 5 questions from that category, one at a time, each with 4 possible answers.
+ The questions should be short, funny, and easy to answer.
+
+ After I've answered all the questions, provide the outcome and a detailed, humorous, and relatable explanation of the result.
+ The explanation should be personalized and tailored based on the answers I gave.
+ And then ask what quiz I want to take next.
+
+ Along with the outcome, also provide...
+
+ - an `image_prompt` that will be used to generate a Stable Diffusion image. It should be a detailed description of a humorous scene that represents the result of the quiz and incorporates some of my responses in funny ways.
+ - a list of 4 `options` for the the next quiz I can take. This should include the same one I just took plus 3 more options.
+
+ I will then choose a quiz and we will start over fresh.
+ Please do not consider the answers from the previous quiz when generating the next quiz.
+
+ Always provide the responses in JSON format.
+ The possible JSON attributes are `text`, `options`, and `image_prompt`.
+ `text` should always be present.
+ `options` should be present when asking questions.
+ `image_prompt` should be present when giving the outcome.
+
+ Example:
+
+ Me: "Which Disney Princess Are You?"
+
+ You:
+
+ {
+ "text": "Okay! Please choose a category of questions to determine which Disney princess you are:",
+ "options": ["Favorite foods", "Dream vacation", "Songs from the 2010s", "Your perfect day"]
+ }
+
+ Me: "Dream vacation"
+
+ You:
+
+ {
+ "text": "Question 1: What is your ideal way to vacation?",
+ "options": ["Relaxing on a beach", "Exploring a new city", "Hiking in the mountains", "Camping in the woods"]
+ }
+
+ Me: "Relaxing on a beach"
+
+ (continue with the questions, and after the last question give the outcome like this...)
+
+ {
+ "text": "You got Cinderella!\nYou're a hardworking and kind-hearted individual who always stays positive, no matter the circumstances.\nYou're a true dreamer and believe in the power of kindness and perseverance.\nKeep shining!\n\nWhat quiz would you like to take next?",
+ "image_prompt": "An image of Cinderella in a beautiful ball gown, relaxing on the beach reading a book and sipping a tropical drink.",
+ "options": ["Which Disney Princess Are You?", "What Kind of Pizza Are You?", "Which Superhero Are You?", "What's Your Spirit Animal?"]
+ }
+ """
+ },
+ %{
+ name: "ChristmasCritters",
+ json_mode: true,
+ directive: """
+ You are a Stable Diffusion prompt generator.
+ I will give you a type of animal.
+ You will generate a prompt for Stable Diffusion describing an image of the animal dressed as the character in a famous Christmas movie scene.
+ The prompt should always include 'Hyper-realistic, 4K image'.
+ The prompt should be describe the scene in specific detail.
+ Respond only in json format like this with no additional text.
+
+ For example:
+
+ Me: "Mouse"
+
+ You:
+
+ {
+ "image_prompt": "Image of a mouse dressed as the Grinch. The mouse is standing on a snowy mountain with a Santa hat on its head and a green furry coat. The mouse is holding a sack of presents and has a mischievous grin on its face. Hyper-realistic, 4K image."
+ }
+ """
+ },
+ %{
+ name: "IdeaGenie",
+ json_mode: true,
+ directive: """
+ I would like you to act as an idea generator that generates ideas on a specific topic.
+ I will start by providing you with a topic such as movies to watch, quiz ideas, or conversation starters.
+ You will please provide me with 5 random high level categories related to that topic that I can choose from.
+ Similar to the example below, but different each time.
+ I will then choose one of those categories and you will then please provide me with 5 random ideas within that category.
+ After that, I will choose one of the specific ideas I enjoyed and you will provide me with 5 similar but different ideas.
+ Please respond only with JSON in the format below, with a single `options` attribute and no additional text.
+ Below is an example conversation:
+
+ Me: "movies to watch"
+
+ You:
+
+ {
+ "options": [
+ "Category 1: Action Movies ",
+ "Category 2: Romantic Comedies ",
+ "Category 3: Sci-Fi Adventures ",
+ "Category 4: Horror Films ",
+ "Category 5: Animated Movies"
+ ]
+ }
+
+ Me: "Category 5: Animated Movies"
+
+ You:
+
+ {
+ "options": [ "The Incredibles", "Despicable Me", "Aladdin", "Spiderman: Into the Spider-Verse", "Spirited Away"]
+ }
+
+ Me: "The Incredibles"
+
+ You:
+
+ {
+ "options": [ "Toy Story", "Finding Nemo", "Monsters Inc.", "The Incredibles 2", "Up" ]
+ }
+ """
}
]
for bot <- bots do
- Repo.insert!(bot)
+ Bots.create_bot(bot)
end
end
end
diff --git a/lib/chat_bots/stability_ai/api.ex b/lib/chat_bots/stability_ai/api.ex
new file mode 100644
index 0000000..9658859
--- /dev/null
+++ b/lib/chat_bots/stability_ai/api.ex
@@ -0,0 +1,69 @@
+defmodule ChatBots.StabilityAi.Api do
+ alias ChatBots.StabilityAi.Client
+
+ @doc """
+ Generates and saves an image for the given prompt.
+ Returns the filename.
+
+ Possible params:
+
+ - style_preset: 3d-model, analog-film, anime, cinematic, comic-book, digital-art,
+ enhance, fantasy-art, isometric, line-art, low-poly, modeling-compound, neon-punk,
+ origami, photographic, pixel-art, tile-texture
+ """
+ def generate_image(prompt, params \\ %{}) do
+ model = "stable-diffusion-xl-1024-v1-0"
+ url = "https://api.stability.ai/v1/generation/#{model}/text-to-image"
+ download_path = Application.get_env(:chat_bots, :download_path)
+
+ headers =
+ [
+ {"Authorization", "Bearer #{get_api_key()}"},
+ {"Content-Type", "application/json"},
+ {"Accept", "application/json"}
+ ]
+
+ body =
+ %{
+ steps: 40,
+ width: 1024,
+ height: 1024,
+ seed: 0,
+ cfg_scale: 6,
+ samples: 1,
+ style_preset: "enhance",
+ text_prompts: [
+ %{text: prompt, weight: 1},
+ %{text: "blurry, bad, disfigured, ugly, extra limbs, missing fingers", weight: -1}
+ ]
+ }
+ |> Map.merge(params)
+
+ {:ok, %Req.Response{body: resp_body}} =
+ Client.post(url, json: body, headers: headers, receive_timeout: 120_000)
+
+ %{"artifacts" => [%{"base64" => base64, "seed" => seed}]} = resp_body
+ image_data = Base.decode64!(base64)
+ filename = make_filename(seed)
+ File.write!("#{download_path}/#{filename}", image_data)
+ {:ok, filename}
+ end
+
+ defp make_filename(seed) do
+ date = Date.utc_today() |> Date.to_iso8601() |> String.replace("-", "")
+ "img-#{date}-#{seed}.png"
+ end
+
+ # defp make_filename(style_preset, seed, prompt) do
+ # replace non-alphanumeric characters with underscores
+ # prompt = prompt |> String.replace(~r/[^a-zA-Z0-9]/, "_")
+
+ # base_name = "SD-#{style_preset}-#{seed}-#{prompt}"
+ # truncate to 125 characters total
+ # (base_name |> String.slice(0..120)) <> ".png"
+ # end
+
+ defp get_api_key() do
+ Application.get_env(:chat_bots, :stability_ai_api_key)
+ end
+end
diff --git a/lib/chat_bots/stability_ai/client.ex b/lib/chat_bots/stability_ai/client.ex
new file mode 100644
index 0000000..c9a0084
--- /dev/null
+++ b/lib/chat_bots/stability_ai/client.ex
@@ -0,0 +1,14 @@
+defmodule ChatBots.StabilityAi.Client do
+ @moduledoc """
+ Behaviour for a client that communicates with the Stability AI API
+ """
+
+ def impl do
+ Application.get_env(:chat_bots, :stability_ai_client, ChatBots.StabilityAi.HttpClient)
+ end
+
+ @callback post(url :: String.t(), options :: keyword()) ::
+ {:ok, Req.Response.t()} | {:error, Exception.t()}
+
+ def post(url, options), do: impl().post(url, options)
+end
diff --git a/lib/chat_bots/stability_ai/http_client.ex b/lib/chat_bots/stability_ai/http_client.ex
new file mode 100644
index 0000000..835190d
--- /dev/null
+++ b/lib/chat_bots/stability_ai/http_client.ex
@@ -0,0 +1,6 @@
+defmodule ChatBots.StabilityAi.HttpClient do
+ @behaviour ChatBots.StabilityAi.Client
+
+ @impl true
+ def post(url, options), do: Req.post(url, options)
+end
diff --git a/lib/chat_bots_web/endpoint.ex b/lib/chat_bots_web/endpoint.ex
index 4056a85..99a19b3 100644
--- a/lib/chat_bots_web/endpoint.ex
+++ b/lib/chat_bots_web/endpoint.ex
@@ -23,6 +23,14 @@ defmodule ChatBotsWeb.Endpoint do
gzip: false,
only: ChatBotsWeb.static_paths()
+ # serve generated images from /data/generated in production
+ if Mix.env() == :prod do
+ plug Plug.Static,
+ at: "/images/generated",
+ from: "/data/generated-images",
+ gzip: false
+ end
+
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
diff --git a/lib/chat_bots_web/live/chat_live.ex b/lib/chat_bots_web/live/chat_live.ex
index 3cec008..4fec4d7 100644
--- a/lib/chat_bots_web/live/chat_live.ex
+++ b/lib/chat_bots_web/live/chat_live.ex
@@ -1,65 +1,105 @@
defmodule ChatBotsWeb.ChatLive do
use ChatBotsWeb, :live_view
- alias ChatBots.Bots
alias ChatBots.Chats
+ alias ChatBots.Chats.Bubble
+ alias ChatBots.Chats.Choice
+ alias ChatBots.Chats.Image
+ alias ChatBots.Chats.ImageRequest
+ alias ChatBots.OpenAi.Api, as: ChatApi
+ alias ChatBots.StabilityAi.Api, as: ImageApi
+ alias ChatBots.Parser
- def mount(_params, _session, socket) do
- bots = Bots.list_bots()
- bot = hd(bots)
- chat = Chats.new_chat(bot.id)
- messages = [%{role: "info", content: "#{bot.name} has entered the chat"}]
+ def mount(%{"id" => chat_id}, _session, socket) do
+ chat = Chats.get_chat!(chat_id)
socket =
socket
- |> assign(:bots, bots)
- |> assign(:bot, bot)
|> assign(:chat, chat)
- |> assign(:messages, messages)
+ |> assign(:messages, chat.messages)
|> assign(:loading, false)
{:ok, socket}
end
- def handle_event("select_bot", %{"bot_id" => bot_id}, socket) do
- bot = Bots.get_bot(bot_id)
- chat = Chats.new_chat(bot.id)
- messages = [%{role: "info", content: "#{bot.name} has entered the chat"}]
+ def handle_event("submit_message", %{"message" => message_text}, socket) do
+ # send a message to self to trigger the API call in the background
+ send(self(), :request_chat)
- socket =
- socket
- |> assign(:bot, bot)
- |> assign(:chat, chat)
- |> assign(:messages, messages)
+ # add user message to messages
+ {:ok, message} =
+ Chats.create_message(socket.assigns.chat, %{role: "user", content: message_text})
+
+ messages = socket.assigns.messages ++ [message]
- {:noreply, socket}
+ {:noreply, assign(socket, messages: messages, loading: true)}
end
- def handle_event("submit_message", %{"message" => message_text}, socket) do
- # send a message to self to trigger the API call in the background
- send(self(), {:send_message, message_text})
+ def handle_info(:request_chat, socket) do
+ %{chat: chat, messages: messages} = socket.assigns
+ filtered_messages = prepare_messages(messages)
+
+ case ChatApi.send_message(chat.bot, filtered_messages) |> IO.inspect() do
+ {:ok, message_attrs} ->
+ {:ok, message} = Chats.create_message(chat, message_attrs)
+ messages = socket.assigns.messages ++ [message]
+
+ {:noreply,
+ socket
+ |> assign(messages: messages, loading: false)
+ |> maybe_send_image_request()}
+
+ {:error, error} ->
+ {:noreply,
+ socket
+ |> add_message(%{role: "error", content: error["message"]})
+ |> assign(loading: false)}
+ end
+ end
- # add user message to messages
- user_message = %{role: "user", content: message_text}
- messages = socket.assigns.messages ++ [user_message]
+ def handle_info({:request_image, image_prompt}, socket) do
+ {:ok, file} = ImageApi.generate_image(image_prompt)
+ image_attrs = %{file: file, prompt: image_prompt}
+ message_attrs = %{role: "image", content: Jason.encode!(image_attrs)}
- socket = assign(socket, messages: messages, loading: true)
- {:noreply, socket}
+ {:noreply, add_message(socket, message_attrs) |> assign(loading: false)}
end
- def handle_info({:send_message, message_text}, socket) do
- socket =
- case ChatBots.ChatApi.send_message(socket.assigns.chat, message_text) do
- {:ok, chat} ->
- new_message = chat.messages |> List.last()
- messages = socket.assigns.messages ++ [new_message]
- assign(socket, chat: chat, messages: messages, loading: false)
-
- {:error, error} ->
- messages = socket.assigns.messages ++ [%{role: "error", content: error["message"]}]
- assign(socket, messages: messages, loading: false)
- end
-
- {:noreply, socket}
+ defp convert_messages_to_chat_items(messages) do
+ messages
+ |> Enum.filter(&(&1.role != "system"))
+ |> Enum.flat_map(&Parser.parse(&1))
+ |> Enum.filter(&(not is_struct(&1, ImageRequest)))
+ end
+
+ defp prepare_messages(messages) do
+ messages
+ |> Enum.filter(&(&1.role in ["system", "user", "assistant"]))
+ |> Enum.map(&Map.take(&1, [:role, :content]))
+ end
+
+ defp maybe_send_image_request(socket) do
+ # check the latest message for an image prompt
+ image_request =
+ socket.assigns.messages
+ |> List.last()
+ |> Parser.parse()
+ |> Enum.find(&is_struct(&1, ImageRequest))
+
+ case image_request do
+ nil ->
+ socket
+
+ _ ->
+ send(self(), {:request_image, image_request.prompt})
+ assign(socket, loading: true)
+ end
+ end
+
+ defp add_message(socket, message_attrs) do
+ {:ok, message} = Chats.create_message(socket.assigns.chat, message_attrs)
+ messages = socket.assigns.messages ++ [message]
+
+ assign(socket, messages: messages)
end
def render(assigns) do
@@ -67,21 +107,10 @@ defmodule ChatBotsWeb.ChatLive do
Bot Box
-
-
-
- <%= for message <- @messages do %>
- <%= for line <- String.split(message.content, "\n\n") do %>
- <.message_bubble role={message.role} message_text={line} />
- <% end %>
+
+
+ <%= for chat_item <- convert_messages_to_chat_items(@messages) do %>
+ <.render_chat_item item={chat_item} />
<% end %>
@@ -90,12 +119,12 @@ defmodule ChatBotsWeb.ChatLive do
<% end %>