|
|
|
@@ -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"
|
|
|
|
|
>
|
|
|
|
|
<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={@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>
|
|
|
|
|
<%= if @view == :list do %>
|
|
|
|
|
{render_list_view(assigns)}
|
|
|
|
|
<% else %>
|
|
|
|
|
{render_active_view(assigns)}
|
|
|
|
|
<% end %>
|
|
|
|
|
</.sheet>
|
|
|
|
|
<script :type={Phoenix.LiveView.ColocatedHook} name=".ScrollBottom">
|
|
|
|
|
export default {
|
|
|
|
@@ -182,18 +96,242 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|
|
|
|
"""
|
|
|
|
|
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
|
|
|
|
|
def handle_event("send_message", %{"message" => ""}, socket), do: {:noreply, socket}
|
|
|
|
|
|
|
|
|
|
def handle_event("send_message", %{"message" => text}, socket),
|
|
|
|
|
do: do_send_message(socket, text)
|
|
|
|
|
|
|
|
|
|
def handle_event("clear_chat", _params, socket) do
|
|
|
|
|
def handle_event("new_chat", _params, socket) do
|
|
|
|
|
{:noreply,
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:messages, [])
|
|
|
|
|
|> 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
|
|
|
|
|
|
|
|
|
|
def handle_event("retry", _params, socket) do
|
|
|
|
@@ -217,6 +355,8 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|
|
|
|
chat_module = socket.assigns.chat_module
|
|
|
|
|
chat_context = socket.assigns.chat_context
|
|
|
|
|
|
|
|
|
|
chat = persist_user_message(socket, user_message)
|
|
|
|
|
|
|
|
|
|
Task.Supervisor.start_child(MusicLibrary.TaskSupervisor, fn ->
|
|
|
|
|
case chat_module.stream_response(messages, chat_context, fn chunk ->
|
|
|
|
|
Phoenix.LiveView.send_update(parent_pid, __MODULE__,
|
|
|
|
@@ -243,11 +383,51 @@ defmodule MusicLibraryWeb.Components.Chat do
|
|
|
|
|
{:noreply,
|
|
|
|
|
socket
|
|
|
|
|
|> assign(:messages, messages)
|
|
|
|
|
|> assign(:chat, chat)
|
|
|
|
|
|> assign(:has_history, true)
|
|
|
|
|
|> assign(:loading, true)
|
|
|
|
|
|> assign(:current_response, "")
|
|
|
|
|
|> assign(:error, nil)}
|
|
|
|
|
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
|
|
|
|
|
"ml-auto bg-blue-500 dark:bg-blue-600 text-white"
|
|
|
|
|
end
|
|
|
|
|