streaming single conversation, this is sick
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -36,3 +36,6 @@ npm-debug.log
|
||||
/assets/node_modules/
|
||||
|
||||
elixir_ls/
|
||||
.env
|
||||
|
||||
*.tmp
|
||||
24
README.md
24
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 <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
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
|
||||
108
lib/elixir_ai/chat_runner.ex
Normal file
108
lib/elixir_ai/chat_runner.ex
Normal 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
120
lib/elixir_ai/chat_utils.ex
Normal 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
|
||||
3
lib/elixir_ai_web/components/layouts/app.html.heex
Normal file
3
lib/elixir_ai_web/components/layouts/app.html.heex
Normal file
@@ -0,0 +1,3 @@
|
||||
<main class="px-4 py-8 sm:px-6 lg:px-8">
|
||||
{@inner_content}
|
||||
</main>
|
||||
23
lib/elixir_ai_web/components/markdown.ex
Normal file
23
lib/elixir_ai_web/components/markdown.ex
Normal 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
|
||||
11
lib/elixir_ai_web/components/spinner.ex
Normal file
11
lib/elixir_ai_web/components/spinner.ex
Normal 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
|
||||
@@ -1,3 +1,3 @@
|
||||
<div class="">
|
||||
home
|
||||
<%= live_render(@conn, ElixirAiWeb.ChatLive) %>
|
||||
</div>
|
||||
|
||||
121
lib/elixir_ai_web/live/chat_live.ex
Normal file
121
lib/elixir_ai_web/live/chat_live.ex
Normal 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
|
||||
4
mix.exs
4
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"},
|
||||
|
||||
5
mix.lock
5
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"},
|
||||
|
||||
Reference in New Issue
Block a user