Persist chat history
This commit is contained in:
@@ -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 |
|
| `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 |
|
| `OnlineStoreTemplates.OnlineStoreTemplate` | `online_store_templates` | `id` | name, url_template, enabled |
|
||||||
| `Secrets.Secret` | `secrets` | `name` (string) | value (encrypted binary) |
|
| `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):
|
Last.fm schemas (separate, not Ecto-persisted to main DB):
|
||||||
- `LastFm.Track` — scrobbled tracks stored in Last.fm's own tables via `LastFm.Feed`
|
- `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 |
|
| `Artists` | ArtistInfo, ArtistRecord | Artist metadata from MusicBrainz/Discogs/Wikipedia/Last.fm, images, search |
|
||||||
| `Assets` | Asset | Binary asset storage (covers, artist images), cache tracking |
|
| `Assets` | Asset | Binary asset storage (covers, artist images), cache tracking |
|
||||||
| `Notes` | Note | Free-text notes for records and artists |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `ScrobbleActivity` | — | Scrobbling releases/media/tracks to Last.fm |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -3,6 +3,7 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
alias MusicLibrary.Chats
|
||||||
alias MusicLibraryWeb.Markdown
|
alias MusicLibraryWeb.Markdown
|
||||||
|
|
||||||
def open(id), do: Fluxon.open_dialog(id)
|
def open(id), do: Fluxon.open_dialog(id)
|
||||||
@@ -14,7 +15,11 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
|> assign(:messages, [])
|
|> assign(:messages, [])
|
||||||
|> assign(:current_response, "")
|
|> assign(:current_response, "")
|
||||||
|> assign(:loading, false)
|
|> assign(:loading, false)
|
||||||
|> assign(:error, nil)}
|
|> assign(:error, nil)
|
||||||
|
|> assign(:view, :active)
|
||||||
|
|> assign(:chat, nil)
|
||||||
|
|> assign(:chats, [])
|
||||||
|
|> assign(:has_history, false)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@@ -25,6 +30,8 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
def update(%{done: true}, socket) do
|
def update(%{done: true}, socket) do
|
||||||
completed_message = %{role: "assistant", content: socket.assigns.current_response}
|
completed_message = %{role: "assistant", content: socket.assigns.current_response}
|
||||||
|
|
||||||
|
save_assistant_message(socket.assigns.chat, completed_message.content)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> update(:messages, &(&1 ++ [completed_message]))
|
|> update(:messages, &(&1 ++ [completed_message]))
|
||||||
@@ -40,7 +47,16 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def update(assigns, socket) do
|
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
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@@ -52,113 +68,11 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
placement="right"
|
placement="right"
|
||||||
class="w-md sm:min-w-lg lg:min-w-2xl flex flex-col h-full"
|
class="w-md sm:min-w-lg lg:min-w-2xl flex flex-col h-full"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 pb-4 border-b border-zinc-200 dark:border-zinc-700">
|
<%= if @view == :list do %>
|
||||||
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
{render_list_view(assigns)}
|
||||||
{gettext("Chat about %{title}", title: @title)}
|
<% else %>
|
||||||
</h2>
|
{render_active_view(assigns)}
|
||||||
<.button
|
<% end %>
|
||||||
: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" />
|
|
||||||
</.button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
id={"#{@id}-messages"}
|
|
||||||
class="flex-1 overflow-y-auto py-4 space-y-4"
|
|
||||||
phx-hook=".ScrollBottom"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
:if={@messages == [] and @current_response == ""}
|
|
||||||
class="flex flex-col items-center justify-center h-full text-center text-zinc-500 dark:text-zinc-400"
|
|
||||||
>
|
|
||||||
<.icon
|
|
||||||
name="hero-chat-bubble-left-right"
|
|
||||||
class="size-12 mb-4 text-zinc-300 dark:text-zinc-600"
|
|
||||||
/>
|
|
||||||
<p class="text-sm font-medium">{@empty_prompt}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
:for={message <- @messages}
|
|
||||||
class={[
|
|
||||||
"max-w-[85%] rounded-lg px-4 py-2 text-sm",
|
|
||||||
message_classes(message.role)
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<p :if={message.role == "user"} class="whitespace-pre-wrap">{message.content}</p>
|
|
||||||
<div :if={message.role == "assistant"} class="prose prose-sm dark:prose-invert">
|
|
||||||
{raw(Markdown.to_html(message.content))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
:if={@current_response != ""}
|
|
||||||
class="max-w-[85%] rounded-lg px-4 py-2 text-sm bg-zinc-100 dark:bg-zinc-700 text-zinc-900 dark:text-zinc-100"
|
|
||||||
>
|
|
||||||
<div class="prose prose-sm dark:prose-invert">
|
|
||||||
{raw(Markdown.to_html(@current_response))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
:if={@loading and @current_response == ""}
|
|
||||||
class="flex items-center gap-2 text-zinc-500 dark:text-zinc-400"
|
|
||||||
>
|
|
||||||
<.loading class="size-4" />
|
|
||||||
<span class="text-sm">{gettext("Thinking...")}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
:if={@error}
|
|
||||||
class="max-w-[85%] rounded-lg px-4 py-2 text-sm bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300"
|
|
||||||
>
|
|
||||||
<p>{@error}</p>
|
|
||||||
<.button
|
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
color="danger"
|
|
||||||
phx-click="retry"
|
|
||||||
phx-target={@myself}
|
|
||||||
class="mt-2"
|
|
||||||
>
|
|
||||||
{gettext("Retry")}
|
|
||||||
</.button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
|
||||||
<form
|
|
||||||
id={"#{@id}-form"}
|
|
||||||
phx-submit="send_message"
|
|
||||||
phx-target={@myself}
|
|
||||||
class="flex gap-2"
|
|
||||||
>
|
|
||||||
<.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" />
|
|
||||||
</.button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</.sheet>
|
</.sheet>
|
||||||
<script :type={Phoenix.LiveView.ColocatedHook} name=".ScrollBottom">
|
<script :type={Phoenix.LiveView.ColocatedHook} name=".ScrollBottom">
|
||||||
export default {
|
export default {
|
||||||
@@ -182,18 +96,242 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp render_list_view(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="flex items-center justify-between pb-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||||
|
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{gettext("Chat history")}
|
||||||
|
</h2>
|
||||||
|
<.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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto py-4">
|
||||||
|
<div
|
||||||
|
:if={@chats == []}
|
||||||
|
class="flex flex-col items-center justify-center h-full text-center text-zinc-500 dark:text-zinc-400"
|
||||||
|
>
|
||||||
|
<.icon
|
||||||
|
name="hero-chat-bubble-left-right"
|
||||||
|
class="size-12 mb-4 text-zinc-300 dark:text-zinc-600"
|
||||||
|
/>
|
||||||
|
<p class="text-sm font-medium">{gettext("No previous chats")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :for={chat <- @chats} class="group relative">
|
||||||
|
<button
|
||||||
|
phx-click="select_chat"
|
||||||
|
phx-value-id={chat.id}
|
||||||
|
phx-target={@myself}
|
||||||
|
class="w-full text-left px-3 py-3 rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-colors"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium text-zinc-900 dark:text-zinc-100 truncate">
|
||||||
|
{chat.topic || gettext("Untitled")}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-zinc-500 dark:text-zinc-400 mt-1">
|
||||||
|
{ngettext("%{count} message", "%{count} messages", chat.message_count)} · {Calendar.strftime(
|
||||||
|
chat.updated_at,
|
||||||
|
"%b %-d, %Y"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
<.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 group-hover:opacity-100 transition-opacity"
|
||||||
|
>
|
||||||
|
<.icon name="hero-trash" class="icon" />
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp render_active_view(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div class="flex items-center gap-2 pb-4 border-b border-zinc-200 dark:border-zinc-700">
|
||||||
|
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||||
|
{gettext("Chat about %{title}", title: @title)}
|
||||||
|
</h2>
|
||||||
|
<.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>
|
||||||
|
<.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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id={"#{@id}-messages"}
|
||||||
|
class="flex-1 overflow-y-auto py-4 space-y-4"
|
||||||
|
phx-hook=".ScrollBottom"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:if={@messages == [] and @current_response == ""}
|
||||||
|
class="flex flex-col items-center justify-center h-full text-center text-zinc-500 dark:text-zinc-400"
|
||||||
|
>
|
||||||
|
<.icon
|
||||||
|
name="hero-chat-bubble-left-right"
|
||||||
|
class="size-12 mb-4 text-zinc-300 dark:text-zinc-600"
|
||||||
|
/>
|
||||||
|
<p class="text-sm font-medium">{@empty_prompt}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:for={message <- @messages}
|
||||||
|
class={[
|
||||||
|
"max-w-[85%] rounded-lg px-4 py-2 text-sm",
|
||||||
|
message_classes(message.role)
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<p :if={message.role == "user"} class="whitespace-pre-wrap">{message.content}</p>
|
||||||
|
<div :if={message.role == "assistant"} class="prose prose-sm dark:prose-invert">
|
||||||
|
{raw(Markdown.to_html(message.content))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:if={@current_response != ""}
|
||||||
|
class="max-w-[85%] rounded-lg px-4 py-2 text-sm bg-zinc-100 dark:bg-zinc-700 text-zinc-900 dark:text-zinc-100"
|
||||||
|
>
|
||||||
|
<div class="prose prose-sm dark:prose-invert">
|
||||||
|
{raw(Markdown.to_html(@current_response))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:if={@loading and @current_response == ""}
|
||||||
|
class="flex items-center gap-2 text-zinc-500 dark:text-zinc-400"
|
||||||
|
>
|
||||||
|
<.loading class="size-4" />
|
||||||
|
<span class="text-sm">{gettext("Thinking...")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
:if={@error}
|
||||||
|
class="max-w-[85%] rounded-lg px-4 py-2 text-sm bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300"
|
||||||
|
>
|
||||||
|
<p>{@error}</p>
|
||||||
|
<.button
|
||||||
|
size="xs"
|
||||||
|
variant="ghost"
|
||||||
|
color="danger"
|
||||||
|
phx-click="retry"
|
||||||
|
phx-target={@myself}
|
||||||
|
class="mt-2"
|
||||||
|
>
|
||||||
|
{gettext("Retry")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-4 border-t border-zinc-200 dark:border-zinc-700">
|
||||||
|
<form
|
||||||
|
id={"#{@id}-form"}
|
||||||
|
phx-submit="send_message"
|
||||||
|
phx-target={@myself}
|
||||||
|
class="flex gap-2"
|
||||||
|
>
|
||||||
|
<.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" />
|
||||||
|
</.button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("send_message", %{"message" => ""}, socket), do: {:noreply, socket}
|
def handle_event("send_message", %{"message" => ""}, socket), do: {:noreply, socket}
|
||||||
|
|
||||||
def handle_event("send_message", %{"message" => text}, socket),
|
def handle_event("send_message", %{"message" => text}, socket),
|
||||||
do: do_send_message(socket, text)
|
do: do_send_message(socket, text)
|
||||||
|
|
||||||
def handle_event("clear_chat", _params, socket) do
|
def handle_event("new_chat", _params, socket) do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:messages, [])
|
|> assign(:messages, [])
|
||||||
|> assign(:current_response, "")
|
|> assign(:current_response, "")
|
||||||
|> assign(:error, 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)
|
||||||
|
messages = Enum.map(chat.messages, &%{role: &1.role, content: &1.content})
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:chat, chat)
|
||||||
|
|> assign(:messages, messages)
|
||||||
|
|> assign(:current_response, "")
|
||||||
|
|> 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)
|
||||||
|
|
||||||
|
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(:error, nil)
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :chats, chats)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("retry", _params, socket) do
|
def handle_event("retry", _params, socket) do
|
||||||
@@ -217,6 +355,8 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
chat_module = socket.assigns.chat_module
|
chat_module = socket.assigns.chat_module
|
||||||
chat_context = socket.assigns.chat_context
|
chat_context = socket.assigns.chat_context
|
||||||
|
|
||||||
|
chat = persist_user_message(socket, user_message)
|
||||||
|
|
||||||
Task.Supervisor.start_child(MusicLibrary.TaskSupervisor, fn ->
|
Task.Supervisor.start_child(MusicLibrary.TaskSupervisor, fn ->
|
||||||
case chat_module.stream_response(messages, chat_context, fn chunk ->
|
case chat_module.stream_response(messages, chat_context, fn chunk ->
|
||||||
Phoenix.LiveView.send_update(parent_pid, __MODULE__,
|
Phoenix.LiveView.send_update(parent_pid, __MODULE__,
|
||||||
@@ -243,11 +383,51 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|||||||
{:noreply,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> assign(:messages, messages)
|
|> assign(:messages, messages)
|
||||||
|
|> assign(:chat, chat)
|
||||||
|
|> assign(:has_history, true)
|
||||||
|> assign(:loading, true)
|
|> assign(:loading, true)
|
||||||
|> assign(:current_response, "")
|
|> assign(:current_response, "")
|
||||||
|> assign(:error, nil)}
|
|> assign(:error, nil)}
|
||||||
end
|
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}
|
||||||
|
)
|
||||||
|
|
||||||
|
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 check_chat_history(%{entity: entity, musicbrainz_id: musicbrainz_id})
|
||||||
|
when not is_nil(entity) and not is_nil(musicbrainz_id) do
|
||||||
|
Chats.has_any_chats?(entity, musicbrainz_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp check_chat_history(_assigns), do: false
|
||||||
|
|
||||||
defp message_classes("user") do
|
defp message_classes("user") do
|
||||||
"ml-auto bg-blue-500 dark:bg-blue-600 text-white"
|
"ml-auto bg-blue-500 dark:bg-blue-600 text-white"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -494,6 +494,8 @@ defmodule MusicLibraryWeb.ArtistLive.Show do
|
|||||||
sheet_id="artist-chat-sheet"
|
sheet_id="artist-chat-sheet"
|
||||||
module={MusicLibraryWeb.Components.Chat}
|
module={MusicLibraryWeb.Components.Chat}
|
||||||
title={@artist.name}
|
title={@artist.name}
|
||||||
|
entity={:artist}
|
||||||
|
musicbrainz_id={@artist.musicbrainz_id}
|
||||||
chat_module={MusicLibrary.ArtistChat}
|
chat_module={MusicLibrary.ArtistChat}
|
||||||
chat_context={{@artist, @artist_info}}
|
chat_context={{@artist, @artist_info}}
|
||||||
placeholder={gettext("Ask about this artist...")}
|
placeholder={gettext("Ask about this artist...")}
|
||||||
|
|||||||
@@ -306,6 +306,8 @@ defmodule MusicLibraryWeb.CollectionLive.Show do
|
|||||||
sheet_id="record-chat-sheet"
|
sheet_id="record-chat-sheet"
|
||||||
module={MusicLibraryWeb.Components.Chat}
|
module={MusicLibraryWeb.Components.Chat}
|
||||||
title={@record.title}
|
title={@record.title}
|
||||||
|
entity={:record}
|
||||||
|
musicbrainz_id={@record.musicbrainz_id}
|
||||||
chat_module={MusicLibrary.RecordChat}
|
chat_module={MusicLibrary.RecordChat}
|
||||||
chat_context={{@record, @embedding_text}}
|
chat_context={{@record, @embedding_text}}
|
||||||
placeholder={gettext("Ask about this album...")}
|
placeholder={gettext("Ask about this album...")}
|
||||||
|
|||||||
@@ -251,6 +251,8 @@ defmodule MusicLibraryWeb.WishlistLive.Show do
|
|||||||
sheet_id="record-chat-sheet"
|
sheet_id="record-chat-sheet"
|
||||||
module={MusicLibraryWeb.Components.Chat}
|
module={MusicLibraryWeb.Components.Chat}
|
||||||
title={@record.title}
|
title={@record.title}
|
||||||
|
entity={:record}
|
||||||
|
musicbrainz_id={@record.musicbrainz_id}
|
||||||
chat_module={MusicLibrary.RecordChat}
|
chat_module={MusicLibrary.RecordChat}
|
||||||
chat_context={{@record, @embedding_text}}
|
chat_context={{@record, @embedding_text}}
|
||||||
placeholder={gettext("Ask about this album...")}
|
placeholder={gettext("Ask about this album...")}
|
||||||
|
|||||||
@@ -1872,11 +1872,6 @@ msgstr ""
|
|||||||
msgid "Chat about album"
|
msgid "Chat about album"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/music_library_web/components/chat.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Clear chat"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/music_library_web/components/chat.ex
|
#: lib/music_library_web/components/chat.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Send message"
|
msgid "Send message"
|
||||||
@@ -2308,3 +2303,35 @@ msgstr ""
|
|||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No online store templates found"
|
msgid "No online store templates found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/components/chat.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} message"
|
||||||
|
msgid_plural "%{count} messages"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/components/chat.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Chat history"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/components/chat.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Delete chat"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/components/chat.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "New chat"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/components/chat.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No previous chats"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/components/chat.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Untitled"
|
||||||
|
msgstr ""
|
||||||
|
|||||||
@@ -1872,11 +1872,6 @@ msgstr ""
|
|||||||
msgid "Chat about album"
|
msgid "Chat about album"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/music_library_web/components/chat.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Clear chat"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/music_library_web/components/chat.ex
|
#: lib/music_library_web/components/chat.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Send message"
|
msgid "Send message"
|
||||||
@@ -2308,3 +2303,35 @@ msgstr ""
|
|||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "No online store templates found"
|
msgid "No online store templates found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/components/chat.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "%{count} message"
|
||||||
|
msgid_plural "%{count} messages"
|
||||||
|
msgstr[0] ""
|
||||||
|
msgstr[1] ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/components/chat.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Chat history"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/components/chat.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Delete chat"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/components/chat.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "New chat"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/components/chat.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "No previous chats"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/music_library_web/components/chat.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Untitled"
|
||||||
|
msgstr ""
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
defmodule MusicLibrary.Repo.Migrations.CreateChats do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:chats, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
add :entity, :string, null: false
|
||||||
|
add :musicbrainz_id, :uuid, null: false
|
||||||
|
add :topic, :string
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Listing chats for a specific entity
|
||||||
|
create index(:chats, [:entity, :musicbrainz_id])
|
||||||
|
|
||||||
|
# Ordered listing of chats for a specific entity
|
||||||
|
create index(:chats, [:entity, :musicbrainz_id, :updated_at])
|
||||||
|
|
||||||
|
create table(:chat_messages, primary_key: false) do
|
||||||
|
add :id, :binary_id, primary_key: true
|
||||||
|
add :role, :string, null: false
|
||||||
|
add :content, :text, null: false
|
||||||
|
add :position, :integer, null: false
|
||||||
|
|
||||||
|
add :chat_id, references(:chats, type: :binary_id, on_delete: :delete_all), null: false
|
||||||
|
|
||||||
|
timestamps(type: :utc_datetime)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Ordered listing of messages within a chat
|
||||||
|
create index(:chat_messages, [:chat_id, :position])
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
defmodule MusicLibrary.ChatsTest do
|
||||||
|
use MusicLibrary.DataCase
|
||||||
|
|
||||||
|
alias MusicLibrary.Chats
|
||||||
|
alias MusicLibrary.Chats.Chat
|
||||||
|
|
||||||
|
@musicbrainz_id Ecto.UUID.generate()
|
||||||
|
|
||||||
|
defp create_chat(_context) do
|
||||||
|
{:ok, chat} =
|
||||||
|
Chats.create_chat_with_message(
|
||||||
|
%{entity: :record, musicbrainz_id: @musicbrainz_id},
|
||||||
|
%{role: "user", content: "Tell me about this album"}
|
||||||
|
)
|
||||||
|
|
||||||
|
%{chat: chat}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "list_chats/2" do
|
||||||
|
setup [:create_chat]
|
||||||
|
|
||||||
|
test "returns empty list when no chats exist" do
|
||||||
|
assert Chats.list_chats(:record, Ecto.UUID.generate()) == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns chats for entity with message count", %{chat: chat} do
|
||||||
|
chats = Chats.list_chats(:record, @musicbrainz_id)
|
||||||
|
|
||||||
|
assert [listed_chat] = chats
|
||||||
|
assert listed_chat.id == chat.id
|
||||||
|
assert listed_chat.message_count == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "does not return chats for different entity", %{chat: _chat} do
|
||||||
|
assert Chats.list_chats(:artist, @musicbrainz_id) == []
|
||||||
|
end
|
||||||
|
|
||||||
|
test "orders by updated_at desc", %{chat: first_chat} do
|
||||||
|
{:ok, second_chat} =
|
||||||
|
Chats.create_chat_with_message(
|
||||||
|
%{entity: :record, musicbrainz_id: @musicbrainz_id},
|
||||||
|
%{role: "user", content: "Another question"}
|
||||||
|
)
|
||||||
|
|
||||||
|
chats = Chats.list_chats(:record, @musicbrainz_id)
|
||||||
|
assert [%{id: id1}, %{id: id2}] = chats
|
||||||
|
assert id1 == second_chat.id
|
||||||
|
assert id2 == first_chat.id
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_chat!/1" do
|
||||||
|
setup [:create_chat]
|
||||||
|
|
||||||
|
test "returns chat with preloaded messages", %{chat: chat} do
|
||||||
|
found = Chats.get_chat!(chat.id)
|
||||||
|
|
||||||
|
assert found.id == chat.id
|
||||||
|
assert found.entity == :record
|
||||||
|
assert [message] = found.messages
|
||||||
|
assert message.role == "user"
|
||||||
|
assert message.content == "Tell me about this album"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "raises for nonexistent chat" do
|
||||||
|
assert_raise Ecto.NoResultsError, fn ->
|
||||||
|
Chats.get_chat!(Ecto.UUID.generate())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "create_chat_with_message/2" do
|
||||||
|
test "creates chat with first message" do
|
||||||
|
assert {:ok, chat} =
|
||||||
|
Chats.create_chat_with_message(
|
||||||
|
%{entity: :artist, musicbrainz_id: @musicbrainz_id},
|
||||||
|
%{role: "user", content: "Who is this artist?"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert chat.entity == :artist
|
||||||
|
assert chat.musicbrainz_id == @musicbrainz_id
|
||||||
|
assert chat.topic == "Who is this artist?"
|
||||||
|
assert [message] = chat.messages
|
||||||
|
assert message.role == "user"
|
||||||
|
assert message.position == 0
|
||||||
|
end
|
||||||
|
|
||||||
|
test "truncates topic to 80 characters" do
|
||||||
|
long_message = String.duplicate("a", 200)
|
||||||
|
|
||||||
|
assert {:ok, chat} =
|
||||||
|
Chats.create_chat_with_message(
|
||||||
|
%{entity: :record, musicbrainz_id: @musicbrainz_id},
|
||||||
|
%{role: "user", content: long_message}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert String.length(chat.topic) == 80
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns error for invalid chat attrs" do
|
||||||
|
assert {:error, _changeset} =
|
||||||
|
Chats.create_chat_with_message(
|
||||||
|
%{},
|
||||||
|
%{role: "user", content: "Hello"}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "add_message/2" do
|
||||||
|
setup [:create_chat]
|
||||||
|
|
||||||
|
test "adds message with auto-computed position", %{chat: chat} do
|
||||||
|
assert {:ok, message} =
|
||||||
|
Chats.add_message(chat, %{role: "assistant", content: "Here's what I know..."})
|
||||||
|
|
||||||
|
assert message.role == "assistant"
|
||||||
|
assert message.position == 1
|
||||||
|
assert message.chat_id == chat.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "increments position for each message", %{chat: chat} do
|
||||||
|
{:ok, _} = Chats.add_message(chat, %{role: "assistant", content: "Response 1"})
|
||||||
|
{:ok, msg} = Chats.add_message(chat, %{role: "user", content: "Follow up"})
|
||||||
|
|
||||||
|
assert msg.position == 2
|
||||||
|
end
|
||||||
|
|
||||||
|
test "touches chat updated_at", %{chat: chat} do
|
||||||
|
# Force a different timestamp by manually setting the original earlier
|
||||||
|
Repo.update_all(
|
||||||
|
from(c in Chat, where: c.id == ^chat.id),
|
||||||
|
set: [updated_at: ~U[2025-01-01 00:00:00Z]]
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, _} = Chats.add_message(chat, %{role: "assistant", content: "Response"})
|
||||||
|
|
||||||
|
updated_chat = Chats.get_chat!(chat.id)
|
||||||
|
assert DateTime.compare(updated_chat.updated_at, ~U[2025-01-01 00:00:00Z]) == :gt
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "delete_chat/1" do
|
||||||
|
setup [:create_chat]
|
||||||
|
|
||||||
|
test "deletes chat and its messages", %{chat: chat} do
|
||||||
|
assert {:ok, _} = Chats.delete_chat(chat)
|
||||||
|
|
||||||
|
assert_raise Ecto.NoResultsError, fn ->
|
||||||
|
Chats.get_chat!(chat.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user