streaming single conversation, this is sick

This commit is contained in:
2026-03-05 16:11:11 -07:00
parent 76c164d931
commit ea2b6d4cbf
14 changed files with 517 additions and 17 deletions

3
.gitignore vendored
View File

@@ -36,3 +36,6 @@ npm-debug.log
/assets/node_modules/
elixir_ls/
.env
*.tmp

View File

@@ -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 <https://hexdocs.pm/oban/Oban.html>
- can autoscale with flame <https://hexdocs.pm/flame/FLAME.html>
- tool calling scaling?
- using elixir langchain for tool definitions

View File

@@ -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); }
}

View File

@@ -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

View File

@@ -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
]

View File

@@ -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

120
lib/elixir_ai/chat_utils.ex Normal file
View File

@@ -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

View File

@@ -0,0 +1,3 @@
<main class="px-4 py-8 sm:px-6 lg:px-8">
{@inner_content}
</main>

View File

@@ -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

View File

@@ -0,0 +1,11 @@
defmodule ElixirAiWeb.Spinner do
use Phoenix.Component
attr :class, :string, default: nil
def spinner(assigns) do
~H"""
<span class={["loader", @class]}></span>
"""
end
end

View File

@@ -1,3 +1,3 @@
<div class="">
home
<%= live_render(@conn, ElixirAiWeb.ChatLive) %>
</div>

View File

@@ -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"""
<div class="flex flex-col h-96 border rounded-lg overflow-hidden">
<div class="px-4 py-3 font-semibold">
Live Chat
</div>
<div class="flex-1 overflow-y-auto p-4">
<%= if @messages == [] do %>
<p class="text-sm text-center mt-4">No messages yet.</p>
<% end %>
<%= for msg <- @messages do %>
<div class={["mb-2 text-sm", if(msg.role == :user, do: "text-right", else: "text-left")]}>
<%= if msg.role == :assistant do %>
<span class="inline-block px-3 py-1 rounded-lg border">
{Markdown.render(msg.reasoning_content)}
</span>
<% end %>
<span class="inline-block px-3 py-1 rounded-lg border">
{Markdown.render(msg.content)}
</span>
</div>
<% end %>
<%= if @streaming_response do %>
<div class="mb-2 text-sm text-left">
<span class="inline-block px-3 py-1 rounded-lg border">
{Markdown.render(@streaming_response.reasoning_content)}
</span>
<span class="inline-block px-3 py-1 rounded-lg border">
{Markdown.render(@streaming_response.content)}
</span>
<.spinner />
</div>
<% end %>
</div>
<form class="border-t p-3 flex gap-2" phx-submit="submit" phx-change="update_user_input">
<input
type="text"
name="user_input"
value={@user_input}
class="flex-1 border rounded px-3 py-2 text-sm focus:outline-none focus:ring-2"
/>
<button type="submit" class="px-4 py-2 rounded text-sm border">
Send
</button>
</form>
</div>
"""
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

View File

@@ -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"},

View File

@@ -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"},