From 6e6e419801423095ab1e7efa9ae0a9cc6172b113 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Sun, 22 Feb 2026 10:13:05 +0000 Subject: [PATCH] Add record chat sheet with OpenAI streaming Co-Authored-By: Claude Opus 4.6 --- lib/music_library/record_chat.ex | 50 ++++ .../components/record_chat.ex | 268 ++++++++++++++++++ .../live/collection_live/show.html.heex | 19 ++ .../live/wishlist_live/show.html.heex | 249 ++++++++-------- lib/open_ai.ex | 8 + lib/open_ai/api.ex | 53 ++++ lib/open_ai/completion.ex | 4 +- priv/gettext/default.pot | 42 +++ priv/gettext/en/LC_MESSAGES/default.po | 42 +++ 9 files changed, 621 insertions(+), 114 deletions(-) create mode 100644 lib/music_library/record_chat.ex create mode 100644 lib/music_library_web/components/record_chat.ex diff --git a/lib/music_library/record_chat.ex b/lib/music_library/record_chat.ex new file mode 100644 index 00000000..e362ed9a --- /dev/null +++ b/lib/music_library/record_chat.ex @@ -0,0 +1,50 @@ +defmodule MusicLibrary.RecordChat do + alias MusicLibrary.Records.{Record, Similarity} + + def stream_response(messages, record, embedding_text, callback) do + system_prompt = build_system_prompt(record, embedding_text) + + full_messages = [ + %{role: "system", content: system_prompt} | messages + ] + + OpenAI.chat_stream(full_messages, on_chunk: callback) + end + + def get_embedding_text(record_id) do + Similarity.get_embedding_text(record_id) + end + + def build_system_prompt(%Record{} = record, embedding_text) do + context = + case embedding_text do + nil -> basic_context(record) + "" -> basic_context(record) + text -> text + end + + """ + You are a knowledgeable music assistant. Answer questions about the album \ + the user is currently viewing. Use the provided information as your primary \ + reference. Be concise and accurate. When unsure, say so. + + Album information: + #{context} + """ + end + + defp basic_context(%Record{} = record) do + artist_names = Record.artist_names(record) + genres = Enum.join(record.genres || [], ", ") + + """ + Album: #{record.title} + Artists: #{artist_names} + Genres: #{genres} + Released: #{record.release_date || "Unknown"} + Type: #{record.type} + Format: #{record.format} + """ + |> String.trim() + end +end diff --git a/lib/music_library_web/components/record_chat.ex b/lib/music_library_web/components/record_chat.ex new file mode 100644 index 00000000..ca30da95 --- /dev/null +++ b/lib/music_library_web/components/record_chat.ex @@ -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""" +
+ <.sheet + id={@sheet_id} + placement="right" + class="w-md sm:min-w-lg lg:min-w-2xl flex flex-col h-full" + > +
+

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

+ <.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" /> + +
+ +
+
+ <.icon + name="hero-chat-bubble-left-right" + class="size-12 mb-4 text-zinc-300 dark:text-zinc-600" + /> +

{gettext("Ask anything about this album")}

+

+ {gettext("e.g. \"What genre is this?\", \"Tell me about the artists\"")} +

+
+ +
+

{message.content}

+
+ +
+

{@current_response}

+
+ +
+ <.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={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" /> + +
+
+ + +
+ """ + 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 diff --git a/lib/music_library_web/live/collection_live/show.html.heex b/lib/music_library_web/live/collection_live/show.html.heex index 8020d30d..2fed29a7 100644 --- a/lib/music_library_web/live/collection_live/show.html.heex +++ b/lib/music_library_web/live/collection_live/show.html.heex @@ -26,6 +26,18 @@ data-slot="icon" /> + <.button + variant="soft" + phx-click={MusicLibraryWeb.Components.RecordChat.open("record-chat-sheet")} + > + {gettext("Chat about album")} + <.icon + name="hero-chat-bubble-left-right" + class="h-5 w-5" + aria-hidden="true" + data-slot="icon" + /> + <.button :if={@record.selected_release_id} variant="soft" @@ -327,6 +339,13 @@ musicbrainz_id={@record.musicbrainz_id} /> + <.live_component + id="record-chat" + sheet_id="record-chat-sheet" + module={MusicLibraryWeb.Components.RecordChat} + record={@record} + /> + <.structured_modal :if={@live_action == :edit} id="record-modal" diff --git a/lib/music_library_web/live/wishlist_live/show.html.heex b/lib/music_library_web/live/wishlist_live/show.html.heex index 098e6e82..3267f1c9 100644 --- a/lib/music_library_web/live/wishlist_live/show.html.heex +++ b/lib/music_library_web/live/wishlist_live/show.html.heex @@ -12,131 +12,147 @@

<.artist_links joinphrase_class="text-sm" artists={@record.artists} />

