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\"")}
+
+
+
+
+
+
+
+
+ <.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")}
+
+
+
+
+
+
+
+
+
+
+ """
+ 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 ""