Add record chat sheet with OpenAI streaming

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claudio Ortolina
2026-02-22 10:13:05 +00:00
parent 5868fa0219
commit 6e6e419801
9 changed files with 621 additions and 114 deletions
@@ -0,0 +1,268 @@
defmodule MusicLibraryWeb.Components.RecordChat do
use MusicLibraryWeb, :live_component
alias MusicLibrary.RecordChat
def open(id), do: Fluxon.open_dialog(id)
@impl true
def mount(socket) do
{:ok,
socket
|> assign(:messages, [])
|> assign(:current_response, "")
|> assign(:loading, false)
|> assign(:embedding_text, nil)
|> assign(:error, nil)}
end
@impl true
def update(%{chunk: chunk}, socket) do
{:ok, update(socket, :current_response, &(&1 <> chunk))}
end
def update(%{done: true}, socket) do
completed_message = %{role: "assistant", content: socket.assigns.current_response}
{:ok,
socket
|> update(:messages, &(&1 ++ [completed_message]))
|> assign(:current_response, "")
|> assign(:loading, false)}
end
def update(%{error: error}, socket) do
{:ok,
socket
|> assign(:error, error)
|> assign(:loading, false)}
end
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> maybe_load_embedding_text()}
end
defp maybe_load_embedding_text(socket) do
if socket.assigns.embedding_text == nil and socket.assigns[:record] do
case RecordChat.get_embedding_text(socket.assigns.record.id) do
{:ok, text} -> assign(socket, :embedding_text, text)
{:error, :not_found} -> assign(socket, :embedding_text, nil)
end
else
socket
end
end
@impl true
def render(assigns) do
~H"""
<div>
<.sheet
id={@sheet_id}
placement="right"
class="w-md sm:min-w-lg lg:min-w-2xl flex flex-col h-full"
>
<div class="flex items-center justify-between pb-4 border-b border-zinc-200 dark:border-zinc-700">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
{gettext("Chat about %{title}", title: @record.title)}
</h2>
<.button
:if={@messages != []}
size="icon-sm"
variant="ghost"
phx-click="clear_chat"
phx-target={@myself}
aria-label={gettext("Clear chat")}
>
<.icon name="hero-trash" class="icon" />
</.button>
</div>
<div
id={"#{@id}-messages"}
class="flex-1 overflow-y-auto py-4 space-y-4"
phx-hook=".ScrollBottom"
>
<div
:if={@messages == [] and @current_response == ""}
class="flex flex-col items-center justify-center h-full text-center text-zinc-500 dark:text-zinc-400"
>
<.icon
name="hero-chat-bubble-left-right"
class="size-12 mb-4 text-zinc-300 dark:text-zinc-600"
/>
<p class="text-sm font-medium">{gettext("Ask anything about this album")}</p>
<p class="text-xs mt-1">
{gettext("e.g. \"What genre is this?\", \"Tell me about the artists\"")}
</p>
</div>
<div
:for={message <- @messages}
class={[
"max-w-[85%] rounded-lg px-4 py-2 text-sm",
message_classes(message.role)
]}
>
<p class="whitespace-pre-wrap">{message.content}</p>
</div>
<div
:if={@current_response != ""}
class="max-w-[85%] rounded-lg px-4 py-2 text-sm bg-zinc-100 dark:bg-zinc-700 text-zinc-900 dark:text-zinc-100"
>
<p class="whitespace-pre-wrap">{@current_response}</p>
</div>
<div
:if={@loading and @current_response == ""}
class="flex items-center gap-2 text-zinc-500 dark:text-zinc-400"
>
<.loading class="size-4" />
<span class="text-sm">{gettext("Thinking...")}</span>
</div>
<div
:if={@error}
class="max-w-[85%] rounded-lg px-4 py-2 text-sm bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300"
>
<p>{@error}</p>
<.button
size="xs"
variant="ghost"
color="danger"
phx-click="retry"
phx-target={@myself}
class="mt-2"
>
{gettext("Retry")}
</.button>
</div>
</div>
<div class="pt-4 border-t border-zinc-200 dark:border-zinc-700">
<form id={"#{@id}-form"} phx-submit="send_message" phx-target={@myself} class="flex gap-2">
<.input
name="message"
value=""
placeholder={gettext("Ask about this album...")}
class="flex-1"
disabled={@loading}
autocomplete="off"
/>
<.button
type="submit"
variant="solid"
size="icon"
disabled={@loading}
aria-label={gettext("Send message")}
>
<.icon name="hero-paper-airplane" class="icon" />
</.button>
</form>
</div>
</.sheet>
<script :type={Phoenix.LiveView.ColocatedHook} name=".ScrollBottom">
export default {
mounted() {
this.scrollToBottom();
this.observer = new MutationObserver(() => this.scrollToBottom());
this.observer.observe(this.el, { childList: true, subtree: true, characterData: true });
},
updated() {
this.scrollToBottom();
},
destroyed() {
if (this.observer) this.observer.disconnect();
},
scrollToBottom() {
this.el.scrollTop = this.el.scrollHeight;
}
}
</script>
</div>
"""
end
@impl true
def handle_event("send_message", %{"message" => ""}, socket) do
{:noreply, socket}
end
def handle_event("send_message", %{"message" => text}, socket) do
parent_pid = self()
component_id = socket.assigns.id
user_message = %{role: "user", content: String.trim(text)}
messages = socket.assigns.messages ++ [user_message]
Task.start(fn ->
try do
RecordChat.stream_response(
messages,
socket.assigns.record,
socket.assigns.embedding_text,
fn chunk ->
Phoenix.LiveView.send_update(parent_pid, __MODULE__,
id: component_id,
chunk: chunk
)
end
)
Phoenix.LiveView.send_update(parent_pid, __MODULE__,
id: component_id,
done: true
)
rescue
e ->
Phoenix.LiveView.send_update(parent_pid, __MODULE__,
id: component_id,
error: Exception.message(e)
)
end
end)
{:noreply,
socket
|> assign(:messages, messages)
|> assign(:loading, true)
|> assign(:current_response, "")
|> assign(:error, nil)}
end
def handle_event("clear_chat", _params, socket) do
{:noreply,
socket
|> assign(:messages, [])
|> assign(:current_response, "")
|> assign(:error, nil)}
end
def handle_event("retry", _params, socket) do
case List.last(socket.assigns.messages) do
%{role: "user"} = last_message ->
# Remove the last user message and re-send
messages = Enum.drop(socket.assigns.messages, -1)
socket = assign(socket, :messages, messages)
handle_event("send_message", %{"message" => last_message.content}, socket)
_ ->
{:noreply, assign(socket, :error, nil)}
end
end
defp message_classes("user") do
"ml-auto bg-blue-500 dark:bg-blue-600 text-white"
end
defp message_classes("assistant") do
"bg-zinc-100 dark:bg-zinc-700 text-zinc-900 dark:text-zinc-100"
end
defp message_classes(_), do: ""
end