streaming single conversation, this is sick
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user