defmodule MusicLibraryWeb.Components.Chat do use MusicLibraryWeb, :live_component require Logger alias MusicLibrary.Chats alias MusicLibraryWeb.Markdown alias Phoenix.LiveView def open(id), do: Fluxon.open_dialog(id) @impl true def mount(socket) do {:ok, reset_chat_state(socket)} end @impl true def update(%{chunk: chunk}, socket) do doc = socket.assigns.streaming_doc || Markdown.new_streaming_doc(link_target: "_blank") {:ok, socket |> update(:current_response, &(&1 <> chunk)) |> assign(:streaming_doc, MDEx.Document.put_markdown(doc, chunk))} end def update(%{done: true}, socket) do completed_message = %{role: "assistant", content: socket.assigns.current_response} save_assistant_message(socket.assigns.chat, completed_message.content) {:ok, socket |> update(:messages, &(&1 ++ [completed_message])) |> assign(:current_response, "") |> assign(:streaming_doc, nil) |> assign(:loading, false)} end def update(%{error: error}, socket) do {:ok, socket |> assign(:error, error) |> assign(:loading, false)} end def update(assigns, socket) do socket = assign(socket, assigns) if changed?(socket, :entity) or changed?(socket, :musicbrainz_id) do {:ok, load_for_entity(socket)} else {:ok, socket} end end @impl true def render(assigns) do ~H"""
<.sheet id={@sheet_id} placement="right" class="flex h-full w-md flex-col sm:min-w-lg lg:min-w-2xl" hide_close_button > <%= if @view == :list do %> {render_list_view(assigns)} <% else %> {render_active_view(assigns)} <% end %>
""" end defp render_list_view(assigns) do ~H"""

{gettext("Chat history")}

<.button size="sm" variant="soft" phx-click="new_chat" phx-target={@myself}> <.icon name="hero-plus" class="icon" data-slot="icon" /> {gettext("New chat")} <.button size="icon-sm" variant="ghost" phx-click={Fluxon.close_dialog(@sheet_id)} aria-label={gettext("Close")} > <.icon name="hero-x-mark" class="icon" />
<.icon name="hero-chat-bubble-left-right" class="mb-4 size-12 text-zinc-300 dark:text-zinc-600" />

{gettext("No previous chats")}

<.button size="icon-xs" variant="ghost" color="danger" phx-click="delete_chat" phx-value-id={chat.id} phx-target={@myself} aria-label={gettext("Delete chat")} class="absolute top-3 right-2 opacity-0 transition-opacity group-hover:opacity-100" > <.icon name="hero-trash" class="icon" />
""" end defp render_active_view(assigns) do ~H"""

{gettext("Chat about %{title}", title: @title)}

<.button :if={@has_history} size="icon-sm" variant="ghost" phx-click="show_chat_list" phx-target={@myself} aria-label={gettext("Chat history")} > <.icon name="hero-clock" class="icon" /> <.button :if={@messages != []} size="icon-sm" variant="ghost" phx-click="new_chat" phx-target={@myself} aria-label={gettext("New chat")} > <.icon name="hero-plus" class="icon" /> <.button size="icon-sm" variant="ghost" phx-click={Fluxon.close_dialog(@sheet_id)} aria-label={gettext("Close")} > <.icon name="hero-x-mark" class="icon" />
<.icon name="hero-chat-bubble-left-right" class="mb-4 size-12 text-zinc-300 dark:text-zinc-600" />

{@empty_prompt}

{message.content}

{raw(Markdown.to_html(message.content, link_target: "_blank"))}
<.copy_to_clipboard target_id={"#{@id}-msg-#{index}"} label={gettext("Copy message")} />
{raw(Markdown.streaming_to_html(@streaming_doc, link_target: "_blank"))}
<.loading class="size-4" /> {gettext("Thinking...")}

{@error}

<.button size="xs" variant="ghost" color="danger" phx-click="retry" phx-target={@myself} class="mt-2" > {gettext("Retry")}
<.input name="message" value="" placeholder={@placeholder} 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" />
""" end @impl true def handle_event("send_message", %{"message" => ""}, socket), do: {:noreply, socket} def handle_event("send_message", %{"message" => text}, socket), do: do_send_message(socket, text) def handle_event("new_chat", _params, socket) do {:noreply, socket |> assign(:messages, []) |> assign(:current_response, "") |> assign(:streaming_doc, nil) |> assign(:error, nil) |> assign(:chat, nil) |> assign(:view, :active)} end def handle_event("show_chat_list", _params, socket) do chats = Chats.list_chats(socket.assigns.entity, socket.assigns.musicbrainz_id) {:noreply, socket |> assign(:chats, chats) |> assign(:view, :list)} end def handle_event("select_chat", %{"id" => id}, socket) do chat = Chats.get_chat!(id) {:noreply, socket |> assign(:chat, chat) |> assign(:messages, chat.messages) |> assign(:current_response, "") |> assign(:streaming_doc, nil) |> assign(:error, nil) |> assign(:view, :active)} end def handle_event("delete_chat", %{"id" => id}, socket) do chat = Chats.get_chat!(id) {:ok, _} = Chats.delete_chat(chat) send(self(), {__MODULE__, :chats_changed}) chats = Chats.list_chats(socket.assigns.entity, socket.assigns.musicbrainz_id) socket = if socket.assigns.chat && socket.assigns.chat.id == id do socket |> assign(:chat, nil) |> assign(:messages, []) |> assign(:current_response, "") |> assign(:streaming_doc, nil) |> assign(:error, nil) else socket end {:noreply, assign(socket, :chats, chats)} end def handle_event("retry", _params, socket) do case List.last(socket.assigns.messages) do %{role: "user"} = last_message -> socket |> assign(:messages, Enum.drop(socket.assigns.messages, -1)) |> do_send_message(last_message.content) _ -> {:noreply, assign(socket, :error, nil)} end end defp do_send_message(socket, text) do parent_pid = self() component_id = socket.assigns.id user_message = %{role: "user", content: String.trim(text)} messages = socket.assigns.messages ++ [user_message] chat_module = socket.assigns.chat_module chat_context = socket.assigns.chat_context chat = persist_user_message(socket, user_message) stream_messages = Enum.map(messages, &%{role: &1.role, content: &1.content}) Task.Supervisor.start_child(MusicLibrary.TaskSupervisor, fn -> case chat_module.stream_response(stream_messages, chat_context, fn chunk -> LiveView.send_update(parent_pid, __MODULE__, id: component_id, chunk: chunk ) end) do :ok -> LiveView.send_update(parent_pid, __MODULE__, id: component_id, done: true ) {:error, reason} -> Logger.error("Chat streaming error: #{inspect(reason)}") LiveView.send_update(parent_pid, __MODULE__, id: component_id, error: gettext("Something went wrong. Please try again.") ) end end) {:noreply, socket |> assign(:messages, messages) |> assign(:chat, chat) |> assign(:has_history, true) |> assign(:loading, true) |> assign(:current_response, "") |> assign(:streaming_doc, nil) |> assign(:error, nil)} end defp persist_user_message(socket, user_message) do case socket.assigns.chat do nil -> {:ok, chat} = Chats.create_chat_with_message( %{entity: socket.assigns.entity, musicbrainz_id: socket.assigns.musicbrainz_id}, %{role: user_message.role, content: user_message.content} ) send(self(), {__MODULE__, :chats_changed}) chat chat -> {:ok, _message} = Chats.add_message(chat, %{role: user_message.role, content: user_message.content}) chat end end defp save_assistant_message(nil, _content), do: :ok defp save_assistant_message(chat, content) do case Chats.add_message(chat, %{role: "assistant", content: content}) do {:ok, _message} -> :ok {:error, reason} -> Logger.warning("Failed to persist assistant message: #{inspect(reason)}") end end defp reset_chat_state(socket) do socket |> assign(:messages, []) |> assign(:current_response, "") |> assign(:loading, false) |> assign(:error, nil) |> assign(:view, :active) |> assign(:chat, nil) |> assign(:chats, []) |> assign(:streaming_doc, nil) |> assign(:has_history, false) end defp load_for_entity(socket) do socket = reset_chat_state(socket) if Chats.has_any_chats?(socket.assigns.entity, socket.assigns.musicbrainz_id) do chats = Chats.list_chats(socket.assigns.entity, socket.assigns.musicbrainz_id) socket |> assign(:has_history, true) |> assign(:chats, chats) |> assign(:view, :list) else socket end end defp message_classes("user") do "ml-auto bg-red-500 dark:bg-red-700 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