Spaces:
Sleeping
Sleeping
defmodule ChaiWeb.ChatLive do | |
use ChaiWeb, :live_view | |
@impl true | |
def mount(_params, _session, socket) do | |
{:ok, | |
socket | |
|> assign( | |
messages: [], | |
message: "", | |
history: nil, | |
reply_task: nil, | |
transcribe_task: nil, | |
caption_task: nil | |
) | |
|> allow_upload(:audio, accept: :any, progress: &handle_progress/3, auto_upload: true) | |
|> allow_upload(:image, | |
accept: ~w(.jpg .jpeg .png), | |
progress: &handle_progress/3, | |
auto_upload: true | |
)} | |
end | |
@impl true | |
def render(assigns) do | |
~H""" | |
<div class="mt-20 h-[512px] flex flex-col justify-end border border-zinc-100 rounded-lg"> | |
<div id="messages" phx-hook="Messages" class="flex flex-col gap-2 p-3 overflow-y-auto"> | |
<div class="flex flex-col-reverse gap-2"> | |
<div | |
:for={message <- @messages} | |
class={["relative max-w-[80%]", if(message.user?, do: "self-end", else: "self-start")]} | |
> | |
<.message_content message={message} /> | |
<div | |
:if={message.transcribed?} | |
class="flex absolute top-1/2 -left-5 transform -translate-y-1/2 text-zinc-500" | |
> | |
<.icon name="hero-microphone-solid" class="w-4 h-4" /> | |
</div> | |
<div | |
:if={message.reaction} | |
class="flex absolute bottom-0 right-0 transform translate-y-1/2 bg-white shadow-md rounded-full p-0.5 leading-none" | |
> | |
<%= message.reaction %> | |
</div> | |
</div> | |
</div> | |
<div :if={@reply_task} class="self-start px-4 py-1.5 rounded-3xl text-zinc-900 bg-zinc-100"> | |
<.typing /> | |
</div> | |
</div> | |
<form phx-submit="send_message" class="px-3 py-2 flex gap-2 items-center"> | |
<button | |
type="button" | |
id="microphone" | |
phx-hook="Microphone" | |
data-endianness={System.endianness()} | |
class="flex p-2.5 rounded-full text-white bg-zinc-900 hover:bg-zinc-700 active:bg-red-400 group" | |
> | |
<.icon name="hero-microphone-solid" class="w-5 h-5 group-active:animate-pulse" /> | |
</button> | |
<button | |
type="button" | |
phx-click={JS.dispatch("click", to: "##{@uploads.image.ref}")} | |
class="flex p-2.5 rounded-full text-white bg-zinc-900 hover:bg-zinc-700" | |
> | |
<.icon name="hero-photo-solid" class="w-5 h-5" /> | |
</button> | |
<input | |
type="text" | |
id="message" | |
name="message" | |
value={@message} | |
autofocus | |
autocomplete="off" | |
class="w-full rounded-xl bg-zinc-100 border-none focus:ring-0" | |
/> | |
<button type="submit" class="flex text-zinc-900 hover:text-zinc-700 active:text-zinc-500"> | |
<.icon name="hero-paper-airplane-solid" class="w-6 h-6" /> | |
</button> | |
</form> | |
<form phx-change="noop" phx-submit="noop" class="hidden"> | |
<.live_file_input upload={@uploads.audio} /> | |
</form> | |
<form phx-change="noop" phx-submit="noop" class="hidden"> | |
<.live_file_input upload={@uploads.image} /> | |
</form> | |
</div> | |
""" | |
end | |
defp typing(assigns) do | |
~H""" | |
<div class="relative h-6 flex gap-1 items-center"> | |
<div class="h-2 w-2 bg-current rounded-full opacity-0 animate-[loading-fade_1s_infinite] " /> | |
<div class="h-2 w-2 bg-current rounded-full opacity-0 animate-[loading-fade_1s_infinite_0.2s] " /> | |
<div class="h-2 w-2 bg-current rounded-full opacity-0 animate-[loading-fade_1s_infinite_0.4s] " /> | |
</div> | |
""" | |
end | |
defp message_content(assigns) when assigns.message.image != nil do | |
~H""" | |
<img src={~p"/uploads/#{@message.image}"} class="max-w-[300px] max-h-[200px] rounded-xl" /> | |
""" | |
end | |
defp message_content(assigns) do | |
~H""" | |
<div class={[ | |
"px-4 py-1.5 rounded-3xl", | |
if(@message.user?, do: "text-white bg-blue-500", else: "text-zinc-900 bg-zinc-100") | |
]}> | |
<span :for={{text, label} <- Chai.Utils.labelled_chunks(@message.text, @message.entities)}> | |
<%= if label do %> | |
<span class="inline-flex items-center gap-0.5"> | |
<span class="font-bold"><%= text %></span> | |
<.icon :if={icon = label_to_icon(label)} name={icon} class="w-4 h-4" /> | |
</span> | |
<% else %> | |
<%= text %> | |
<% end %> | |
</span> | |
</div> | |
""" | |
end | |
defp label_to_icon("LOC"), do: "hero-map-pin-solid" | |
defp label_to_icon("PER"), do: "hero-user-solid" | |
defp label_to_icon("ORG"), do: "hero-building-office-solid" | |
defp label_to_icon("MISC"), do: nil | |
defp handle_progress(:audio, entry, socket) when entry.done? do | |
binary = | |
consume_uploaded_entry(socket, entry, fn %{path: path} -> | |
{:ok, File.read!(path)} | |
end) | |
# We always pre-process audio on the client into a single channel | |
audio = Nx.from_binary(binary, :f32) | |
{:noreply, request_transcription(socket, audio)} | |
end | |
defp handle_progress(:image, entry, socket) when entry.done? do | |
filename = | |
consume_uploaded_entry(socket, entry, fn %{path: path} -> | |
filename = Path.basename(path) | |
File.cp!(path, Chai.upload_path(filename)) | |
{:ok, filename} | |
end) | |
{:noreply, request_caption(socket, filename)} | |
end | |
defp handle_progress(_name, _entry, socket), do: {:noreply, socket} | |
@impl true | |
def handle_event("send_message", %{"message" => text}, socket) do | |
{:noreply, | |
socket | |
|> insert_message(text, user?: true) | |
|> request_reply(text) | |
|> assign(message: "")} | |
end | |
def handle_event("noop", %{}, socket) do | |
# We need phx-change and phx-submit on the form for live uploads, | |
# but we process the upload immediately in handle_progress/3 | |
{:noreply, socket} | |
end | |
@impl true | |
def handle_info({ref, {:reply, {text, history}}}, socket) | |
when socket.assigns.reply_task.ref == ref do | |
{:noreply, | |
socket | |
|> insert_message(text) | |
|> assign(history: history, reply_task: nil)} | |
end | |
def handle_info({ref, {:transcription, text}}, socket) | |
when socket.assigns.transcribe_task.ref == ref do | |
{:noreply, | |
socket | |
|> insert_message(text, user?: true, transcribed?: true) | |
|> request_reply(text) | |
|> assign(transcribe_task: nil)} | |
end | |
def handle_info({ref, {:caption, filename, text}}, socket) | |
when socket.assigns.caption_task.ref == ref do | |
text = "look, an image of " <> text | |
{:noreply, | |
socket | |
|> insert_message(text, user?: true, image: filename) | |
|> request_reply(text) | |
|> assign(caption_task: nil)} | |
end | |
def handle_info({_ref, {:reaction, message_id, reaction}}, socket) when reaction != nil do | |
{:noreply, update_message(socket, message_id, &%{&1 | reaction: reaction})} | |
end | |
def handle_info({_ref, {:entities, message_id, entities}}, socket) when entities != [] do | |
{:noreply, update_message(socket, message_id, &%{&1 | entities: entities})} | |
end | |
def handle_info(_message, socket), do: {:noreply, socket} | |
defp insert_message(socket, text, opts \\ []) do | |
message = %{ | |
id: System.unique_integer(), | |
text: text, | |
user?: Keyword.get(opts, :user?, false), | |
transcribed?: Keyword.get(opts, :transcribed?, false), | |
image: Keyword.get(opts, :image), | |
reaction: nil, | |
entities: [] | |
} | |
socket = update(socket, :messages, &[message | &1]) | |
socket = | |
if message.image do | |
socket | |
else | |
request_entities(socket, message.text, message.id) | |
end | |
if message.user? do | |
request_reaction(socket, message.text, message.id) | |
else | |
socket | |
end | |
end | |
defp update_message(socket, message_id, fun) do | |
update(socket, :messages, fn messages -> | |
Enum.map(messages, fn | |
%{id: ^message_id} = message -> fun.(message) | |
message -> message | |
end) | |
end) | |
end | |
defp request_reply(socket, text) do | |
history = socket.assigns.history | |
task = Task.async(fn -> {:reply, Chai.AI.generate_reply(text, history)} end) | |
assign(socket, reply_task: task) | |
end | |
defp request_transcription(socket, audio) do | |
task = Task.async(fn -> {:transcription, Chai.AI.transcribe(audio)} end) | |
assign(socket, transcribe_task: task) | |
end | |
defp request_caption(socket, filename) do | |
task = | |
Task.async(fn -> | |
image = filename |> Chai.upload_path() |> StbImage.read_file!() | |
{:caption, filename, Chai.AI.describe_image(image)} | |
end) | |
assign(socket, caption_task: task) | |
end | |
defp request_reaction(socket, text, message_id) do | |
Task.async(fn -> {:reaction, message_id, Chai.AI.get_reaction(text)} end) | |
socket | |
end | |
defp request_entities(socket, text, message_id) do | |
Task.async(fn -> {:entities, message_id, Chai.AI.get_entities(text)} end) | |
socket | |
end | |
end | |