From 8fd8dde73d8a28d826894492f31defa9d452efb3 Mon Sep 17 00:00:00 2001 From: Claudio Ortolina Date: Tue, 17 Mar 2026 13:50:01 +0000 Subject: [PATCH] Persist chat history --- docs/architecture.md | 3 + lib/music_library/chats.ex | 106 +++++ lib/music_library/chats/chat.ex | 31 ++ lib/music_library/chats/message.ex | 29 ++ lib/music_library_web/components/chat.ex | 402 +++++++++++++----- .../live/artist_live/show.ex | 2 + .../live/collection_live/show.ex | 2 + .../live/wishlist_live/show.ex | 2 + priv/gettext/default.pot | 37 +- priv/gettext/en/LC_MESSAGES/default.po | 37 +- .../20260317130441_create_chats.exs | 34 ++ test/music_library/chats_test.exs | 153 +++++++ 12 files changed, 717 insertions(+), 121 deletions(-) create mode 100644 lib/music_library/chats.ex create mode 100644 lib/music_library/chats/chat.ex create mode 100644 lib/music_library/chats/message.ex create mode 100644 priv/repo/migrations/20260317130441_create_chats.exs create mode 100644 test/music_library/chats_test.exs diff --git a/docs/architecture.md b/docs/architecture.md index 9475337b..04c89505 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -80,6 +80,8 @@ write to it directly; insert/update the `records` table instead. | `ScrobbleRules.ScrobbleRule` | `scrobble_rules` | `id` (integer) | type (:album/:artist), match_value, target_musicbrainz_id, enabled | | `OnlineStoreTemplates.OnlineStoreTemplate` | `online_store_templates` | `id` | name, url_template, enabled | | `Secrets.Secret` | `secrets` | `name` (string) | value (encrypted binary) | +| `Chats.Chat` | `chats` | `id` (binary_id) | entity (:record/:artist), musicbrainz_id, topic, has_many :messages | +| `Chats.Message` | `chat_messages` | `id` (binary_id) | role, content, position, belongs_to :chat | Last.fm schemas (separate, not Ecto-persisted to main DB): - `LastFm.Track` — scrobbled tracks stored in Last.fm's own tables via `LastFm.Feed` @@ -97,6 +99,7 @@ Last.fm schemas (separate, not Ecto-persisted to main DB): | `Artists` | ArtistInfo, ArtistRecord | Artist metadata from MusicBrainz/Discogs/Wikipedia/Last.fm, images, search | | `Assets` | Asset | Binary asset storage (covers, artist images), cache tracking | | `Notes` | Note | Free-text notes for records and artists | +| `Chats` | Chat, Message | Persistent AI chat conversations for records and artists | | `RecordSets` | RecordSet, RecordSetItem | User-curated record groupings with ordering | | `ScrobbleRules` | ScrobbleRule | Rules to remap Last.fm scrobble data to correct MusicBrainz IDs; searchable by match_value/target/description, orderable by alphabetical or inserted_at | | `ScrobbleActivity` | — | Scrobbling releases/media/tracks to Last.fm | diff --git a/lib/music_library/chats.ex b/lib/music_library/chats.ex new file mode 100644 index 00000000..2cb3224c --- /dev/null +++ b/lib/music_library/chats.ex @@ -0,0 +1,106 @@ +defmodule MusicLibrary.Chats do + @moduledoc """ + Persistent storage for AI chat conversations about records and artists. + """ + + import Ecto.Query, warn: false + + alias MusicLibrary.Chats.{Chat, Message} + alias MusicLibrary.Repo + + @spec list_chats(atom(), String.t()) :: [Chat.t()] + def list_chats(entity, musicbrainz_id) do + message_count_query = + from(m in Message, + where: m.chat_id == parent_as(:chat).id, + select: count(m.id) + ) + + from(c in Chat, + as: :chat, + where: c.entity == ^entity and c.musicbrainz_id == ^musicbrainz_id, + order_by: [desc: c.updated_at], + select_merge: %{message_count: subquery(message_count_query)} + ) + |> Repo.all() + end + + @spec has_any_chats?(atom(), String.t()) :: boolean() + def has_any_chats?(entity, musicbrainz_id) do + from(c in Chat, + where: c.entity == ^entity and c.musicbrainz_id == ^musicbrainz_id + ) + |> Repo.exists?() + end + + @spec get_chat!(String.t()) :: Chat.t() + def get_chat!(id) do + Chat + |> Repo.get!(id) + |> Repo.preload(:messages) + end + + @spec create_chat_with_message(map(), map()) :: + {:ok, Chat.t()} | {:error, Ecto.Changeset.t()} + def create_chat_with_message(chat_attrs, message_attrs) do + topic = + message_attrs + |> Map.get(:content, "") + |> String.trim() + |> String.slice(0, 80) + + Repo.transaction(fn -> + chat_attrs = Map.put(chat_attrs, :topic, topic) + + with {:ok, chat} <- %Chat{} |> Chat.changeset(chat_attrs) |> Repo.insert(), + message_attrs = Map.merge(message_attrs, %{position: 0}), + {:ok, _message} <- + %Message{chat_id: chat.id} + |> Message.changeset(message_attrs) + |> Repo.insert() do + Repo.preload(chat, :messages) + else + {:error, changeset} -> Repo.rollback(changeset) + end + end) + end + + @spec add_message(Chat.t(), map()) :: {:ok, Message.t()} | {:error, Ecto.Changeset.t()} + def add_message(%Chat{} = chat, attrs) do + next_position = + from(m in Message, + where: m.chat_id == ^chat.id, + select: coalesce(max(m.position), -1) + ) + |> Repo.one!() + |> Kernel.+(1) + + attrs = Map.put(attrs, :position, next_position) + + result = + %Message{chat_id: chat.id} + |> Message.changeset(attrs) + |> Repo.insert() + + case result do + {:ok, message} -> + touch_chat(chat) + {:ok, message} + + error -> + error + end + end + + @spec delete_chat(Chat.t()) :: {:ok, Chat.t()} | {:error, Ecto.Changeset.t()} + def delete_chat(%Chat{} = chat) do + Repo.delete(chat) + end + + defp touch_chat(chat) do + now = DateTime.truncate(DateTime.utc_now(), :second) + + from(c in Chat, where: c.id == ^chat.id) + |> Repo.update_all(set: [updated_at: now]) + end +end diff --git a/lib/music_library/chats/chat.ex b/lib/music_library/chats/chat.ex new file mode 100644 index 00000000..614b0b43 --- /dev/null +++ b/lib/music_library/chats/chat.ex @@ -0,0 +1,31 @@ +defmodule MusicLibrary.Chats.Chat do + use Ecto.Schema + + import Ecto.Changeset + + alias MusicLibrary.Chats.Message + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "chats" do + field :entity, Ecto.Enum, values: [:record, :artist] + field :musicbrainz_id, Ecto.UUID + field :topic, :string + + field :message_count, :integer, virtual: true, default: 0 + + has_many :messages, Message, preload_order: [asc: :position] + + timestamps(type: :utc_datetime) + end + + @type t :: %__MODULE__{} + + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() + def changeset(chat, attrs) do + chat + |> cast(attrs, [:entity, :musicbrainz_id, :topic]) + |> validate_required([:entity, :musicbrainz_id]) + |> validate_length(:topic, max: 200) + end +end diff --git a/lib/music_library/chats/message.ex b/lib/music_library/chats/message.ex new file mode 100644 index 00000000..5995cb96 --- /dev/null +++ b/lib/music_library/chats/message.ex @@ -0,0 +1,29 @@ +defmodule MusicLibrary.Chats.Message do + use Ecto.Schema + + import Ecto.Changeset + + alias MusicLibrary.Chats.Chat + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "chat_messages" do + field :role, :string + field :content, :string + field :position, :integer + + belongs_to :chat, Chat + + timestamps(type: :utc_datetime) + end + + @type t :: %__MODULE__{} + + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() + def changeset(message, attrs) do + message + |> cast(attrs, [:role, :content, :position]) + |> validate_required([:role, :content, :position]) + |> validate_inclusion(:role, ["user", "assistant"]) + end +end diff --git a/lib/music_library_web/components/chat.ex b/lib/music_library_web/components/chat.ex index 06004d4c..6aa4662c 100644 --- a/lib/music_library_web/components/chat.ex +++ b/lib/music_library_web/components/chat.ex @@ -3,6 +3,7 @@ defmodule MusicLibraryWeb.Components.Chat do require Logger + alias MusicLibrary.Chats alias MusicLibraryWeb.Markdown def open(id), do: Fluxon.open_dialog(id) @@ -14,7 +15,11 @@ defmodule MusicLibraryWeb.Components.Chat do |> assign(:messages, []) |> assign(:current_response, "") |> assign(:loading, false) - |> assign(:error, nil)} + |> assign(:error, nil) + |> assign(:view, :active) + |> assign(:chat, nil) + |> assign(:chats, []) + |> assign(:has_history, false)} end @impl true @@ -25,6 +30,8 @@ defmodule MusicLibraryWeb.Components.Chat do 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])) @@ -40,7 +47,16 @@ defmodule MusicLibraryWeb.Components.Chat do end def update(assigns, socket) do - {:ok, assign(socket, assigns)} + socket = assign(socket, assigns) + + socket = + if changed?(socket, :entity) or changed?(socket, :musicbrainz_id) do + assign(socket, :has_history, check_chat_history(socket.assigns)) + else + socket + end + + {:ok, socket} end @impl true @@ -52,113 +68,11 @@ defmodule MusicLibraryWeb.Components.Chat do placement="right" class="w-md sm:min-w-lg lg:min-w-2xl flex flex-col h-full" > -
-

- {gettext("Chat about %{title}", title: @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" - /> -

{@empty_prompt}

-
- -
-

{message.content}

-
- {raw(Markdown.to_html(message.content))} -
-
- -
-
- {raw(Markdown.to_html(@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={@placeholder} - class="flex-1" - disabled={@loading} - autocomplete="off" - autofocus - /> - <.button - type="submit" - variant="solid" - size="icon" - disabled={@loading} - aria-label={gettext("Send message")} - > - <.icon name="hero-paper-airplane" class="icon" /> - -
-
+ <%= if @view == :list do %> + {render_list_view(assigns)} + <% else %> + {render_active_view(assigns)} + <% end %>