From 60d81e14deb379199a858741a920282c7868c607 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Sun, 22 Feb 2026 15:31:09 +0000 Subject: [PATCH] Enable chat for artists --- ARCHITECTURE.md | 10 +++-- lib/music_library/artist_chat.ex | 44 +++++++++++++++++++ lib/music_library/chat.ex | 7 +++ lib/music_library/record_chat.ex | 5 ++- .../components/{record_chat.ex => chat.ex} | 20 ++++----- .../live/artist_live/show.html.heex | 23 ++++++++++ .../live/collection_live/show.html.heex | 11 +++-- .../live/wishlist_live/show.html.heex | 11 +++-- priv/gettext/default.pot | 38 ++++++++++------ priv/gettext/en/LC_MESSAGES/default.po | 38 ++++++++++------ 10 files changed, 156 insertions(+), 51 deletions(-) create mode 100644 lib/music_library/artist_chat.ex create mode 100644 lib/music_library/chat.ex rename lib/music_library_web/components/{record_chat.ex => chat.ex} (91%) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 2ecd5d8e..52726374 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -117,7 +117,9 @@ Last.fm schemas (separate, not Ecto-persisted to main DB): | `Assets.Image` / `Assets.Transform` | Image processing via Vix (libvips) | | `Colors.ColorFrequencyExtractor` | Color extraction via pixel sampling/histogram | | `Colors.EdgeWeightedExtractor` | Color extraction weighted by Sobel edge detection | -| `RecordChat` | OpenAI-powered streaming chat about a record (web search enabled) | +| `Chat` | Behaviour for streaming AI chat (`stream_response/3` callback) | +| `RecordChat` | Chat implementation for records (OpenAI streaming, web search enabled) | +| `ArtistChat` | Chat implementation for artists (OpenAI streaming, uses Wikipedia/artist context) | | `FormatNumber` | Number formatting utility | --- @@ -240,7 +242,7 @@ All authenticated routes live inside a single `live_session` with three `on_moun | `StatsLive.TopAlbums` | StatsLive.Index | Top albums by period (assign_async) | | `StatsLive.TopArtists` | StatsLive.Index | Top artists by period (assign_async) | | `UniversalSearchLive.Index` | Layout (global) | Cmd+K search modal | -| `RecordChat` | CollectionLive.Show, WishlistLive.Show | AI chat sheet for a record (OpenAI streaming) | +| `Chat` | CollectionLive.Show, WishlistLive.Show, ArtistLive.Show | AI chat sheet (OpenAI streaming, configurable per entity) | ### Shared Component Modules (lib/music_library_web/components/) @@ -257,7 +259,7 @@ All authenticated routes live inside a single `live_session` with three `on_moun | `BarcodeScanner` | Barcode scanning UI (uses barcode-detector JS) | | `Release` | MusicBrainz release display | | `Notes` | Markdown note rendering | -| `RecordChat` | AI record chat sheet with streaming responses | +| `Chat` | AI chat sheet with streaming responses (generic, used for records and artists) | | `Markdown` | Markdown-to-HTML conversion with `[[double bracket]]` link syntax | | `Duration` | Milliseconds to human-readable duration formatting | @@ -288,7 +290,7 @@ All authenticated routes live inside a single `live_session` with three `on_moun | `UniversalSearchNavigation` | External | Keyboard navigation in search modal (via `create-navigation-hook` factory) | | `RecordPickerNavigation` | External | Keyboard navigation in record picker (via `create-navigation-hook` factory) | | `SortableList` | External (`assets/js/hooks/`) | Drag-and-drop reordering of record set items (uses sortablejs) | -| Various `.ColocatedHooks` | Colocated (in .heex) | Inline hooks prefixed with `.` (includes `.ScrollBottom` for RecordChat) | +| Various `.ColocatedHooks` | Colocated (in .heex) | Inline hooks prefixed with `.` (includes `.ScrollBottom` for Chat) | ### JS Event Listeners (app.js) diff --git a/lib/music_library/artist_chat.ex b/lib/music_library/artist_chat.ex new file mode 100644 index 00000000..d24d5efc --- /dev/null +++ b/lib/music_library/artist_chat.ex @@ -0,0 +1,44 @@ +defmodule MusicLibrary.ArtistChat do + @behaviour MusicLibrary.Chat + + alias MusicLibrary.Artists.ArtistInfo + + @impl true + def stream_response(messages, {artist, artist_info}, callback) do + instructions = build_instructions(artist, artist_info) + + OpenAI.chat_stream(messages, on_chunk: callback, instructions: instructions) + end + + defp build_instructions(artist, artist_info) do + context = build_context(artist, artist_info) + + """ + You are a knowledgeable music assistant. Answer questions about the artist \ + the user is currently viewing. Use the provided artist information as your \ + primary reference, and use web search to find additional up-to-date \ + information when helpful. Be concise and accurate. When unsure, say so. + + Artist information: + #{context} + """ + end + + defp build_context(artist, artist_info) do + country = ArtistInfo.country(artist_info) + description = ArtistInfo.wikipedia_description(artist_info) + summary = ArtistInfo.wikipedia_summary(artist_info) + + parts = + [ + {"Name", artist.name}, + {"Country", country.name}, + {"Description", description}, + {"Biography", summary} + ] + |> Enum.reject(fn {_label, value} -> value in [nil, ""] end) + |> Enum.map_join("\n", fn {label, value} -> "#{label}: #{value}" end) + + parts + end +end diff --git a/lib/music_library/chat.ex b/lib/music_library/chat.ex new file mode 100644 index 00000000..f0b3a2c1 --- /dev/null +++ b/lib/music_library/chat.ex @@ -0,0 +1,7 @@ +defmodule MusicLibrary.Chat do + @callback stream_response( + messages :: list(map()), + context :: term(), + callback :: (String.t() -> any()) + ) :: :ok | {:error, term()} +end diff --git a/lib/music_library/record_chat.ex b/lib/music_library/record_chat.ex index 97a01e21..e96ae60f 100644 --- a/lib/music_library/record_chat.ex +++ b/lib/music_library/record_chat.ex @@ -1,7 +1,10 @@ defmodule MusicLibrary.RecordChat do + @behaviour MusicLibrary.Chat + alias MusicLibrary.Records.Record - def stream_response(messages, record, embedding_text, callback) do + @impl true + def stream_response(messages, {record, embedding_text}, callback) do instructions = build_instructions(record, embedding_text) OpenAI.chat_stream(messages, on_chunk: callback, instructions: instructions) diff --git a/lib/music_library_web/components/record_chat.ex b/lib/music_library_web/components/chat.ex similarity index 91% rename from lib/music_library_web/components/record_chat.ex rename to lib/music_library_web/components/chat.ex index 7f8110ae..d8a47020 100644 --- a/lib/music_library_web/components/record_chat.ex +++ b/lib/music_library_web/components/chat.ex @@ -1,9 +1,8 @@ -defmodule MusicLibraryWeb.Components.RecordChat do +defmodule MusicLibraryWeb.Components.Chat do use MusicLibraryWeb, :live_component require Logger - alias MusicLibrary.RecordChat alias MusicLibraryWeb.Markdown def open(id), do: Fluxon.open_dialog(id) @@ -55,7 +54,7 @@ defmodule MusicLibraryWeb.Components.RecordChat do >

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

<.button :if={@messages != []} @@ -82,10 +81,7 @@ defmodule MusicLibraryWeb.Components.RecordChat do 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\"")} -

+

{@empty_prompt}

- case RecordChat.stream_response(messages, record, embedding_text, fn chunk -> + case chat_module.stream_response(messages, chat_context, fn chunk -> Phoenix.LiveView.send_update(parent_pid, __MODULE__, id: component_id, chunk: chunk @@ -232,7 +228,7 @@ defmodule MusicLibraryWeb.Components.RecordChat do ) {:error, reason} -> - Logger.error("RecordChat streaming error: #{reason}") + Logger.error("Chat streaming error: #{reason}") Phoenix.LiveView.send_update(parent_pid, __MODULE__, id: component_id, diff --git a/lib/music_library_web/live/artist_live/show.html.heex b/lib/music_library_web/live/artist_live/show.html.heex index 1d86793a..bddec309 100644 --- a/lib/music_library_web/live/artist_live/show.html.heex +++ b/lib/music_library_web/live/artist_live/show.html.heex @@ -21,6 +21,18 @@ data-slot="icon" /> + <.button + variant="soft" + phx-click={MusicLibraryWeb.Components.Chat.open("artist-chat-sheet")} + > + {gettext("Chat about artist")} + <.icon + name="hero-chat-bubble-left-right" + class="h-5 w-5" + aria-hidden="true" + data-slot="icon" + /> + <.dropdown id={"actions-#{@artist.musicbrainz_id}"} placement="bottom-end"> <:toggle> <.button variant="soft"> @@ -332,6 +344,17 @@ musicbrainz_id={@artist.musicbrainz_id} /> + <.live_component + id="artist-chat" + sheet_id="artist-chat-sheet" + module={MusicLibraryWeb.Components.Chat} + title={@artist.name} + chat_module={MusicLibrary.ArtistChat} + chat_context={{@artist, @artist_info}} + placeholder={gettext("Ask about this artist...")} + empty_prompt={gettext("Ask anything about this artist")} + /> + <.structured_modal :if={@live_action == :edit} id="artist-info-modal" 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 a8253400..4efccc54 100644 --- a/lib/music_library_web/live/collection_live/show.html.heex +++ b/lib/music_library_web/live/collection_live/show.html.heex @@ -28,7 +28,7 @@ <.button variant="soft" - phx-click={MusicLibraryWeb.Components.RecordChat.open("record-chat-sheet")} + phx-click={MusicLibraryWeb.Components.Chat.open("record-chat-sheet")} > {gettext("Chat about album")} <.icon @@ -342,9 +342,12 @@ <.live_component id="record-chat" sheet_id="record-chat-sheet" - module={MusicLibraryWeb.Components.RecordChat} - record={@record} - embedding_text={@embedding_text} + module={MusicLibraryWeb.Components.Chat} + title={@record.title} + chat_module={MusicLibrary.RecordChat} + chat_context={{@record, @embedding_text}} + placeholder={gettext("Ask about this album...")} + empty_prompt={gettext("Ask anything about this album")} /> <.structured_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 900c39cb..bfed8631 100644 --- a/lib/music_library_web/live/wishlist_live/show.html.heex +++ b/lib/music_library_web/live/wishlist_live/show.html.heex @@ -16,7 +16,7 @@ <.button_group> <.button variant="soft" - phx-click={MusicLibraryWeb.Components.RecordChat.open("record-chat-sheet")} + phx-click={MusicLibraryWeb.Components.Chat.open("record-chat-sheet")} > {gettext("Chat about album")} <.icon @@ -307,9 +307,12 @@ <.live_component id="record-chat" sheet_id="record-chat-sheet" - module={MusicLibraryWeb.Components.RecordChat} - record={@record} - embedding_text={@embedding_text} + module={MusicLibraryWeb.Components.Chat} + title={@record.title} + chat_module={MusicLibrary.RecordChat} + chat_context={{@record, @embedding_text}} + placeholder={gettext("Ask about this album...")} + empty_prompt={gettext("Ask anything about this album")} /> <.structured_modal diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index f02c1bd8..1a81c357 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -1951,7 +1951,7 @@ msgstr "" msgid "Record sets" msgstr "" -#: lib/music_library_web/components/record_chat.ex +#: lib/music_library_web/components/chat.ex #: lib/music_library_web/components/release.ex #, elixir-autogen, elixir-format msgid "Retry" @@ -1962,17 +1962,19 @@ msgstr "" msgid "Based on genres, artists, and musical style" msgstr "" -#: lib/music_library_web/components/record_chat.ex +#: 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 "Ask about this album..." msgstr "" -#: lib/music_library_web/components/record_chat.ex +#: 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 "Ask anything about this album" msgstr "" -#: lib/music_library_web/components/record_chat.ex +#: lib/music_library_web/components/chat.ex #, elixir-autogen, elixir-format msgid "Chat about %{title}" msgstr "" @@ -1983,27 +1985,37 @@ msgstr "" msgid "Chat about album" msgstr "" -#: lib/music_library_web/components/record_chat.ex +#: lib/music_library_web/components/chat.ex #, elixir-autogen, elixir-format msgid "Clear chat" msgstr "" -#: lib/music_library_web/components/record_chat.ex +#: lib/music_library_web/components/chat.ex #, elixir-autogen, elixir-format msgid "Send message" msgstr "" -#: lib/music_library_web/components/record_chat.ex +#: lib/music_library_web/components/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 "" - -#: lib/music_library_web/components/record_chat.ex +#: lib/music_library_web/components/chat.ex #, elixir-autogen, elixir-format msgid "Something went wrong. Please try again." msgstr "" + +#: lib/music_library_web/live/artist_live/show.html.heex +#, elixir-autogen, elixir-format +msgid "Ask about this artist..." +msgstr "" + +#: lib/music_library_web/live/artist_live/show.html.heex +#, elixir-autogen, elixir-format +msgid "Ask anything about this artist" +msgstr "" + +#: lib/music_library_web/live/artist_live/show.html.heex +#, elixir-autogen, elixir-format +msgid "Chat about artist" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 1770c3e4..898dcbb6 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -1951,7 +1951,7 @@ msgstr "" msgid "Record sets" msgstr "" -#: lib/music_library_web/components/record_chat.ex +#: lib/music_library_web/components/chat.ex #: lib/music_library_web/components/release.ex #, elixir-autogen, elixir-format msgid "Retry" @@ -1962,17 +1962,19 @@ msgstr "" msgid "Based on genres, artists, and musical style" msgstr "" -#: lib/music_library_web/components/record_chat.ex +#: 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 "Ask about this album..." msgstr "" -#: lib/music_library_web/components/record_chat.ex +#: 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 "Ask anything about this album" msgstr "" -#: lib/music_library_web/components/record_chat.ex +#: lib/music_library_web/components/chat.ex #, elixir-autogen, elixir-format msgid "Chat about %{title}" msgstr "" @@ -1983,27 +1985,37 @@ msgstr "" msgid "Chat about album" msgstr "" -#: lib/music_library_web/components/record_chat.ex +#: lib/music_library_web/components/chat.ex #, elixir-autogen, elixir-format msgid "Clear chat" msgstr "" -#: lib/music_library_web/components/record_chat.ex +#: lib/music_library_web/components/chat.ex #, elixir-autogen, elixir-format msgid "Send message" msgstr "" -#: lib/music_library_web/components/record_chat.ex +#: lib/music_library_web/components/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 "" - -#: lib/music_library_web/components/record_chat.ex +#: lib/music_library_web/components/chat.ex #, elixir-autogen, elixir-format msgid "Something went wrong. Please try again." msgstr "" + +#: lib/music_library_web/live/artist_live/show.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Ask about this artist..." +msgstr "" + +#: lib/music_library_web/live/artist_live/show.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Ask anything about this artist" +msgstr "" + +#: lib/music_library_web/live/artist_live/show.html.heex +#, elixir-autogen, elixir-format, fuzzy +msgid "Chat about artist" +msgstr ""