- <.dropdown id={"actions-#{@record.id}"} placement="bottom-end"> - <:toggle class="h-5 block"> - <.button variant="ghost"> - {gettext("Actions")} +
+ <.button_group> + <.button + variant="soft" + phx-click={MusicLibraryWeb.Components.RecordChat.open("record-chat-sheet")} + > + {gettext("Chat about album")} <.icon - name="hero-ellipsis-vertical" - class="h-5 w-5 text-zinc-500 dark:text-zinc-400 cursor-pointer" + name="hero-chat-bubble-left-right" + class="h-5 w-5" aria-hidden="true" data-slot="icon" /> - - <.focus_wrap id={"actions-#{@record.id}-focus-wrap"}> - <.dropdown_link - id={"actions-#{@record.id}-edit"} - patch={~p"/wishlist/#{@record}/show/edit"} - > - <.icon - name="hero-pencil-square" - class="h-4 w-4 mr-1 phx-click-loading:animate-bounce" - aria-hidden="true" - data-slot="icon" - /> - {gettext("Edit")} - + <.dropdown id={"actions-#{@record.id}"} placement="bottom-end"> + <:toggle> + <.button variant="soft"> + {gettext("Actions")} + <.icon + name="hero-ellipsis-vertical" + class="h-5 w-5 text-zinc-500 dark:text-zinc-400 cursor-pointer" + aria-hidden="true" + data-slot="icon" + /> + + + <.focus_wrap id={"actions-#{@record.id}-focus-wrap"}> + <.dropdown_link + id={"actions-#{@record.id}-edit"} + patch={~p"/wishlist/#{@record}/show/edit"} + > + <.icon + name="hero-pencil-square" + class="h-4 w-4 mr-1 phx-click-loading:animate-bounce" + aria-hidden="true" + data-slot="icon" + /> + {gettext("Edit")} + - <.dropdown_link - id={"actions-#{@record.id}-refresh-cover"} - phx-click={JS.push("refresh_cover", value: %{id: @record.id})} - > - <.icon - name="hero-photo" - class="h-4 w-4 mr-1 phx-click-loading:animate-bounce" - aria-hidden="true" - data-slot="icon" - /> - {gettext("Refresh cover")} - + <.dropdown_link + id={"actions-#{@record.id}-refresh-cover"} + phx-click={JS.push("refresh_cover", value: %{id: @record.id})} + > + <.icon + name="hero-photo" + class="h-4 w-4 mr-1 phx-click-loading:animate-bounce" + aria-hidden="true" + data-slot="icon" + /> + {gettext("Refresh cover")} + - <.dropdown_link - id={"actions-#{@record.id}-refresh-mb-data"} - phx-click={JS.push("refresh_musicbrainz_data", value: %{id: @record.id})} - > - <.icon - name="hero-arrow-path" - class="h-4 w-4 mr-1 phx-click-loading:animate-spin" - aria-hidden="true" - data-slot="icon" - /> - {gettext("Refresh MB data")} - + <.dropdown_link + id={"actions-#{@record.id}-refresh-mb-data"} + phx-click={JS.push("refresh_musicbrainz_data", value: %{id: @record.id})} + > + <.icon + name="hero-arrow-path" + class="h-4 w-4 mr-1 phx-click-loading:animate-spin" + aria-hidden="true" + data-slot="icon" + /> + {gettext("Refresh MB data")} + - <.dropdown_link - id={"actions-#{@record.id}-populate-genres"} - phx-click={JS.push("populate_genres", value: %{id: @record.id})} - > - <.icon - name="hero-sparkles" - class="h-4 w-4 mr-1 phx-click-loading:animate-shake" - aria-hidden="true" - data-slot="icon" - /> - {gettext("Populate genres")} - + <.dropdown_link + id={"actions-#{@record.id}-populate-genres"} + phx-click={JS.push("populate_genres", value: %{id: @record.id})} + > + <.icon + name="hero-sparkles" + class="h-4 w-4 mr-1 phx-click-loading:animate-shake" + aria-hidden="true" + data-slot="icon" + /> + {gettext("Populate genres")} + - <.dropdown_link - :if={!@record.purchased_at} - id={"actions-#{@record.id}-purchase"} - phx-click={ - JS.dispatch("music_library:confetti") - |> JS.push("add-to-collection", value: %{id: @record.id}) - } - > - <.icon - name="hero-banknotes" - class="h-4 w-4 mr-1 phx-click-loading:animate-shake" - aria-hidden="true" - data-slot="icon" - /> - {gettext("Purchased")} - + <.dropdown_link + :if={!@record.purchased_at} + id={"actions-#{@record.id}-purchase"} + phx-click={ + JS.dispatch("music_library:confetti") + |> JS.push("add-to-collection", value: %{id: @record.id}) + } + > + <.icon + name="hero-banknotes" + class="h-4 w-4 mr-1 phx-click-loading:animate-shake" + aria-hidden="true" + data-slot="icon" + /> + {gettext("Purchased")} + - <.dropdown_link - id={"actions-#{@record.id}-extract-colors-fast"} - phx-click={JS.push("extract_colors", value: %{id: @record.id, method: :fast})} - > - <.icon - name="hero-paint-brush" - class="h-4 w-4 mr-1 phx-click-loading:animate-shake" - aria-hidden="true" - data-slot="icon" - /> - {gettext("Extract colors (fast)")} - + <.dropdown_link + id={"actions-#{@record.id}-extract-colors-fast"} + phx-click={JS.push("extract_colors", value: %{id: @record.id, method: :fast})} + > + <.icon + name="hero-paint-brush" + class="h-4 w-4 mr-1 phx-click-loading:animate-shake" + aria-hidden="true" + data-slot="icon" + /> + {gettext("Extract colors (fast)")} + - <.dropdown_link - id={"actions-#{@record.id}-extract-colors-slow"} - phx-click={JS.push("extract_colors", value: %{id: @record.id, method: :slow})} - > - <.icon - name="hero-paint-brush" - class="h-4 w-4 mr-1 phx-click-loading:animate-shake" - aria-hidden="true" - data-slot="icon" - /> - {gettext("Extract colors (slow)")} - + <.dropdown_link + id={"actions-#{@record.id}-extract-colors-slow"} + phx-click={JS.push("extract_colors", value: %{id: @record.id, method: :slow})} + > + <.icon + name="hero-paint-brush" + class="h-4 w-4 mr-1 phx-click-loading:animate-shake" + aria-hidden="true" + data-slot="icon" + /> + {gettext("Extract colors (slow)")} + - <.dropdown_separator /> - <.dropdown_link - id={"actions-#{@record.id}-delete"} - phx-click={JS.push("delete", value: %{id: @record.id})} - data-confirm={gettext("Are you sure?")} - class="text-red-900! hover:bg-red-50! dark:text-red-500! dark:hover:bg-red-900/30! dark:hover:text-red-600!" - > - <.icon - name="hero-trash" - class="h-4 w-4 mr-1 phx-click-loading:animate-spin" - aria-hidden="true" - data-slot="icon" - /> - {gettext("Delete")} - - - + <.dropdown_separator /> + <.dropdown_link + id={"actions-#{@record.id}-delete"} + phx-click={JS.push("delete", value: %{id: @record.id})} + data-confirm={gettext("Are you sure?")} + class="text-red-900! hover:bg-red-50! dark:text-red-500! dark:hover:bg-red-900/30! dark:hover:text-red-600!" + > + <.icon + name="hero-trash" + class="h-4 w-4 mr-1 phx-click-loading:animate-spin" + aria-hidden="true" + data-slot="icon" + /> + {gettext("Delete")} + + + + +

