diff --git a/.gitignore b/.gitignore index 3cc6f85..2496090 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,7 @@ elixir_ai-*.tar npm-debug.log /assets/node_modules/ -elixir_ls/ \ No newline at end of file +elixir_ls/ +.env + +*.tmp \ No newline at end of file diff --git a/README.md b/README.md index 55f809b..61f0425 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,16 @@ -# ElixirAi -To start your Phoenix server: - * Run `mix setup` to install and setup dependencies - * Start Phoenix endpoint with `mix phx.server` or inside IEx with `iex -S mix phx.server` +things to try: +- slow chat + - with tools +- streaming chat + - with tools -Now you can visit [`localhost:4000`](http://localhost:4000) from your browser. -Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html). -## Learn more - - * Official website: https://www.phoenixframework.org/ - * Guides: https://hexdocs.pm/phoenix/overview.html - * Docs: https://hexdocs.pm/phoenix - * Forum: https://elixirforum.com/c/phoenix-forum - * Source: https://github.com/phoenixframework/phoenix +video ideas: +- [ai agents elixir conf](https://www.youtube.com/watch?v=Wu3jXi1IyeM) + - recommends oban instead of GenServer + - can autoscale with flame + - tool calling scaling? + - using elixir langchain for tool definitions \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index cd5c28a..d69f0c0 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -14,3 +14,99 @@ @variant phx-change-loading (&.phx-change-loading, .phx-change-loading &); /* This file is for your main application CSS */ + +/* Form Elements */ +label { + @apply block text-sm font-medium text-cyan-300 mb-1; +} + +input[type="text"], +input[type="email"], +input[type="password"], +input[type="number"], +input[type="search"], +input[type="url"], +textarea, +select { + @apply w-full rounded-md px-3 py-2 text-sm + bg-cyan-950 text-cyan-50 placeholder-cyan-600 + border border-cyan-800 + outline-none + transition-colors duration-150 + focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500; +} + +textarea { + @apply resize-y min-h-24; +} + +button[type="submit"], +input[type="submit"] { + @apply px-4 py-2 rounded-md text-sm font-medium + bg-cyan-600 text-white + border border-cyan-500 + transition-colors duration-150 + hover:bg-cyan-500 + disabled:opacity-40 disabled:cursor-not-allowed; +} + +fieldset { + @apply border border-cyan-800 rounded-md px-4 py-3; +} + +legend { + @apply text-sm font-semibold text-cyan-400 px-1; +} + +/* Spinner */ +.loader { + width: 48px; + height: 48px; + display: inline-block; + position: relative; + transform: rotate(45deg); +} +.loader::before { + content: ''; + box-sizing: border-box; + width: 24px; + height: 24px; + position: absolute; + left: 0; + top: -24px; + animation: animloader 4s ease infinite; +} +.loader::after { + content: ''; + box-sizing: border-box; + position: absolute; + left: 0; + top: 0; + width: 24px; + height: 24px; + background: rgba(255, 255, 255, 0.85); + box-shadow: 0 0 10px rgba(0, 0, 0, 0.15); + animation: animloader2 2s ease infinite; +} + +@keyframes animloader { + 0% { box-shadow: 0 24px rgba(255,255,255,0), 24px 24px rgba(255,255,255,0), 24px 48px rgba(255,255,255,0), 0px 48px rgba(255,255,255,0); } + 12% { box-shadow: 0 24px white, 24px 24px rgba(255,255,255,0), 24px 48px rgba(255,255,255,0), 0px 48px rgba(255,255,255,0); } + 25% { box-shadow: 0 24px white, 24px 24px white, 24px 48px rgba(255,255,255,0), 0px 48px rgba(255,255,255,0); } + 37% { box-shadow: 0 24px white, 24px 24px white, 24px 48px white, 0px 48px rgba(255,255,255,0); } + 50% { box-shadow: 0 24px white, 24px 24px white, 24px 48px white, 0px 48px white; } + 62% { box-shadow: 0 24px rgba(255,255,255,0), 24px 24px white, 24px 48px white, 0px 48px white; } + 75% { box-shadow: 0 24px rgba(255,255,255,0), 24px 24px rgba(255,255,255,0), 24px 48px white, 0px 48px white; } + 87% { box-shadow: 0 24px rgba(255,255,255,0), 24px 24px rgba(255,255,255,0), 24px 48px rgba(255,255,255,0), 0px 48px white; } + 100% { box-shadow: 0 24px rgba(255,255,255,0), 24px 24px rgba(255,255,255,0), 24px 48px rgba(255,255,255,0), 0px 48px rgba(255,255,255,0); } +} + +@keyframes animloader2 { + 0% { transform: translate(0, 0) rotateX(0) rotateY(0); } + 25% { transform: translate(100%, 0) rotateX(0) rotateY(180deg); } + 50% { transform: translate(100%, 100%) rotateX(-180deg) rotateY(180deg); } + 75% { transform: translate(0, 100%) rotateX(-180deg) rotateY(360deg); } + 100% { transform: translate(0, 0) rotateX(0) rotateY(360deg); } +} + + diff --git a/config/runtime.exs b/config/runtime.exs index 94e8bd5..2146aaf 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -1,4 +1,12 @@ import Config +import Dotenvy + +source!([".env", System.get_env()]) + +config :elixir_ai, + ai_endpoint: env!("AI_RESPONSES_ENDPOINT", :string!), + ai_token: env!("AI_TOKEN", :string!), + ai_model: env!("AI_MODEL", :string!) # config/runtime.exs is executed for all environments, including # during releases. It is executed after compilation and before the @@ -81,5 +89,4 @@ if config_env() == :prod do # force_ssl: [hsts: true] # # Check `Plug.SSL` for all available options in `force_ssl`. - - end +end diff --git a/lib/elixir_ai/application.ex b/lib/elixir_ai/application.ex index f0c4f85..48df4f7 100644 --- a/lib/elixir_ai/application.ex +++ b/lib/elixir_ai/application.ex @@ -8,6 +8,7 @@ defmodule ElixirAi.Application do ElixirAiWeb.Telemetry, {DNSCluster, query: Application.get_env(:elixir_ai, :dns_cluster_query) || :ignore}, {Phoenix.PubSub, name: ElixirAi.PubSub}, + ElixirAi.ChatRunner, ElixirAiWeb.Endpoint ] diff --git a/lib/elixir_ai/chat_runner.ex b/lib/elixir_ai/chat_runner.ex new file mode 100644 index 0000000..5069c74 --- /dev/null +++ b/lib/elixir_ai/chat_runner.ex @@ -0,0 +1,108 @@ +defmodule ElixirAi.ChatRunner do + require Logger + use GenServer + import ElixirAi.ChatUtils + + @topic "ai_chat" + + def new_user_message(text_content) do + GenServer.cast(__MODULE__, {:user_message, text_content}) + end + + def get_conversation do + GenServer.call(__MODULE__, :get_conversation) + end + + def start_link(_opts) do + GenServer.start_link( + __MODULE__, + %{ + messages: [], + streaming_response: nil, + turn: :user + }, + name: __MODULE__ + ) + end + + def init(state) do + {:ok, state} + end + + def handle_cast({:user_message, text_content}, state) do + new_message = %{role: :user, content: text_content} + broadcast({:user_chat_message, new_message}) + new_state = %{state | messages: state.messages ++ [new_message], turn: :assistant} + request_ai_response(self(), new_state.messages) + {:noreply, new_state} + end + + def handle_info({:start_new_ai_response, id}, state) do + starting_response = %{id: id, reasoning_content: "", content: ""} + broadcast({:start_ai_response_stream, starting_response}) + + {:noreply, %{state | streaming_response: starting_response}} + end + + def handle_info( + msg, + %{streaming_response: %{id: current_id}} = state + ) + when is_tuple(msg) and tuple_size(msg) in [2, 3] and elem(msg, 1) != current_id do + Logger.warning( + "Received #{elem(msg, 0)} for id #{elem(msg, 1)} but current streaming response is for id #{current_id}" + ) + + {:noreply, state} + end + + def handle_info({:ai_reasoning_chunk, _id, reasoning_content}, state) do + broadcast({:reasoning_chunk_content, reasoning_content}) + + {:noreply, + %{ + state + | streaming_response: %{ + state.streaming_response + | reasoning_content: state.streaming_response.reasoning_content <> reasoning_content + } + }} + end + + def handle_info({:ai_text_chunk, _id, text_content}, state) do + broadcast({:text_chunk_content, text_content}) + + {:noreply, + %{ + state + | streaming_response: %{ + state.streaming_response + | content: state.streaming_response.content <> text_content + } + }} + end + + def handle_info({:ai_stream_finish, _id}, state) do + broadcast(:end_ai_response) + + final_message = %{ + role: :assistant, + content: state.streaming_response.content, + reasoning_content: state.streaming_response.reasoning_content + } + + {:noreply, + %{ + state + | streaming_response: nil, + messages: state.messages ++ [final_message], + turn: :user + }} + end + + def handle_call(:get_conversation, _from, state) do + {:reply, state, state} + end + + defp broadcast(msg), do: Phoenix.PubSub.broadcast(ElixirAi.PubSub, @topic, msg) +end diff --git a/lib/elixir_ai/chat_utils.ex b/lib/elixir_ai/chat_utils.ex new file mode 100644 index 0000000..d784aaf --- /dev/null +++ b/lib/elixir_ai/chat_utils.ex @@ -0,0 +1,120 @@ +defmodule ElixirAi.ChatUtils do + require Logger + + def request_ai_response(server, messages) do + Task.start(fn -> + api_url = Application.fetch_env!(:elixir_ai, :ai_endpoint) + api_key = Application.fetch_env!(:elixir_ai, :ai_token) + model = Application.fetch_env!(:elixir_ai, :ai_model) + + body = %{ + model: model, + stream: true, + messages: messages |> Enum.map(&api_message/1) + } + + headers = [{"authorization", "Bearer #{api_key}"}] + + case Req.post(api_url, + json: body, + headers: headers, + into: fn {:data, data}, acc -> + data + |> String.split("\n") + |> Enum.each(&handle_stream_line(server, &1)) + + {:cont, acc} + end + ) do + {:ok, _} -> + :ok + + {:error, reason} -> + IO.warn("AI request failed: #{inspect(reason)}") + end + end) + end + + def handle_stream_line(_server, "") do + :ok + end + + def handle_stream_line(server, "data: [DONE]") do + # send(server, :ai_stream_done) + :ok + end + + def handle_stream_line(server, "data: " <> json) do + case Jason.decode(json) do + {:ok, body} -> + # Logger.debug("Received AI chunk: #{inspect(body)}") + handle_stream_line(server, body) + + other -> + Logger.error("Failed to decode AI response chunk: #{inspect(other)}") + :ok + end + end + + # first streamed response + def handle_stream_line(server, %{ + "choices" => [%{"delta" => %{"content" => nil, "role" => "assistant"}}], + "id" => id + }) do + send( + server, + {:start_new_ai_response, id} + ) + end + + # last streamed response + def handle_stream_line(server, %{ + "choices" => [%{"finish_reason" => "stop"}], + "id" => id + }) do + send( + server, + {:ai_stream_finish, id} + ) + end + + # streamed in reasoning + def handle_stream_line(server, %{ + "choices" => [ + %{ + "delta" => %{"reasoning_content" => reasoning_content}, + "finish_reason" => nil + } + ], + "id" => id + }) do + send( + server, + {:ai_reasoning_chunk, id, reasoning_content} + ) + end + + def handle_stream_line(server, %{ + "choices" => [ + %{ + "delta" => %{"content" => reasoning_content}, + "finish_reason" => nil + } + ], + "id" => id + }) do + send( + server, + {:ai_text_chunk, id, reasoning_content} + ) + end + + def handle_stream_line(_server, unmatched_message) do + Logger.warning("Received unmatched stream line: #{inspect(unmatched_message)}") + :ok + end + + def api_message(%{role: role, content: content}) do + %{role: Atom.to_string(role), content: content} + end +end diff --git a/lib/elixir_ai_web/components/layouts/app.html.heex b/lib/elixir_ai_web/components/layouts/app.html.heex new file mode 100644 index 0000000..0dc9f32 --- /dev/null +++ b/lib/elixir_ai_web/components/layouts/app.html.heex @@ -0,0 +1,3 @@ +
+ {@inner_content} +
diff --git a/lib/elixir_ai_web/components/markdown.ex b/lib/elixir_ai_web/components/markdown.ex new file mode 100644 index 0000000..bbe2095 --- /dev/null +++ b/lib/elixir_ai_web/components/markdown.ex @@ -0,0 +1,23 @@ +defmodule ElixirAiWeb.Markdown do + @doc """ + Converts a markdown string to sanitized HTML, safe for raw rendering. + Falls back to escaped plain text if the markdown is incomplete or invalid. + """ + def render(nil), do: Phoenix.HTML.raw("") + def render(""), do: Phoenix.HTML.raw("") + + def render(markdown) do + case Earmark.as_html(markdown, breaks: true, compact_output: true) do + {:ok, html, _warnings} -> + html + |> HtmlSanitizeEx.markdown_html() + |> Phoenix.HTML.raw() + + {:error, html, _errors} -> + # Partial/invalid markdown — use whatever HTML was produced, still sanitize + html + |> HtmlSanitizeEx.markdown_html() + |> Phoenix.HTML.raw() + end + end +end diff --git a/lib/elixir_ai_web/components/spinner.ex b/lib/elixir_ai_web/components/spinner.ex new file mode 100644 index 0000000..0363818 --- /dev/null +++ b/lib/elixir_ai_web/components/spinner.ex @@ -0,0 +1,11 @@ +defmodule ElixirAiWeb.Spinner do + use Phoenix.Component + + attr :class, :string, default: nil + + def spinner(assigns) do + ~H""" + + """ + end +end diff --git a/lib/elixir_ai_web/controllers/page_html/home.html.heex b/lib/elixir_ai_web/controllers/page_html/home.html.heex index 3e78aaf..38aa392 100644 --- a/lib/elixir_ai_web/controllers/page_html/home.html.heex +++ b/lib/elixir_ai_web/controllers/page_html/home.html.heex @@ -1,3 +1,3 @@
- home + <%= live_render(@conn, ElixirAiWeb.ChatLive) %>
diff --git a/lib/elixir_ai_web/live/chat_live.ex b/lib/elixir_ai_web/live/chat_live.ex new file mode 100644 index 0000000..f9120cf --- /dev/null +++ b/lib/elixir_ai_web/live/chat_live.ex @@ -0,0 +1,121 @@ +defmodule ElixirAiWeb.ChatLive do + use ElixirAiWeb, :live_view + import ElixirAiWeb.Spinner + import ElixirAi.ChatRunner + alias ElixirAiWeb.Markdown + + @topic "ai_chat" + + def mount(_params, _session, socket) do + if connected?(socket), do: Phoenix.PubSub.subscribe(ElixirAi.PubSub, @topic) + conversation = get_conversation() + + {:ok, + socket + |> assign(user_input: "") + |> assign(messages: conversation.messages) + |> assign(streaming_response: nil)} + end + + def render(assigns) do + ~H""" +
+
+ Live Chat +
+
+ <%= if @messages == [] do %> +

No messages yet.

+ <% end %> + <%= for msg <- @messages do %> +
+ <%= if msg.role == :assistant do %> + + {Markdown.render(msg.reasoning_content)} + + <% end %> + + {Markdown.render(msg.content)} + +
+ <% end %> + <%= if @streaming_response do %> +
+ + {Markdown.render(@streaming_response.reasoning_content)} + + + {Markdown.render(@streaming_response.content)} + + <.spinner /> +
+ <% end %> +
+
+ + +
+
+ """ + end + + def handle_event("update_user_input", %{"user_input" => user_input}, socket) do + {:noreply, assign(socket, user_input: user_input)} + end + + def handle_event("submit", %{"user_input" => user_input}, socket) when user_input != "" do + ElixirAi.ChatRunner.new_user_message(user_input) + {:noreply, assign(socket, user_input: "")} + end + + def handle_info({:user_chat_message, message}, socket) do + {:noreply, update(socket, :messages, &(&1 ++ [message]))} + end + + def handle_info( + {:start_ai_response_stream, + %{id: _id, reasoning_content: "", content: ""} = starting_response}, + socket + ) do + {:noreply, assign(socket, streaming_response: starting_response)} + end + + def handle_info({:reasoning_chunk_content, reasoning_content}, socket) do + updated_response = %{ + socket.assigns.streaming_response + | reasoning_content: + socket.assigns.streaming_response.reasoning_content <> reasoning_content + } + + {:noreply, assign(socket, streaming_response: updated_response)} + end + + def handle_info({:text_chunk_content, text_content}, socket) do + updated_response = %{ + socket.assigns.streaming_response + | content: socket.assigns.streaming_response.content <> text_content + } + + {:noreply, assign(socket, streaming_response: updated_response)} + end + + def handle_info(:end_ai_response, socket) do + final_response = %{ + role: :assistant, + content: socket.assigns.streaming_response.content, + reasoning_content: socket.assigns.streaming_response.reasoning_content + } + + {:noreply, + socket + |> update(:messages, &(&1 ++ [final_response])) + |> assign(streaming_response: nil)} + end +end diff --git a/mix.exs b/mix.exs index f9de914..4003c81 100644 --- a/mix.exs +++ b/mix.exs @@ -47,6 +47,10 @@ defmodule ElixirAi.MixProject do app: false, compile: false, depth: 1}, + {:req, "~> 0.5"}, + {:html_sanitize_ex, "~> 1.4"}, + {:earmark, "~> 1.4"}, + {:dotenvy, "~> 1.1.1"}, {:telemetry_metrics, "~> 1.0"}, {:telemetry_poller, "~> 1.0"}, {:jason, "~> 1.2"}, diff --git a/mix.lock b/mix.lock index 6fceb43..4772034 100644 --- a/mix.lock +++ b/mix.lock @@ -2,6 +2,8 @@ "bandit": {:hex, :bandit, "1.10.3", "1e5d168fa79ec8de2860d1b4d878d97d4fbbe2fdbe7b0a7d9315a4359d1d4bb9", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "99a52d909c48db65ca598e1962797659e3c0f1d06e825a50c3d75b74a5e2db18"}, "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"}, "dns_cluster": {:hex, :dns_cluster, "0.1.3", "0bc20a2c88ed6cc494f2964075c359f8c2d00e1bf25518a6a6c7fd277c9b0c66", [:mix], [], "hexpm", "46cb7c4a1b3e52c7ad4cbe33ca5079fbde4840dedeafca2baf77996c2da1bc33"}, + "dotenvy": {:hex, :dotenvy, "1.1.1", "00e318f3c51de9fafc4b48598447e386f19204dc18ca69886905bb8f8b08b667", [:mix], [], "hexpm", "c8269471b5701e9e56dc86509c1199ded2b33dce088c3471afcfef7839766d8e"}, + "earmark": {:hex, :earmark, "1.4.48", "5f41e579d85ef812351211842b6e005f6e0cef111216dea7d4b9d58af4608434", [:mix], [], "hexpm", "a461a0ddfdc5432381c876af1c86c411fd78a25790c75023c7a4c035fdc858f9"}, "esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"}, "expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, @@ -10,10 +12,12 @@ "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, "heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "88ab3a0d790e6a47404cba02800a6b25d2afae50", [tag: "v2.1.1", sparse: "optimized", depth: 1]}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, + "html_sanitize_ex": {:hex, :html_sanitize_ex, "1.4.4", "271455b4d300d5d53a5d92b5bd1c00ad14c5abf1c9ff87be069af5736496515c", [:mix], [{:mochiweb, "~> 2.15 or ~> 3.1", [hex: :mochiweb, repo: "hexpm", optional: false]}], "hexpm", "12e1754204e7db5df1750df0a5dba1bbdf89260800019ab081f2b046596be56b"}, "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.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mochiweb": {:hex, :mochiweb, "3.3.0", "2898ad0bfeee234e4cbae623c7052abc3ff0d73d499ba6e6ffef445b13ffd07a", [:rebar3], [], "hexpm", "aa85b777fb23e9972ebc424e40b5d35106f19bc998873e026dedd876df8ee50c"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "phoenix": {:hex, :phoenix, "1.7.21", "14ca4f1071a5f65121217d6b57ac5712d1857e40a0833aff7a691b7870fc9a3b", [: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.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "336dce4f86cba56fed312a7d280bf2282c720abb6074bdb1b61ec8095bdd0bc9"}, @@ -25,6 +29,7 @@ "phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"}, "plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"}, "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"}, + "req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{: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, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"}, "swoosh": {:hex, :swoosh, "1.23.0", "a1b7f41705357ffb06457d177e734bf378022901ce53889a68bcc59d10a23c27", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "97aaf04481ce8a351e2d15a3907778bdf3b1ea071cfff3eb8728b65943c77f6d"}, "tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"}, "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},