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 %>
-
+
@@ -110,29 +139,60 @@ defmodule ChatBotsWeb.ChatLive do """ end - defp message_bubble(%{role: "error"} = assigns) do + defp render_chat_item(%{item: %Bubble{type: "error"}} = assigns) do ~H""" -

Error: <%= @message_text %>

+

Error: <%= @item.text %>

""" end - defp message_bubble(assigns) do + defp render_chat_item(%{item: %Bubble{}} = assigns) do ~H""" -

<%= @message_text %>

+

<%= @item.text %>

""" end - defp get_message_classes(role) do - base_classes = "p-2 my-2 rounded-lg text-sm w-auto max-w-md" + defp render_chat_item(%{item: %Image{}} = assigns) do + ~H""" +
+ <%= if is_nil(@item.file) do %> + loading... + <% else %> + @item.file} /> + <% end %> +
+ """ + end - case role do + defp render_chat_item(%{item: %Choice{}} = assigns) do + ~H""" +
+ <%= for option <- @item.options do %> +
+ +
+ <% end %> +
+ """ + end + + defp get_message_classes(type) do + base_classes = "p-2 rounded-lg text-sm w-auto max-w-md" + + case type do "user" -> "#{base_classes} user-bubble text-white bg-blue-500 self-end" + "bot" -> + "#{base_classes} bot-bubble text-white bg-purple-500" + _ -> - "#{base_classes} bot-bubble text-gray-800 bg-gray-300" + "#{base_classes} text-gray-800 bg-gray-300" end end - - defp bot_options(bots), do: Enum.map(bots, &{&1.name, &1.id}) end diff --git a/lib/chat_bots_web/live/home_live.ex b/lib/chat_bots_web/live/home_live.ex new file mode 100644 index 0000000..ed17ead --- /dev/null +++ b/lib/chat_bots_web/live/home_live.ex @@ -0,0 +1,107 @@ +defmodule ChatBotsWeb.HomeLive do + alias ChatBots.Chats.Bubble + use ChatBotsWeb, :live_view + alias ChatBots.Bots + alias ChatBots.Chats + alias ChatBots.Chats.Choice + alias ChatBots.Chats.Image + alias ChatBots.Chats.ImageRequest + alias ChatBots.Parser + alias Timex.Format.DateTime.Formatters.Relative + + @impl true + def mount(_params, _session, socket) do + chats = Chats.list_chats() + bots = Bots.list_bots() + + {:ok, assign(socket, chats: chats, bots: bots)} + end + + @impl true + def handle_event("new_chat", %{"bot_id" => bot_id}, socket) do + bot = Bots.get_bot(bot_id) + chat = Chats.create_chat(bot) + socket = push_navigate(socket, to: ~p"/chat/#{chat.id}") + + {:noreply, socket} + end + + @impl true + def render(assigns) do + ~H""" +
+
+ Bot Box +
+ +
+ + +
+ +
+ <%= for chat <- @chats do %> + <.link navigate={~p"/chat/#{chat.id}"} id={"chat-#{chat.id}"}> +
+
<.bot_icon />
+
+
<%= chat.bot.name %>
+
+ <%= make_preview(chat) %> +
+
+
+ <%= Relative.format!(chat.latest_message.inserted_at, "{relative}") %> +
+
+ + <% end %> +
+
+ """ + end + + defp bot_icon(assigns) do + ~H""" + + + + """ + end + + defp bot_options(bots), do: Enum.map(bots, &{&1.name, &1.id}) + + defp make_preview(chat) do + chat.latest_message + |> Parser.parse() + |> List.last() + |> item_to_text() + end + + defp item_to_text(%Bubble{type: "system"}), do: "(no messages yet)" + defp item_to_text(%Bubble{text: text}), do: text + defp item_to_text(%Image{prompt: prompt}), do: prompt + defp item_to_text(%ImageRequest{prompt: prompt}), do: prompt + defp item_to_text(%Choice{options: options}), do: Enum.join(options, ", ") + defp item_to_text(item), do: inspect(item) +end diff --git a/lib/chat_bots_web/router.ex b/lib/chat_bots_web/router.ex index 0dfd852..0f4ff3d 100644 --- a/lib/chat_bots_web/router.ex +++ b/lib/chat_bots_web/router.ex @@ -18,7 +18,8 @@ defmodule ChatBotsWeb.Router do scope "/", ChatBotsWeb do pipe_through :browser - live "/", ChatLive, :chat + live "/", HomeLive, :index + live "/chat/:id", ChatLive, :chat end defp auth(conn, _opts) do diff --git a/lib/mix/seed.ex b/lib/mix/seed.ex new file mode 100644 index 0000000..3009cf5 --- /dev/null +++ b/lib/mix/seed.ex @@ -0,0 +1,15 @@ +defmodule Mix.Tasks.Seed do + @moduledoc """ + Mix task to run the seeder + """ + use Mix.Task + + alias ChatBots.Seeder + + @shortdoc "Runs the Seeder to update the bots" + def run(_) do + # Ensure the application is started + Mix.Task.run("app.start") + Seeder.update_bots() + end +end diff --git a/mix.exs b/mix.exs index ad0d86f..1f46c5d 100644 --- a/mix.exs +++ b/mix.exs @@ -49,7 +49,10 @@ defmodule ChatBots.MixProject do {:ecto_sql, "~> 3.6"}, {:ecto_sqlite3, "~> 0.9.1"}, {:phoenix_ecto, "~> 4.4"}, - {:dotenvy, "~> 0.7.0"} + {:dotenvy, "~> 0.7.0"}, + {:req, "~> 0.4.0"}, + {:ex_machina, "~> 2.7.0", only: :test}, + {:timex, "~> 3.0"} ] end diff --git a/mix.lock b/mix.lock index 36542d6..6b9deff 100644 --- a/mix.lock +++ b/mix.lock @@ -2,6 +2,7 @@ "castore": {:hex, :castore, "1.0.1", "240b9edb4e9e94f8f56ab39d8d2d0a57f49e46c56aced8f873892df8ff64ff5a", [:mix], [], "hexpm", "b4951de93c224d44fac71614beabd88b71932d0b1dea80d2f80fb9044e01bbb3"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.7", "77de20ac77f0e53f20ca82c563520af0237c301a1ec3ab3bc598e8a96c7ee5d9", [:mix], [{:elixir_make, "~> 0.7.3", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "2768b28bf3c2b4f788c995576b39b8cb5d47eb788526d93bd52206c1d8bf4b75"}, "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"}, + "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, @@ -15,10 +16,15 @@ "ecto_sqlite3": {:hex, :ecto_sqlite3, "0.9.1", "6cb740afcb0af882cf9dfbac7ff8db863600d3bb24454c840d506e05b1508d6e", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.9", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "cc7664d9210c232370e67e89331056cf0a35e74862cd57e893e7035eacbd4afc"}, "elixir_make": {:hex, :elixir_make, "0.7.6", "67716309dc5d43e16b5abbd00c01b8df6a0c2ab54a8f595468035a50189f9169", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}], "hexpm", "5a0569756b0f7873a77687800c164cca6dfc03a09418e6fcf853d78991f49940"}, "esbuild": {:hex, :esbuild, "0.7.0", "ce3afb13cd2c5fd63e13c0e2d0e0831487a97a7696cfa563707342bb825d122a", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "4ae9f4f237c5ebcb001390b8ada65a12fb2bb04f3fe3d1f1692b7a06fbfe8752"}, + "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, + "expo": {:hex, :expo, "0.5.2", "beba786aab8e3c5431813d7a44b828e7b922bfa431d6bfbada0904535342efe2", [:mix], [], "hexpm", "8c9bfa06ca017c9cb4020fabe980bc7fdb1aaec059fd004c2ab3bff03b1c599c"}, "exqlite": {:hex, :exqlite, "0.13.10", "9cddd9b8764b77232d15d83752832cf96ea1066308547346e1e474a93b026810", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8c688793efbfabd463a2af5b6f4c8ddd35278e402c07e165ee5a52a788aa67ea"}, "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, + "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, "floki": {:hex, :floki, "0.34.2", "5fad07ef153b3b8ec110b6b155ec3780c4b2c4906297d0b4be1a7162d04a7e02", [:mix], [], "hexpm", "26b9d50f0f01796bc6be611ca815c5e0de034d2128e39cc9702eee6b66a4d1c8"}, + "gettext": {:hex, :gettext, "0.24.0", "6f4d90ac5f3111673cbefc4ebee96fe5f37a114861ab8c7b7d5b30a1108ce6d8", [:mix], [{:expo, "~> 0.5.1", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "bdf75cdfcbe9e4622dd18e034b227d77dd17f0f133853a1c73b97b3d6c770e8b"}, "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~> 2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"}, + "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, "httpoison": {:hex, :httpoison, "1.8.2", "9eb9c63ae289296a544842ef816a85d881d4a31f518a0fec089aaa744beae290", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "2bb350d26972e30c96e2ca74a1aaf8293d61d0742ff17f01e0279fef11599921"}, "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, @@ -27,9 +33,13 @@ "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, + "mint": {:hex, :mint, "1.5.2", "4805e059f96028948870d23d7783613b7e6b0e2fb4e98d720383852a760067fd", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "d77d9e9ce4eb35941907f1d3df38d8f750c357865353e21d335bdcdf6d892a02"}, "mix_test_watch": {:hex, :mix_test_watch, "1.1.0", "330bb91c8ed271fe408c42d07e0773340a7938d8a0d281d57a14243eae9dc8c3", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm", "52b6b1c476cbb70fd899ca5394506482f12e5f6b0d6acff9df95c7f1e0812ec3"}, "mock": {:hex, :mock, "0.3.7", "75b3bbf1466d7e486ea2052a73c6e062c6256fb429d6797999ab02fa32f29e03", [:mix], [{:meck, "~> 0.9.2", [hex: :meck, repo: "hexpm", optional: false]}], "hexpm", "4da49a4609e41fd99b7836945c26f373623ea968cfb6282742bcb94440cf7e5c"}, "mox": {:hex, :mox, "1.0.2", "dc2057289ac478b35760ba74165b4b3f402f68803dd5aecd3bfd19c183815d64", [:mix], [], "hexpm", "f9864921b3aaf763c8741b5b8e6f908f44566f1e427b2630e89e9a73b981fef2"}, + "nimble_options": {:hex, :nimble_options, "1.1.0", "3b31a57ede9cb1502071fade751ab0c7b8dbe75a9a4c2b5bbb0943a690b63172", [:mix], [], "hexpm", "8bbbb3941af3ca9acc7835f5655ea062111c9c27bcac53e004460dfd19008a99"}, + "nimble_ownership": {:hex, :nimble_ownership, "0.2.1", "3e44c72ebe8dd213db4e13aff4090aaa331d158e72ce1891d02e0ffb05a1eb2d", [:mix], [], "hexpm", "bf38d2ef4fb990521a4ecf112843063c1f58a5c602484af4c7977324042badee"}, + "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, "openai": {:hex, :openai, "0.3.1", "0736a9816f71c48b54bf9432aac35a9d03f1f8044990cb75dd4e8723ab0a00fe", [:mix], [{:httpoison, "~> 1.8", [hex: :httpoison, repo: "hexpm", optional: false]}, {:json, "~> 1.4", [hex: :json, repo: "hexpm", optional: false]}, {:mix_test_watch, "~> 1.0", [hex: :mix_test_watch, repo: "hexpm", optional: false]}, {:mock, "~> 0.3.6", [hex: :mock, repo: "hexpm", optional: false]}], "hexpm", "d4806a1f2eac2e0b2b35bb2d3cdb0305c4f8f4e4cc187a1695e5654aa698646e"}, "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, "phoenix": {:hex, :phoenix, "1.7.1", "a029bde19d9c3b559e5c3d06c78b76e81396bedd456a6acedb42f9c7b2e535a9", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.4", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "ea9d4a85c3592e37efa07d0dc013254fda445885facaefddcbf646375c116457"}, @@ -44,11 +54,14 @@ "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, + "req": {:hex, :req, "0.4.13", "6fde45b78e606e2a46fc3a7e4a74828c220cd0b16951f4321c1214f955402d72", [:mix], [{:aws_signature, "~> 0.3.2", [hex: :aws_signature, repo: "hexpm", optional: true]}, {:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:nimble_ownership, "~> 0.2.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "e01a596b74272de799bc57191883e5d4d3875be63f0480223edc5a0086bfe31b"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, "tailwind": {:hex, :tailwind, "0.1.10", "21ed80ae1f411f747ee513470578acaaa1d0eb40170005350c5b0b6d07e2d624", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "e0fc474dfa8ed7a4573851ac69c5fd3ca70fbb0a5bada574d1d657ebc6f2f1f1"}, "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "telemetry_metrics": {:hex, :telemetry_metrics, "0.6.1", "315d9163a1d4660aedc3fee73f33f1d355dcc76c5c3ab3d59e76e3edf80eef1f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7be9e0871c41732c233be71e4be11b96e56177bf15dde64a8ac9ce72ac9834c6"}, "telemetry_poller": {:hex, :telemetry_poller, "1.0.0", "db91bb424e07f2bb6e73926fcafbfcbcb295f0193e0a00e825e589a0a47e8453", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b3a24eafd66c3f42da30fc3ca7dda1e9d546c12250a2d60d7b81d264fbec4f6e"}, + "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, + "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, "websock": {:hex, :websock, "0.5.0", "f6bbce90226121d62a0715bca7c986c5e43de0ccc9475d79c55381d1796368cc", [:mix], [], "hexpm", "b51ac706df8a7a48a2c622ee02d09d68be8c40418698ffa909d73ae207eb5fb8"}, "websock_adapter": {:hex, :websock_adapter, "0.5.0", "cea35d8bbf1a6964e32d4b02ceb561dfb769c04f16d60d743885587e7d2ca55b", [:mix], [{:bandit, "~> 0.6", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "16318b124effab8209b1eb7906c636374f623dc9511a8278ad09c083cea5bb83"}, diff --git a/priv/repo/migrations/20240405141621_create_messages.exs b/priv/repo/migrations/20240405141621_create_messages.exs new file mode 100644 index 0000000..d1a6c1b --- /dev/null +++ b/priv/repo/migrations/20240405141621_create_messages.exs @@ -0,0 +1,14 @@ +defmodule ChatBots.Repo.Migrations.CreateMessages do + use Ecto.Migration + + def change do + create table(:messages, primary_key: false) do + add :id, :binary_id, primary_key: true + add :chat_id, references(:chats, on_delete: :delete_all) + add :role, :string + add :content, :string + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20240406141130_create_chats.exs b/priv/repo/migrations/20240406141130_create_chats.exs new file mode 100644 index 0000000..677b5d2 --- /dev/null +++ b/priv/repo/migrations/20240406141130_create_chats.exs @@ -0,0 +1,12 @@ +defmodule ChatBots.Repo.Migrations.CreateChats do + use Ecto.Migration + + def change do + create table(:chats, primary_key: false) do + add :id, :binary_id, primary_key: true + add :bot_id, references(:bots, on_delete: :restrict) + + timestamps() + end + end +end diff --git a/priv/repo/migrations/20240618132522_add_json_mode_to_bots.exs b/priv/repo/migrations/20240618132522_add_json_mode_to_bots.exs new file mode 100644 index 0000000..7c0539a --- /dev/null +++ b/priv/repo/migrations/20240618132522_add_json_mode_to_bots.exs @@ -0,0 +1,10 @@ +defmodule ChatBots.Repo.Migrations.AddJsonModeToBots do + use Ecto.Migration + + def change do + # add json_mode boolean column to bots table + alter table(:bots) do + add :json_mode, :boolean, default: false + end + end +end diff --git a/priv/repo/migrations/20240623134912_add_unique_index_to_bots_name.exs b/priv/repo/migrations/20240623134912_add_unique_index_to_bots_name.exs new file mode 100644 index 0000000..15e7119 --- /dev/null +++ b/priv/repo/migrations/20240623134912_add_unique_index_to_bots_name.exs @@ -0,0 +1,7 @@ +defmodule ChatBots.Repo.Migrations.AddUniqueIndexToBotsName do + use Ecto.Migration + + def change do + create unique_index(:bots, [:name]) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs new file mode 100644 index 0000000..01195ef --- /dev/null +++ b/priv/repo/seeds.exs @@ -0,0 +1,218 @@ +defmodule ChatBots.Seeds do + alias ChatBots.Bots + alias ChatBots.Bots.Bot + alias ChatBots.Repo + + def delete_bots do + IO.puts("Clearing bots...") + Bot |> Repo.delete_all() + end + + def update_bots do + IO.puts("Adding and updating bots...") + + bots = [ + %{ + 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." + }, + %{ + 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." + }, + %{ + 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" + }, + %{ + 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." + }, + %{ + 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?"] + } + """ + } + ] + + for bot <- bots do + Bots.create_bot(bot) + end + end +end + +ChatBots.Seeds.update_bots() diff --git a/test/chat_bots/bots_test.exs b/test/chat_bots/bots_test.exs index b21ef97..57479e3 100644 --- a/test/chat_bots/bots_test.exs +++ b/test/chat_bots/bots_test.exs @@ -1,6 +1,7 @@ defmodule ChatBots.BotsTest do use ChatBots.DataCase alias ChatBots.Bots + alias ChatBots.Repo import ChatBots.Fixtures test "list_bots/0 returns a list of Bots" do @@ -14,4 +15,26 @@ defmodule ChatBots.BotsTest do assert ^bot = Bots.get_bot(bot.id) end + + test "create_bot/1 creates a new Bot" do + assert {:ok, bot} = + Bots.create_bot(%{ + name: "Test Bot", + directive: "You are a helpful assistant.", + json_mode: true + }) + + assert bot.name == "Test Bot" + assert bot.directive == "You are a helpful assistant." + assert bot.json_mode == true + end + + test "create_bot/1 updates the bot if the name is already taken" do + bot_fixture(%{name: "Test Bot", directive: "You are a useless assistant."}) + + assert {:ok, bot} = + Bots.create_bot(%{name: "Test Bot", directive: "You are a helpful assistant."}) + + assert %{directive: "You are a helpful assistant."} = Repo.reload(bot) + end end diff --git a/test/chat_bots/chat_api_test.exs b/test/chat_bots/chat_api_test.exs deleted file mode 100644 index 2042562..0000000 --- a/test/chat_bots/chat_api_test.exs +++ /dev/null @@ -1,59 +0,0 @@ -defmodule ChatBots.ChatApiTest do - use ChatBots.DataCase - - import Mox - import ChatBots.Fixtures - alias ChatBots.OpenAi.MockClient - alias ChatBots.ChatApi - alias ChatBots.Chats - alias ChatBots.Chats.Message - import ChatBots.Fixtures - - # mocks need to be verified when the test exits - setup :verify_on_exit! - - test "send_message/2 adds a response to the chat" do - bot = bot_fixture() - chat = Chats.new_chat(bot.id) - message_text = "What is the meaning of life?" - - # Set up the mock and assert the message is sent to the client as a map - MockClient - |> expect(:chat_completion, fn [model: _, messages: messages] -> - assert [system_prompt, user_prompt] = messages - assert system_prompt == %{role: "system", content: "You are a helpful assistant."} - assert user_prompt == %{role: "user", content: message_text} - api_success_fixture("42") - end) - - {:ok, updated_chat} = ChatApi.send_message(chat, message_text) - - # assert the last message in the updated_chat is "42 - assert %Message{role: "user", content: ^message_text} = updated_chat.messages |> Enum.at(-2) - assert %Message{role: "assistant", content: "42"} = updated_chat.messages |> Enum.at(-1) - end - - test "send_message/2 returns an error tuple if the client returns an error" do - bot = bot_fixture() - chat = Chats.new_chat(bot.id) - message_text = "What is the meaning of life?" - - # Set up the mock and assert the message is sent to the client as a map - MockClient |> expect(:chat_completion, fn _ -> api_error_fixture() end) - - assert {:error, error} = ChatApi.send_message(chat, message_text) - assert error["message"] == "Invalid request" - end - - test "send_message/2 can handle a :timeout error" do - bot = bot_fixture() - chat = Chats.new_chat(bot.id) - message_text = "What is the meaning of life?" - - # Set up the mock and assert the message is sent to the client as a map - MockClient |> expect(:chat_completion, fn _ -> api_timeout_fixture() end) - - assert {:error, error} = ChatApi.send_message(chat, message_text) - assert error["message"] == "Your request timed out" - end -end diff --git a/test/chat_bots/chats_test.exs b/test/chat_bots/chats_test.exs index ad6d42f..6cee621 100644 --- a/test/chat_bots/chats_test.exs +++ b/test/chat_bots/chats_test.exs @@ -1,33 +1,116 @@ defmodule ChatBots.ChatsTest do use ChatBots.DataCase - alias ChatBots.Chats.{Chat, Message} + alias ChatBots.Bots.Bot alias ChatBots.Chats + alias ChatBots.Chats.Chat + alias ChatBots.Chats.Message + import ChatBots.Fixtures + import ChatBots.Factory + + test "create_chat/1 creates a new chat for a bot" do + bot = %{id: bot_id} = insert(:bot) + + assert %Chat{bot_id: ^bot_id} = Chats.create_chat(bot) + end + + test "create_chat/1 creates a chat with the bot's system prompt" do + bot = bot_fixture() + + assert %Chat{messages: messages} = Chats.create_chat(bot) + assert [%Message{role: "system", content: "You are a helpful assistant."}] = messages + end + + test "get_chat!/1 returns the chat for a bot" do + %{id: chat_id} = insert(:chat) - test "new_chat/1 returns a Chat with the specified bot's id and system prompt" do + assert %Chat{id: ^chat_id} = Chats.get_chat!(chat_id) + end + + test "get_chat!/1 preloads messages" do + chat = insert(:chat, messages: [%{role: "system", content: "You are a helpful assistant."}]) + + assert [%Message{role: "system", content: "You are a helpful assistant."}] = + Chats.get_chat!(chat.id).messages + end + + test "get_chat!/1 preloads the bot" do + chat = insert(:chat, messages: [%{role: "system", content: "You are a helpful assistant."}]) + + assert %{bot: %Bot{}} = Chats.get_chat!(chat.id) + end + + test "new_chat/1 returns a list of messages containing the bot's system prompt" do bot = bot_fixture() - assert %Chat{} = chat = Chats.new_chat(bot.id) - assert chat.bot_id == bot.id - assert [%Message{role: "system", content: "You are a helpful assistant."}] = chat.messages + + assert [%Message{role: "system", content: "You are a helpful assistant."}] = + Chats.new_chat(bot.id) end test "add_message/2 adds a message to the chat" do bot = bot_fixture() - chat = Chats.new_chat(bot.id) + messages = Chats.new_chat(bot.id) message = %Message{content: "Hello", role: "user"} - chat = Chats.add_message(chat, message) - assert [_system_prompt, ^message] = chat.messages + assert [_system_prompt, ^message] = Chats.add_message(messages, message) end test "add_message/2 adds a message to the end of the chat" do bot = bot_fixture() - chat = Chats.new_chat(bot.id) + messages = Chats.new_chat(bot.id) message1 = %Message{content: "User message", role: "user"} message2 = %Message{content: "Assistant response", role: "assistant"} - chat = Chats.add_message(chat, message1) - chat = Chats.add_message(chat, message2) - assert [_system_prompt, ^message1, ^message2] = chat.messages + messages = Chats.add_message(messages, message1) + messages = Chats.add_message(messages, message2) + assert [_system_prompt, ^message1, ^message2] = messages + end + + test "create_message/2 creates a new message on the given chat" do + chat = %{id: chat_id} = insert(:chat) + attrs = %{role: "user", content: "Hello"} + + assert {:ok, %Message{chat_id: ^chat_id, role: "user", content: "Hello"}} = + Chats.create_message(chat, attrs) + end + + test "list_chats/0 lists all existing chats" do + %{id: id} = insert(:chat) + + assert [%Chat{id: ^id}] = Chats.list_chats() + end + + test "list_chats/0 sorts chats newest to oldest" do + %{id: chat1_id} = insert(:chat, inserted_at: Timex.now() |> Timex.shift(hours: -2)) + %{id: chat2_id} = insert(:chat, inserted_at: Timex.now() |> Timex.shift(hours: -1)) + %{id: chat3_id} = insert(:chat, inserted_at: Timex.now()) + + assert [%Chat{id: ^chat3_id}, %Chat{id: ^chat2_id}, %Chat{id: ^chat1_id}] = Chats.list_chats() + end + + test "list_chats/0 preloads messages" do + chat = insert(:chat) + %{id: message_id} = insert(:message, chat: chat) + + assert [chat] = Chats.list_chats() + assert [%Message{id: ^message_id}] = chat.messages + end + + test "list_chats/0 preloads the latest_message for each chat" do + chat = insert(:chat) + %{id: latest_message_id} = insert(:message, chat: chat, inserted_at: Timex.now()) + insert(:message, chat: chat, inserted_at: Timex.now() |> Timex.shift(hours: -1)) + insert(:message, chat: chat, inserted_at: Timex.now() |> Timex.shift(hours: -2)) + + assert [chat] = Chats.list_chats() + assert %Message{id: ^latest_message_id} = chat.latest_message + end + + test "list_chats/0 preloads bot" do + bot = insert(:bot, name: "Bob") + chat = insert(:chat, bot: bot) + insert(:message, chat: chat) + + assert [%Chat{bot: ^bot}] = Chats.list_chats() end end diff --git a/test/chat_bots/open_ai/api_test.exs b/test/chat_bots/open_ai/api_test.exs new file mode 100644 index 0000000..247acc9 --- /dev/null +++ b/test/chat_bots/open_ai/api_test.exs @@ -0,0 +1,82 @@ +defmodule ChatBots.OpenAi.ApiTest do + use ChatBots.DataCase + + import Mox + import ChatBots.Fixtures + import ChatBots.Factory + + alias ChatBots.OpenAi.MockClient + alias ChatBots.OpenAi.Api + alias ChatBots.Chats + alias ChatBots.Chats.Message + + # mocks need to be verified when the test exits + setup :verify_on_exit! + + describe "send_message/2" do + test "sends a message and returns an assistant message" do + bot = insert(:bot) + message_text = "What is the meaning of life?" + messages = messages_fixture(message_text) + + # Set up the mock and assert the message is sent to the client as a map + MockClient + |> expect(:chat_completion, fn params -> + messages = params[:messages] + assert [system_prompt, user_prompt] = messages + assert system_prompt == %{role: "system", content: "You are a helpful assistant."} + assert user_prompt == %{role: "user", content: message_text} + api_success_fixture("42") + end) + + {:ok, message} = Api.send_message(bot, messages) + + assert %{"role" => "assistant", "content" => "42"} = message + end + + test "returns an error tuple if the client returns an error" do + bot = insert(:bot) + message_text = "What is the meaning of life?" + messages = messages_fixture(message_text) + + # Set up the mock and assert the message is sent to the client as a map + MockClient |> expect(:chat_completion, fn _ -> api_error_fixture() end) + + assert {:error, error} = Api.send_message(bot, messages) + assert error["message"] == "Invalid request" + end + + test "can handle a :timeout error" do + bot = insert(:bot) + message_text = "What is the meaning of life?" + messages = messages_fixture(message_text) + + # Set up the mock and assert the message is sent to the client as a map + MockClient |> expect(:chat_completion, fn _ -> api_timeout_fixture() end) + + assert {:error, error} = Api.send_message(bot, messages) + assert error["message"] == "Your request timed out" + end + + test "requests json response for bot that uses json_mode" do + bot = insert(:bot, %{json_mode: true}) + messages = messages_fixture("What is the meaning of life?") + + MockClient + |> expect(:chat_completion, fn params -> + assert {:response_format, %{type: "json_object"}} in params + api_success_fixture("{\"text\": \"42\}") + end) + + {:ok, _message} = Api.send_message(bot, messages) + end + end + + defp messages_fixture(message_text) do + bot = bot_fixture() + + Chats.new_chat(bot.id) + |> Chats.add_message(%Message{role: "user", content: message_text}) + |> Enum.map(&Map.take(&1, [:role, :content])) + end +end diff --git a/test/chat_bots/parser_test.exs b/test/chat_bots/parser_test.exs new file mode 100644 index 0000000..bf4d611 --- /dev/null +++ b/test/chat_bots/parser_test.exs @@ -0,0 +1,166 @@ +defmodule ChatBots.ParserTest do + use ChatBots.DataCase, async: true + + alias ChatBots.Chats.Bubble + alias ChatBots.Chats.Choice + alias ChatBots.Chats.Image + alias ChatBots.Chats.ImageRequest + alias ChatBots.Chats.Message + alias ChatBots.Parser + + describe "parse/1" do + test "parses a Bubble from a text response" do + response = %{ + role: "assistant", + content: "Hello, world!" + } + + assert [%Bubble{type: "bot", text: "Hello, world!"}] = Parser.parse(response) + end + + test "can parse a Bubble from a user message" do + response = %{ + role: "user", + content: "Hello, world!" + } + + assert [%Bubble{type: "user", text: "Hello, world!"}] = Parser.parse(response) + end + + test "can parse a Bubble from an info message" do + response = %{ + role: "info", + content: "Bot has entered the chat" + } + + assert [%Bubble{type: "info", text: "Bot has entered the chat"}] = Parser.parse(response) + end + + test "splits mult-line content into multiple Bubbles from a text response" do + response = %{ + role: "assistant", + content: "Hello, world!\nHow are you?" + } + + assert [ + %Bubble{type: "bot", text: "Hello, world!"}, + %Bubble{type: "bot", text: "How are you?"} + ] = Parser.parse(response) + end + + test "can handle splitting on double newlines" do + response = %{ + role: "assistant", + content: "Hello, world!\n\nHow are you?" + } + + assert [ + %Bubble{type: "bot", text: "Hello, world!"}, + %Bubble{type: "bot", text: "How are you?"} + ] = Parser.parse(response) + end + + test "parses a message from a text response containing only a number" do + response = %{ + role: "assistant", + content: "42" + } + + assert [%Bubble{type: "bot", text: "42"}] = Parser.parse(response) + end + + test "parses a message from a JSON response" do + response = make_json_message(%{text: "Hello, world!"}) + + assert [%Bubble{type: "bot", text: "Hello, world!"}] = Parser.parse(response) + end + + test "splits multi-line content into multiple Bubbles from a JSON response" do + response = make_json_message(%{text: "Hello, world!\nHow are you?"}) + + assert [ + %Bubble{type: "bot", text: "Hello, world!"}, + %Bubble{type: "bot", text: "How are you?"} + ] = Parser.parse(response) + end + + test "can parse an image do from a JSON response" do + response = + make_json_message(%{ + role: "image", + content: %{file: "/path/to/image.jpg", prompt: "An image of a cat"} + }) + + assert [%Image{file: "/path/to/image.jpg", prompt: "An image of a cat"}] = + Parser.parse(response) + end + + test "can parse an image request from a JSON response" do + response = + %{ + role: "assistant", + content: Jason.encode!(%{image_prompt: "An image of a cat"}) + } + + assert [%ImageRequest{prompt: "An image of a cat"}] = Parser.parse(response) + end + + test "can parse multiple elements from a single JSON response" do + response = + %{ + role: "assistant", + content: Jason.encode!(%{text: "Here you go", image_prompt: "An image of a cat"}) + } + + assert [ + %Bubble{type: "bot", text: "Here you go"}, + %ImageRequest{prompt: "An image of a cat"} + ] = Parser.parse(response) + end + + test "can parse a choice from a JSON response" do + response = + %{ + role: "assistant", + content: Jason.encode!(%{options: ["Yes", "No"]}) + } + + assert [%Choice{options: ["Yes", "No"]}] = Parser.parse(response) + end + + test "returns text before image prompts and choices" do + response = + %{ + role: "assistant", + content: + Jason.encode!(%{ + image_prompt: "picture of a cat and a dog", + options: ["Cats", "Dogs"], + text: "Do you like cats or dogs?" + }) + } + + assert [ + %Bubble{type: "bot", text: "Do you like cats or dogs?"}, + %Choice{options: ["Cats", "Dogs"]}, + %ImageRequest{prompt: "picture of a cat and a dog"} + ] = Parser.parse(response) + end + end + + defp make_json_message(%{role: role, content: content}) do + %Message{ + role: role, + content: Jason.encode!(content) + } + end + + defp make_json_message(response_json) do + json = Jason.encode!(response_json) + + %Message{ + role: "assistant", + content: json + } + end +end diff --git a/test/chat_bots/stability_ai/api_test.exs b/test/chat_bots/stability_ai/api_test.exs new file mode 100644 index 0000000..ec4410d --- /dev/null +++ b/test/chat_bots/stability_ai/api_test.exs @@ -0,0 +1,49 @@ +defmodule ChatBots.StabilityAi.ApiTest do + use ChatBotsWeb.ConnCase, async: true + + import ChatBots.Fixtures.StabilityAiFixtures + import Mox + + alias ChatBots.StabilityAi.Api + alias ChatBots.StabilityAi.MockClient + + setup [:verify_on_exit!, :setup_environment] + + describe "generate_image/1" do + test "sends a post request with the expected params to the API" do + MockClient + |> expect(:post, fn url, options -> + assert url == + "https://api.stability.ai/v1/generation/stable-diffusion-xl-1024-v1-0/text-to-image" + + assert {"Authorization", "Bearer StaB1l1tyA1"} in options[:headers] + assert {"Content-Type", "application/json"} in options[:headers] + assert {"Accept", "application/json"} in options[:headers] + + %{text_prompts: text_prompts} = Keyword.get(options, :json) + assert %{text: "a cute fluffy cat", weight: 1} in text_prompts + + {:ok, + %Req.Response{ + body: %{ + "artifacts" => [ + %{ + "base64" => "Zm9vYmFy", + "seed" => 12345 + } + ] + } + }} + end) + + expected_file_name = expected_file_name(12345) + + assert {:ok, ^expected_file_name} = + Api.generate_image("a cute fluffy cat", %{style_preset: "awesome"}) + end + end + + defp setup_environment(_) do + Application.put_env(:chat_bots, :stability_ai_api_key, "StaB1l1tyA1") + end +end diff --git a/test/chat_bots_web/live/chat_live_test.exs b/test/chat_bots_web/live/chat_live_test.exs index c1361a4..6dbb225 100644 --- a/test/chat_bots_web/live/chat_live_test.exs +++ b/test/chat_bots_web/live/chat_live_test.exs @@ -1,60 +1,62 @@ defmodule ChatBotsWeb.ChatLiveTest do use ChatBotsWeb.ConnCase, async: false import Mox + import ChatBots.Factory import ChatBots.Fixtures + import ChatBots.Fixtures.StabilityAiFixtures import Phoenix.LiveViewTest - alias ChatBots.OpenAi.MockClient + + alias ChatBots.OpenAi.MockClient, as: OpenAiMock + alias ChatBots.Repo + alias ChatBots.StabilityAi.MockClient, as: StabilityAiMock setup :verify_on_exit! setup :login_user test "returns 401 when not logged in", %{conn: conn} do + chat = insert(:chat) + conn = conn |> delete_req_header("authorization") - |> get("/") + |> get("/chat/#{chat.id}") assert response(conn, 401) end - test "renders the page", %{conn: conn} do - _bot = bot_fixture() - {:ok, _view, html} = live(conn, "/") - assert html =~ "Bot Box" - end + test "loads a chat from the database", %{conn: conn} do + chat = insert(:chat, messages: [%{role: "info", content: "Welcome to Bot Box"}]) - test "has a select to choose the bot", %{conn: conn} do - bot = bot_fixture() - {:ok, view, _html} = live(conn, "/") - - assert has_element?(view, "#bot-select") - assert has_element?(view, "#bot-select option", bot.name) + {:ok, _view, html} = live(conn, "/chat/#{chat.id}") + assert html =~ "Welcome to Bot Box" end test "can enter a message and see it appear in the chat", %{conn: conn} do - _bot = bot_fixture() - {:ok, view, _html} = live(conn, "/") + chat = insert(:chat) + {:ok, view, _html} = live(conn, "/chat/#{chat.id}") message_text = "Hello!" refute has_element?(view, "#chat-box p", message_text) - expect_api_success(message_text) + expect_chat_api_call(message_text) view |> form("#chat-form", %{"message" => message_text}) |> render_submit() assert has_element?(view, "#chat-box p.user-bubble", ~r/Hello!/) + chat = Repo.reload(chat) |> Repo.preload(:messages) + assert chat.messages |> Enum.any?(&(&1.role == "user" && &1.content == "Hello!")) end test "can receive and view a response from the bot", %{conn: conn} do - bot_fixture() - {:ok, view, _html} = live(conn, "/") + chat = insert(:chat) + {:ok, view, _html} = live(conn, "/chat/#{chat.id}") message_text = "I am a user" - expect_api_success(message_text, "I am a bot") + expect_chat_api_call(message_text, "I am a bot") view |> form("#chat-form", %{"message" => message_text}) @@ -64,19 +66,33 @@ defmodule ChatBotsWeb.ChatLiveTest do end test "doesn't display system prompt", %{conn: conn} do - _bot = bot_fixture(%{name: "Test Bot", directive: "You are a helpful assistant"}) - {:ok, _view, html} = live(conn, "/") + chat = insert(:chat, messages: [%{role: "system", content: "You are a helpful assistant"}]) + {:ok, _view, html} = live(conn, "/chat/#{chat.id}") refute html =~ "You are a helpful assistant" end + test "doesn't display ImageRequests", %{conn: conn} do + chat = insert(:chat) + + insert(:message, + chat: chat, + role: "assistant", + content: Jason.encode!(%{image_prompt: "A picture of a cat"}) + ) + + {:ok, _view, html} = live(conn, "/chat/#{chat.id}") + + refute html =~ "A picture of a cat" + end + test "displays a user-friendly role title for each message", %{conn: conn} do - bot_fixture() - {:ok, view, _html} = live(conn, "/") + chat = insert(:chat) + {:ok, view, _html} = live(conn, "/chat/#{chat.id}") message_text = "I am a user" - expect_api_success(message_text, "I am a bot") + expect_chat_api_call(message_text, "I am a bot") view |> form("#chat-form", %{"message" => message_text}) @@ -86,102 +102,218 @@ defmodule ChatBotsWeb.ChatLiveTest do assert has_element?(view, "#chat-box p.bot-bubble", ~r/I am a bot/) end - test "displays welcome message", %{conn: conn} do - bot = bot_fixture() - {:ok, view, _html} = live(conn, "/") + test "does not send 'info' messages to the API", %{conn: conn} do + chat = insert(:chat) + {:ok, view, _html} = live(conn, "/chat/#{chat.id}") + + OpenAiMock + |> expect(:chat_completion, fn params -> + messages = params[:messages] + assert not Enum.any?(messages, &(&1.role == "info")) + assert %{role: "user", content: "Hello bot"} in messages + api_success_fixture("Hello human") + end) - assert has_element?(view, "#chat-box p", ~r/#{bot.name} has entered the chat/) + view + |> form("#chat-form", %{"message" => "Hello bot"}) + |> render_submit() + + :timer.sleep(100) end - test "selecting a different bot clears the chat", %{conn: conn} do - _bot1 = bot_fixture(name: "Bot 1", directive: "some directive 1") - bot2 = bot_fixture(name: "Bot 2", directive: "some directive 2") - {:ok, view, _html} = live(conn, "/") + test "displays error message returned by the API in the chat area", %{conn: conn} do + chat = insert(:chat) + {:ok, view, _html} = live(conn, "/chat/#{chat.id}") - message_text = "I am a user" + OpenAiMock + |> expect(:chat_completion, fn _ -> api_error_fixture() end) - expect_api_success(message_text, "I am a bot") + view + |> form("#chat-form", %{"message" => "Hello"}) + |> render_submit() + + assert has_element?(view, "#chat-box p", ~r/Error.*Invalid request/) + chat = Repo.preload(chat, :messages) + assert chat.messages |> Enum.any?(&(&1.role == "error")) + end + + test "breaks up mult-line responses into multiple chat bubbles", %{conn: conn} do + chat = insert(:chat) + {:ok, view, _html} = live(conn, "/chat/#{chat.id}") + + message_text = "Hello" + expect_chat_api_call(message_text, "first line\n\nsecond line") view |> form("#chat-form", %{"message" => message_text}) |> render_submit() - assert has_element?(view, "#chat-box p", ~r/Bot 1 has entered the chat/) - assert has_element?(view, "#chat-box p.user-bubble", ~r/I am a user/) - assert has_element?(view, "#chat-box p.bot-bubble", ~r/I am a bot/) + assert has_element?(view, "p.bot-bubble", ~r"\Afirst line\z") + assert has_element?(view, "p.bot-bubble", ~r"\Asecond line\z") + end + + # TODO: figure out how to test this + @tag :skip + test "displays loading animation while retrieving an image", %{conn: conn} do + chat = insert(:chat) + {:ok, view, _html} = live(conn, "/chat/#{chat.id}") + + message_text = "Make a picture of a cat" + + expect_chat_api_call(message_text, %{ + text: "here is your picture", + image_prompt: "A picture of a cat" + }) + + expect_image_api_call("A picture of a cat") view - |> form("#bot-select-form", %{"bot_id" => bot2.id}) - |> render_change() + |> form("#chat-form", %{"message" => message_text}) + |> render_submit() + + :timer.sleep(10) - refute has_element?(view, "#chat-box p", ~r/Bot 1 has entered the chat/) - refute has_element?(view, "#chat-box p", ~r/I am a user/) - refute has_element?(view, "#chat-box p", ~r/I am a bot/) - assert has_element?(view, "#chat-box p", ~r/Bot 2 has entered the chat/) + assert has_element?(view, "div.loader") end - test "retains selected bot", %{conn: conn} do - _bot1 = bot_fixture(name: "Bot 1", directive: "some directive 1") - bot2 = bot_fixture(name: "Bot 2", directive: "some directive 2") - {:ok, view, _html} = live(conn, "/") + test "stops loading animation after image response is received", %{conn: conn} do + chat = insert(:chat) + {:ok, view, _html} = live(conn, "/chat/#{chat.id}") + + message_text = "Make a picture of a cat" + + expect_chat_api_call(message_text, %{ + text: "here is your picture", + image_prompt: "A picture of a cat" + }) + + expect_image_api_call("A picture of a cat") view - |> form("#bot-select-form", %{"bot_id" => bot2.id}) - |> render_change() + |> form("#chat-form", %{"message" => message_text}) + |> render_submit() + + _ = :sys.get_state(view.pid) + + refute has_element?(view, "div.loader") + end + + test "displays an Image after the new Bubble", %{conn: conn} do + chat = insert(:chat) + {:ok, view, _html} = live(conn, "/chat/#{chat.id}") + + message_text = "Make a picture of a cat" - assert has_element?(view, "#bot-select option[selected]", bot2.name) + expect_chat_api_call(message_text, %{ + text: "here is your picture", + image_prompt: "A picture of a cat" + }) - expect_api_success("Hello") + expect_image_api_call("A picture of a cat") view - |> form("#chat-form", %{"message" => "Hello"}) + |> form("#chat-form", %{"message" => message_text}) |> render_submit() - assert has_element?(view, "#bot-select option[selected]", bot2.name) + :timer.sleep(10) + + assert render(view) =~ ~r"here is your picture.*chat-image" end - test "displays error message returned by the API in the chat area", %{conn: conn} do - _bot = bot_fixture() + test "displays a Choice as a list of buttons", %{conn: conn} do + chat = insert(:chat) + {:ok, view, _html} = live(conn, "/chat/#{chat.id}") + + message_text = "What is your favorite color?" - {:ok, view, _html} = live(conn, "/") + chat_response = %{ + text: "Choose a color", + options: ["Red", "Green", "Blue"] + } - expect_api_failure() + expect_chat_api_call(message_text, chat_response) view - |> form("#chat-form", %{"message" => "Hello"}) + |> form("#chat-form", %{"message" => message_text}) |> render_submit() - assert has_element?(view, "#chat-box p", ~r/Error.*Invalid request/) + assert has_element?(view, "button", ~r/Red/) + assert has_element?(view, "button", ~r/Green/) + assert has_element?(view, "button", ~r/Blue/) end - test "breaks up mult-line responses into multiple chat bubbles", %{conn: conn} do - bot_fixture() - {:ok, view, _html} = live(conn, "/") + test "clicking a Choice button sends the option text to the API", %{conn: conn} do + chat = insert(:chat) - message_text = "Hello" - expect_api_success(message_text, "first line\n\nsecond line") + insert(:message, + chat: chat, + role: "assistant", + content: + Jason.encode!(%{ + text: "Choose a color", + options: ["Red", "Green", "Blue"] + }) + ) + + {:ok, view, _html} = live(conn, "/chat/#{chat.id}") + + # "Red" gets sent to the API + expect_chat_api_call("Red", "You chose Red") + + view + |> element("button", "Red") + |> render_click() + + # "Red" is displayed as a user message + assert has_element?(view, "p.user-bubble", ~r/Red/) + end + + test "sends image prompts to the StabilityAI API and displays the image returned", %{conn: conn} do + chat = insert(:chat) + {:ok, view, _html} = live(conn, "/chat/#{chat.id}") + + message_text = "Make a picture of a cat" + + chat_response = %{ + text: "here is your picture", + image_prompt: "A picture of a cat" + } + + expect_chat_api_call(message_text, chat_response) + expect_image_api_call("A picture of a cat") view |> form("#chat-form", %{"message" => message_text}) |> render_submit() - assert has_element?(view, "p.bot-bubble", ~r"\Afirst line\z") - assert has_element?(view, "p.bot-bubble", ~r"\Asecond line\z") + # TODO set up expectation blocking instead of sleeping + :timer.sleep(100) + + file_name = expected_file_name(12345) + + assert has_element?(view, "img[src='/images/generated/#{file_name}']") + + chat = Repo.preload(chat, :messages) + assert chat.messages |> Enum.any?(&(&1.role == "image")) end # Set up the mock and assert the message is sent to the client with message_text - defp expect_api_success(message_sent, message_received \\ "42") do - MockClient - |> expect(:chat_completion, fn [model: _, messages: messages] -> - assert [_, user_message] = messages - assert user_message == %{role: "user", content: message_sent} + defp expect_chat_api_call(message_sent, message_received \\ "42") do + OpenAiMock + |> expect(:chat_completion, fn params -> + messages = params[:messages] + assert %{role: "user", content: ^message_sent} = List.last(messages) api_success_fixture(message_received) end) end - # Set up the mock to return an error response - defp expect_api_failure() do - MockClient - |> expect(:chat_completion, fn _ -> api_error_fixture() end) + defp expect_image_api_call(image_prompt) do + StabilityAiMock + |> expect(:post, fn _url, options -> + %{text_prompts: text_prompts} = Keyword.get(options, :json) + assert %{text: image_prompt, weight: 1} in text_prompts + + {:ok, %Req.Response{body: %{"artifacts" => [%{"base64" => "Zm9vYmFy", "seed" => 12345}]}}} + end) end end diff --git a/test/chat_bots_web/live/home_live_test.exs b/test/chat_bots_web/live/home_live_test.exs new file mode 100644 index 0000000..c2256ad --- /dev/null +++ b/test/chat_bots_web/live/home_live_test.exs @@ -0,0 +1,171 @@ +defmodule ChatBotsWeb.HomeLiveTest do + use ChatBotsWeb.ConnCase, async: false + import Phoenix.LiveViewTest + import ChatBots.Factory + alias ChatBots.Chats.Chat + alias ChatBots.Repo + + setup :login_user + + test "lists existing chats", %{conn: conn} do + chat = insert(:chat) + insert(:message, chat: chat) + {:ok, view, _html} = live(conn, "/") + + assert has_element?(view, "#chat-#{chat.id}") + end + + test "shows the bot's name for each chat", %{conn: conn} do + bot = insert(:bot, name: "Bob") + chat = insert(:chat, bot: bot) + insert(:message, chat: chat) + {:ok, view, _html} = live(conn, "/") + + assert has_element?(view, "#chat-#{chat.id}", "Bob") + end + + test "shows the relative time of the last message on each chat", %{conn: conn} do + chat = insert(:chat) + insert(:message, chat: chat, inserted_at: Timex.now() |> Timex.shift(minutes: -5)) + + {:ok, view, _html} = live(conn, "/") + + assert has_element?( + view, + "#chat-#{chat.id} div[data-role=time]", + "5 minutes ago" + ) + end + + test "shows an excerpt of the last message on each chat", %{conn: conn} do + chat = insert(:chat) + insert(:message, chat: chat, content: "Some witty message", role: "assistant") + + {:ok, view, _html} = live(conn, "/") + + assert has_element?( + view, + "#chat-#{chat.id} div[data-role=content]", + "Some witty message" + ) + end + + test "redirects to ChatLive when a chat is clicked", %{conn: conn} do + chat = insert(:chat) + insert(:message, chat: chat, content: "Hello") + + {:ok, view, _html} = live(conn, "/") + + view + |> element("#chat-#{chat.id}") + |> render_click() + |> follow_redirect(conn, ~p"/chat/#{chat.id}") + end + + test "includes a drop-down list of bots to start a chat with", %{conn: conn} do + insert(:bot, name: "BobBot") + {:ok, view, _html} = live(conn, "/") + + assert has_element?(view, "#bot-select") + assert has_element?(view, "#bot-select option", "BobBot") + assert has_element?(view, "#bot-select-form button", "New Chat") + end + + test "can create a new chat and redirect to it", %{conn: conn} do + _bot1 = insert(:bot, name: "Bot 1") + bot2 = %{id: bot2_id} = insert(:bot, name: "Bot 2") + {:ok, view, _html} = live(conn, "/") + + redirect = + view + |> form("#bot-select-form", %{"bot_id" => bot2.id}) + |> render_submit() + + assert {:error, {:live_redirect, %{to: redirect_url}}} = redirect + assert redirect_url =~ "/chat/" + chat_id = redirect_url |> String.split("/") |> List.last() + assert %{bot_id: ^bot2_id} = Repo.get!(Chat, chat_id) + end + + test "displays the image description when an image was the latest message", %{conn: conn} do + chat = insert(:chat) + + insert(:message, + chat: chat, + role: "image", + content: Jason.encode!(%{file: "filename.jpg", prompt: "A picture of a cat"}) + ) + + {:ok, _view, html} = live(conn, "/") + + assert element_text(html, "#chat-#{chat.id} div[data-role=content]") == "A picture of a cat" + end + + test "displays the image prompt when an image request was the latest message", %{conn: conn} do + chat = insert(:chat) + + insert(:message, + chat: chat, + role: "assistant", + content: Jason.encode!(%{image_prompt: "A picture of a cat"}) + ) + + {:ok, _view, html} = live(conn, "/") + + assert element_text(html, "#chat-#{chat.id} div[data-role=content]") == + "A picture of a cat" + end + + test "displays options when a Choice was the latest message", %{conn: conn} do + chat = insert(:chat) + + insert(:message, + chat: chat, + role: "assistant", + content: Jason.encode!(%{options: ["Option 1", "Option 2"]}) + ) + + {:ok, _view, html} = live(conn, "/") + + assert element_text(html, "#chat-#{chat.id} div[data-role=content]") == + "Option 1, Option 2" + end + + test "displays message text when a normal message was the last message", %{conn: conn} do + chat = insert(:chat) + + insert(:message, + chat: chat, + role: "assistant", + content: "Hello there!" + ) + + {:ok, _view, html} = live(conn, "/") + + assert element_text(html, "#chat-#{chat.id} div[data-role=content]") == + "Hello there!" + end + + test "displays '(no messages yet)' if the sytem prompt is the only message", %{conn: conn} do + chat = insert(:chat) + + insert(:message, + chat: chat, + role: "system", + content: "You are a helpful bot" + ) + + {:ok, _view, html} = live(conn, "/") + + assert element_text(html, "#chat-#{chat.id} div[data-role=content]") == + "(no messages yet)" + end + + defp element_text(html, dom_id) do + html + |> Floki.parse_document!() + |> Floki.find(dom_id) + |> Floki.text() + |> String.trim() + end +end diff --git a/test/support/factory.ex b/test/support/factory.ex new file mode 100644 index 0000000..4286a80 --- /dev/null +++ b/test/support/factory.ex @@ -0,0 +1,28 @@ +defmodule ChatBots.Factory do + use ExMachina.Ecto, repo: ChatBots.Repo + + alias ChatBots.Bots.Bot + alias ChatBots.Chats.Chat + alias ChatBots.Chats.Message + + def bot_factory do + %Bot{ + name: sequence(:bot_name, &"Test Bot #{&1}"), + directive: "You are a helpful assistant." + } + end + + def chat_factory do + %Chat{ + bot: build(:bot) + } + end + + def message_factory do + %Message{ + chat: build(:chat), + role: "system", + content: "You are a helpful assistant." + } + end +end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex index 6996ce1..a56dc6d 100644 --- a/test/support/fixtures.ex +++ b/test/support/fixtures.ex @@ -2,6 +2,11 @@ defmodule ChatBots.Fixtures do alias ChatBots.Repo alias ChatBots.Bots.Bot + def api_success_fixture(response_map) when is_map(response_map) do + json = Jason.encode!(response_map) + api_success_fixture(json) + end + def api_success_fixture(response_text) do {:ok, %{ @@ -23,6 +28,61 @@ defmodule ChatBots.Fixtures do }} end + def api_json_response() do + {:ok, + %{ + id: "chatcmpl-8xykPfT6zxeijbLiidVydQZ0y7spX", + usage: %{ + "completion_tokens" => 30, + "prompt_tokens" => 56, + "total_tokens" => 86 + }, + model: "gpt-3.5-turbo-0125", + choices: [ + %{ + "finish_reason" => "stop", + "index" => 0, + "logprobs" => nil, + "message" => %{ + "content" => + "{\n \"response\": \"The average distance between the Earth and the Sun is approximately 93 million miles, or about 150 million kilometers.\"\n}", + "role" => "assistant" + } + } + ], + object: "chat.completion", + created: 1_709_305_557, + system_fingerprint: "fp_2b778c6b35" + }} + end + + def api_text_response do + {:ok, + %{ + id: "chatcmpl-8yJ8HRzy3tOhCcIS3XiPG3J8j8RSF", + usage: %{ + "completion_tokens" => 19, + "prompt_tokens" => 173, + "total_tokens" => 192 + }, + model: "gpt-3.5-turbo-0125", + choices: [ + %{ + "finish_reason" => "stop", + "index" => 0, + "logprobs" => nil, + "message" => %{ + "content" => "Oh, just a casual 93 million miles. You know, a quick road trip away.", + "role" => "assistant" + } + } + ], + created: 1_709_383_917, + object: "chat.completion", + system_fingerprint: "fp_2b778c6b35" + }} + end + def api_error_fixture do {:error, %{ diff --git a/test/support/fixtures/stability_ai_fixtures.ex b/test/support/fixtures/stability_ai_fixtures.ex new file mode 100644 index 0000000..1105076 --- /dev/null +++ b/test/support/fixtures/stability_ai_fixtures.ex @@ -0,0 +1,62 @@ +defmodule ChatBots.Fixtures.StabilityAiFixtures do + use ChatBots.DataCase, only: [assert: 2] + import Mox + alias ChatBots.StabilityAi.MockClient + + @seed 123_456_789 + + def text_to_image_response do + {:ok, + %Req.Response{ + status: 200, + headers: %{ + "alt-svc" => ["h3=\":443\"; ma=86400"], + "cf-cache-status" => ["DYNAMIC"], + "cf-ray" => ["835ac104dd2382b6-IAD"], + "connection" => ["keep-alive"], + "content-type" => ["application/json"], + "date" => ["Fri, 15 Dec 2023 01:01:19 GMT"], + "server" => ["cloudflare"], + "set-cookie" => [ + "__cf_bm=Sfa_zHOhmcUMezrGYBwpzlw9pCjjjDU8c7Gg2oEYtAo-1702602079-1-AfXGGd4AYd6fxGG6V51lBV0nAjAVNrMobxBi8LsT39X4jX798MlXiMu7+/cAld85V1XwerdifqIIKYFDwvN94g4=; path=/; expires=Fri, 15-Dec-23 01:31:19 GMT; domain=.stability.ai; HttpOnly; Secure; SameSite=None" + ], + "transfer-encoding" => ["chunked"], + "vary" => ["Origin"], + "x-envoy-upstream-service-time" => ["7385"] + }, + body: %{ + "artifacts" => [ + %{ + "base64" => + "iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAIAAADwf7zUAAOKcWNhQlgAA4pxanVtYgAAAB5qdW1kYzJwYQARABCAAACqADibcQNjMnBhAAADiktqdW1iAAAAR2p1bWRjMm1hABEAEIAAAKoAOJtxA3Vybjp1dWlkOjA1MGViZDYxLWE2ZWItNDNmNi05MzQ5LTkzYjI0M2Y4MDgxNwAAA0ujanVtYgAAAClqdW1kYzJhcwARABCAAACqADibcQNjMnBhLmFzc2VydGlvbnMAAANJPGp1bWIAAAAzanVtZEDLDDK7ikidpwsq1vR/Q2kDYzJwYS50aHVtYm5haWwuY2xhaW0uanBlZwAAAAAUYmZkYgBpbWFnZS9qcGVnAAADSO1iaWRi/9j/4AAQSkZJRgABAgAAAQABAAD/wAARCAQABAADAREAAhEBAxEB/9sAQwAGBAUGBQQGBgUGBwcGCAoQCgoJCQoUDg8MEBcUGBgXFBYWGh0lHxobIxwWFiAsICMmJykqKRkfLTAtKDAlKCko/9sAQwEHBwcKCAoTCgoTKBoWGigoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgo/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwCpmtznEzSEJmm1YYZosAZFIABoAXigAyaADmi4BRcAouAUAJmmAmaAFpALQAUwFpAFABmi9gDIoAM0AGaADIoATNABkUXADRcAyKLgJTAChatBotsBmnsAmTQAoJoYC5NIABNDAXNCVwEoAChatBotsBTAOKLgGaLgFABxQAhNO1gEoAUUMBe1K4BmgAouAUAFCVwDNDAM0AFACGi4BRcBc0bgGaLgGaEAUAJTuAZoAWkAmaAAGmAuaQCcUAHFFwF4oAKADigAyKACkAhp2AM0wF5pAHNO4BzSAM0MBKAF/Gi4CUAGeKYCg8UNWAM0gDIoAOKAF4oasAcUXAKADigBOKLgITTQCZoYDgaTAM0AGaLgLmgBM0AGaLgGaQBQAhpoAzTasAUmrAGaLgLQwCgAouAUXAKAEoAKYBmkAc0AKDTAXJ9aQBzQAZNIAyaYCZoAXNF7agITQnYBpNO4BmgAzxSAXJpgKDRcBKACkAhpgJQAUALSADQAUwCi4DqQDTQAU7gFACUAKKGA6kAUWsAUAJTQBSYBQAUAKKGAUJ2AKLgFDVgChOwCdqAEp3AKAEpAFACimAtIAoAM07AFIA4oAKACgAoAMU7gJQAUXAKAFpAJQAUAFMApAFNAFDAChatBotsC0AFAB9ad7bAJSYC0AFABQAlOwBQAUMBRSAMUXsAEUAFACUwDFIAoAMD0p3AMUAKKLgHai9gDFFwFpAFC8wEFDAWgBKAD8KACgBaAEp2sAlIBaACgBRQAUAFCAKGAUJ2AaaYBSAKYC0mACi4BSAKdwChAFACGgAoASmAtDAChatBotsBQA6hqwBQAUJXASncAFIBTQnYBKACmtACi4C0gCgANCAShqwBTQBxRYAosAlIBKYBQAUgHCgBKdwFNIBtMAoAKACgAoAdSASgApgLSuAUwEoAChatBotsC9qFqAUwFpXASgAp3AWklcAoASgBaAEzTYBmhqwB3pABNACCmwCi4BQAlFwFpAFPYBRSAKadgE6UXAP1pAFABTYBQAUAGaGAdaFoAhIBpALTAXNFrgIaQBz3oAKdwCkAUAFOwBSAKACgBaAA0ANpgLii4BQwAmgApAFMAzQwDJodgFpAH402AhoAM0gCgA5oAKYDqTVgCgApAJVALSuAlACE00AZpAGabAM0gHUMBKYB2pAIaAEpgOBoaAWklcAosAmfeiwBQAlNaABouAUgFoABRcBTSAQUALTQDaAAUAGKACgAzQAlMBaAFFJqwATQlcBM0AApsB1JqwBQnYBKAFoYBQnbUBKAAUALQAlMAoAQ0gCgApgHagAoAKAAUgFp3ADSASmAlADu1ACUAJQAooAKACi4CikwA0AJT2AUUXAChatBotsCZoAWmwEpAFMAFAAetIBRQAtACYoAQ0AFO4CUAKKLgLRewCe1IAp3AD1oAMUAOpAIaYCUAFIBKYCgEkAdaQCkc4HSncBKAAUAL3pAZF1qC/wBq21qvLSOU/ADcx/kPzquhfLoa1K5AH0oWmoAMZGelAD5WDyOyggE8A0gGCmwFNIBM00AUXAChatBotsBQAlMBaACgAouAUXAChatBotsAadwCi4BSAMUAFMAxSABQAUwA0gD8KACgBaADvQAE00AmaGA6kwEzTsAlIANMBKLgOxQnYBChatBotsCj0psBaQCUAJQAUALTuAUABpAJQAU7gFFwA0XASgBaQC0ABoAKADNFgEoAKACmACgAoAKACgApAFNaAJQAtIBaAAUNWAWgBtAC02AhpAFABmgBaACgBKYBigB7ptbG9W46qeKGITHHUUAJQAlABRcY6hOwCUgExTACKACkAUwFoADQIKAEzSGFAADTAM0AFDAKLgPjid0lZVysa7mOegzikAyncApAFMQUDCkAooAWhAFCAKGAhpp2ASi4BSAUUMBaadgOx8BaxoOl298NatRLNJ9xmi8zcuPuD0Oe9Q0xpnITsjTSNEuyMsSq9dozwKoRHTuAtIAoATFO4BikAUAFACUAFAC0wK+o3S2dlLO38C5A9T2H50X0KiuZnDtK0XiuYvJ81nEUVuuZCAP/Q3P4Clc6GrRPQD94gcgHin5nKFIBKYC0AWpXtpLOHZGY7mPKuRyJR2b2I6H1496L2Aq0gDvTuAo6UMBMUgEoAWmAUALmgQlIYtG4CUwCgAouAYJouAUgCmAUgCmAlAC0gD8aYAKAFoAKBBRcBOKBhSAKYCUALSASgB2adrABpAJQAooYBQAlAAaADmgBaAAjjOOM4p3ASi4AKLgAFFwA0gHIAT8xwPXGaaAaenT8aGAUAFIApgFABSAKAEoAcgBZd3C55x1xTuBv8AjC40ee5thoSyCGKFUdnQLuI74xyfUnrSSe42c9QIKYBQAtDASgBwobAWpADTSuAhosAhpp2AWi4CGkAUwEoAUUXAO9ACigA7UCAikMDTAQ0CCgYCkA401oAlABQAUCEpDCmAUgHIrO6qgLMTgAdSaLgSXVvNaXDwXUTxTJ95HGCPwoCxDTuAUXAMUXAMUXAChatBotsB3pgLQwEFAC0gOi1Hwdq9hoceqTwr5DAFlU5eNT0LCjmTeg+U5yncQUgFoAKADtTQC0rAFACUAGKLgFFwChK4CZpgFIBTQtAEoAUU7gFAC0gGnrQAlMBaACgAoAwtU33+t6bYIMxPcojD1I+Y/kNv51nOeljqoU+pzEun3mop4lvbVWK20bXMxUchDMq/h6/QVLmka2vudxpN0L3TbW5XnzIwT9eh/XNbXucMlysuUCEoAKAChgLSAKAFpgFIBDQAlMBaACgBaACgQYoGIetAB1pAFMAoWgCUAFABRcBaACkAo6GgAFMQZoAVVZ2VVBLMQAB1JPahjL+s6PfaLPHDqUHkySJvUZByPwpbhYzqAA0wEoAKACgAoAKAFpAAp3AWi4AChatBotsAaAENNOwC", + "finishReason" => "SUCCESS", + "seed" => @seed + } + ] + }, + trailers: %{}, + private: %{} + }} + end + + @doc """ + Set up mock client to expect a text to image request with the given prompt + """ + def expect_text_to_image_request(prompt) do + MockClient + |> expect(:post, fn _url, options -> + json = Keyword.get(options, :json) + assert %{text_prompts: prompts} = json + assert Enum.any?(prompts, &(&1[:text] == prompt)) + text_to_image_response() + end) + end + + @doc """ + Returns the expected image file name for today with the given seed + """ + def expected_file_name(seed \\ @seed) do + date = Date.utc_today() |> Date.to_iso8601() |> String.replace("-", "") + "img-#{date}-#{seed}.png" + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index c6a49e7..cad1b18 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,5 +1,10 @@ ExUnit.start() Ecto.Adapters.SQL.Sandbox.mode(ChatBots.Repo, :manual) +{:ok, _} = Application.ensure_all_started(:ex_machina) + Mox.defmock(ChatBots.OpenAi.MockClient, for: ChatBots.OpenAi.Client) Application.put_env(:chat_bots, :open_ai_client, ChatBots.OpenAi.MockClient) + +Mox.defmock(ChatBots.StabilityAi.MockClient, for: ChatBots.StabilityAi.Client) +Application.put_env(:chat_bots, :stability_ai_client, ChatBots.StabilityAi.MockClient)