@@ -287,6 +303,13 @@ <.json_viewer title={gettext("MusicBrainz data")} data={@record.musicbrainz_data} /> + <.live_component + id="record-chat" + sheet_id="record-chat-sheet" + module={MusicLibraryWeb.Components.RecordChat} + record={@record} + /> + <.structured_modal :if={@live_action == :edit} id="record-modal" diff --git a/lib/open_ai.ex b/lib/open_ai.ex index b5034c14..27f3f30a 100644 --- a/lib/open_ai.ex +++ b/lib/open_ai.ex @@ -16,6 +16,14 @@ defmodule OpenAI do {:ok, result} end + def chat_stream(messages, opts \\ []) do + model = Keyword.get(opts, :model, "gpt-4o-mini") + temperature = Keyword.get(opts, :temperature, 0.7) + on_chunk = Keyword.fetch!(opts, :on_chunk) + + API.chat_stream(messages, model, temperature, api_key(), on_chunk) + end + def embeddings(text) do API.get_embeddings(text, api_key()) end diff --git a/lib/open_ai/api.ex b/lib/open_ai/api.ex index 8322f9cf..0e20be31 100644 --- a/lib/open_ai/api.ex +++ b/lib/open_ai/api.ex @@ -48,6 +48,49 @@ defmodule OpenAI.API do ) end + def chat_stream(messages, model, temperature, api_key, cb) do + fun = fn request, finch_request, finch_name, finch_options -> + fun = fn + {:status, status}, response -> + %{response | status: status} + + {:headers, headers}, response -> + %{response | headers: headers} + + {:data, data}, response -> + data + |> String.split("data: ") + |> Enum.each(fn str -> + str + |> String.trim() + |> decode_chat_chunk(cb) + end) + + response + end + + case Finch.stream(finch_request, finch_name, Req.Response.new(), fun, finch_options) do + {:ok, response} -> {request, response} + {:error, exception, _response} -> {request, exception} + end + end + + Req.post!("https://api.openai.com/v1/chat/completions", + receive_timeout: 30_000, + connect_options: [ + timeout: 5_000 + ], + json: %{ + model: model, + messages: messages, + stream: true, + temperature: temperature + }, + auth: {:bearer, api_key}, + finch_request: fun + ) + end + def get_embeddings(text, api_key) do resp = Req.post!("https://api.openai.com/v1/embeddings", @@ -69,4 +112,14 @@ defmodule OpenAI.API do defp decode_body("", _), do: :ok defp decode_body("[DONE]", _), do: :ok defp decode_body(json, cb), do: cb.(JSON.decode!(json)) + + defp decode_chat_chunk("", _cb), do: :ok + defp decode_chat_chunk("[DONE]", _cb), do: :ok + + defp decode_chat_chunk(json, cb) do + case get_in(JSON.decode!(json), ["choices", Access.at(0), "delta", "content"]) do + nil -> :ok + content -> cb.(content) + end + end end diff --git a/lib/open_ai/completion.ex b/lib/open_ai/completion.ex index 842e7795..e18a8434 100644 --- a/lib/open_ai/completion.ex +++ b/lib/open_ai/completion.ex @@ -3,5 +3,7 @@ defmodule OpenAI.Completion do defstruct content: "", temperature: 0.2, role: "user", - model: "gpt-4o-mini" + model: "gpt-4o-mini", + messages: nil, + response_format: nil end diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index fa95a858..ec1595a7 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -1949,6 +1949,7 @@ msgstr "" msgid "Record sets" msgstr "" +#: lib/music_library_web/components/record_chat.ex #: lib/music_library_web/components/release.ex #, elixir-autogen, elixir-format msgid "Retry" @@ -1958,3 +1959,44 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Based on genres, artists, and musical style" msgstr "" + +#: lib/music_library_web/components/record_chat.ex +#, elixir-autogen, elixir-format +msgid "Ask about this album..." +msgstr "" + +#: lib/music_library_web/components/record_chat.ex +#, elixir-autogen, elixir-format +msgid "Ask anything about this album" +msgstr "" + +#: lib/music_library_web/components/record_chat.ex +#, elixir-autogen, elixir-format +msgid "Chat about %{title}" +msgstr "" + +#: lib/music_library_web/live/collection_live/show.html.heex +#: lib/music_library_web/live/wishlist_live/show.html.heex +#, elixir-autogen, elixir-format +msgid "Chat about album" +msgstr "" + +#: lib/music_library_web/components/record_chat.ex +#, elixir-autogen, elixir-format +msgid "Clear chat" +msgstr "" + +#: lib/music_library_web/components/record_chat.ex +#, elixir-autogen, elixir-format +msgid "Send message" +msgstr "" + +#: lib/music_library_web/components/record_chat.ex +#, elixir-autogen, elixir-format +msgid "Thinking..." +msgstr "" + +#: lib/music_library_web/components/record_chat.ex +#, elixir-autogen, elixir-format +msgid "e.g. \"What genre is this?\", \"Tell me about the artists\"" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index f557bf94..7587173c 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -1949,6 +1949,7 @@ msgstr "" msgid "Record sets" msgstr "" +#: lib/music_library_web/components/record_chat.ex #: lib/music_library_web/components/release.ex #, elixir-autogen, elixir-format msgid "Retry" @@ -1958,3 +1959,44 @@ msgstr "" #, elixir-autogen, elixir-format, fuzzy msgid "Based on genres, artists, and musical style" msgstr "" + +#: lib/music_library_web/components/record_chat.ex +#, elixir-autogen, elixir-format +msgid "Ask about this album..." +msgstr "" + +#: lib/music_library_web/components/record_chat.ex +#, elixir-autogen, elixir-format +msgid "Ask anything about this album" +msgstr "" + +#: lib/music_library_web/components/record_chat.ex +#, elixir-autogen, elixir-format +msgid "Chat about %{title}" +msgstr "" + +#: lib/music_library_web/live/collection_live/show.html.heex +#: lib/music_library_web/live/wishlist_live/show.html.heex +#, elixir-autogen, elixir-format +msgid "Chat about album" +msgstr "" + +#: lib/music_library_web/components/record_chat.ex +#, elixir-autogen, elixir-format +msgid "Clear chat" +msgstr "" + +#: lib/music_library_web/components/record_chat.ex +#, elixir-autogen, elixir-format +msgid "Send message" +msgstr "" + +#: lib/music_library_web/components/record_chat.ex +#, elixir-autogen, elixir-format +msgid "Thinking..." +msgstr "" + +#: lib/music_library_web/components/record_chat.ex +#, elixir-autogen, elixir-format +msgid "e.g. \"What genre is this?\", \"Tell me about the artists\"" +msgstr ""