Add record chat sheet with OpenAI streaming
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
@@ -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"""
|
||||
<div>
|
||||
<.sheet
|
||||
id={@sheet_id}
|
||||
placement="right"
|
||||
class="w-md sm:min-w-lg lg:min-w-2xl flex flex-col h-full"
|
||||
>
|
||||
<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 about %{title}", title: @record.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">{gettext("Ask anything about this album")}</p>
|
||||
<p class="text-xs mt-1">
|
||||
{gettext("e.g. \"What genre is this?\", \"Tell me about the artists\"")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:for={message <- @messages}
|
||||
class={[
|
||||
"max-w-[85%] rounded-lg px-4 py-2 text-sm",
|
||||
message_classes(message.role)
|
||||
]}
|
||||
>
|
||||
<p class="whitespace-pre-wrap">{message.content}</p>
|
||||
</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"
|
||||
>
|
||||
<p class="whitespace-pre-wrap">{@current_response}</p>
|
||||
</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={gettext("Ask about this album...")}
|
||||
class="flex-1"
|
||||
disabled={@loading}
|
||||
autocomplete="off"
|
||||
/>
|
||||
<.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>
|
||||
<script :type={Phoenix.LiveView.ColocatedHook} name=".ScrollBottom">
|
||||
export default {
|
||||
mounted() {
|
||||
this.scrollToBottom();
|
||||
this.observer = new MutationObserver(() => this.scrollToBottom());
|
||||
this.observer.observe(this.el, { childList: true, subtree: true, characterData: true });
|
||||
},
|
||||
updated() {
|
||||
this.scrollToBottom();
|
||||
},
|
||||
destroyed() {
|
||||
if (this.observer) this.observer.disconnect();
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.el.scrollTop = this.el.scrollHeight;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</div>
|
||||
"""
|
||||
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
|
||||
@@ -26,6 +26,18 @@
|
||||
data-slot="icon"
|
||||
/>
|
||||
</.button>
|
||||
<.button
|
||||
variant="soft"
|
||||
phx-click={MusicLibraryWeb.Components.RecordChat.open("record-chat-sheet")}
|
||||
>
|
||||
<span class="sr-only">{gettext("Chat about album")}</span>
|
||||
<.icon
|
||||
name="hero-chat-bubble-left-right"
|
||||
class="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
data-slot="icon"
|
||||
/>
|
||||
</.button>
|
||||
<.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"
|
||||
|
||||
@@ -12,131 +12,147 @@
|
||||
<h1 class="text-base font-medium leading-6 text-zinc-700">
|
||||
<.artist_links joinphrase_class="text-sm" artists={@record.artists} />
|
||||
</h1>
|
||||
<.dropdown id={"actions-#{@record.id}"} placement="bottom-end">
|
||||
<:toggle class="h-5 block">
|
||||
<.button variant="ghost">
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
<div class="min-w-12">
|
||||
<.button_group>
|
||||
<.button
|
||||
variant="soft"
|
||||
phx-click={MusicLibraryWeb.Components.RecordChat.open("record-chat-sheet")}
|
||||
>
|
||||
<span class="sr-only">{gettext("Chat about album")}</span>
|
||||
<.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"
|
||||
/>
|
||||
</.button>
|
||||
</:toggle>
|
||||
<.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>
|
||||
<.dropdown id={"actions-#{@record.id}"} placement="bottom-end">
|
||||
<:toggle>
|
||||
<.button variant="soft">
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
<.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"
|
||||
/>
|
||||
</.button>
|
||||
</:toggle>
|
||||
<.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>
|
||||
|
||||
<.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>
|
||||
<.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>
|
||||
|
||||
<.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>
|
||||
<.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>
|
||||
|
||||
<.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>
|
||||
<.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>
|
||||
|
||||
<.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>
|
||||
<.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>
|
||||
|
||||
<.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>
|
||||
<.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>
|
||||
|
||||
<.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>
|
||||
<.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>
|
||||
|
||||
<.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_link>
|
||||
</.focus_wrap>
|
||||
</.dropdown>
|
||||
<.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_link>
|
||||
</.focus_wrap>
|
||||
</.dropdown>
|
||||
</.button_group>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="mt-1 flex font-semibold text-lg md:text-2xl text-zinc-700 dark:text-zinc-300 text-wrap">
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user