defmodule ElixirAiWeb.ChatMessage do use Phoenix.Component alias Phoenix.LiveView.JS defp max_width_class, do: "max-w-300" attr :content, :string, required: true attr :tool_call_id, :string, required: true def tool_result_message(assigns) do ~H"""
tool result {@tool_call_id}
{@content}
""" end attr :content, :string, required: true def user_message(assigns) do ~H"""
{@content}
""" end attr :content, :string, required: true attr :reasoning_content, :string, default: nil attr :tool_calls, :list, default: [] def assistant_message(assigns) do assigns = assigns |> assign( :_reasoning_id, "reasoning-#{:erlang.phash2({assigns.content, assigns.reasoning_content, assigns.tool_calls})}" ) |> assign(:_expanded, false) ~H""" <.message_bubble reasoning_id={@_reasoning_id} content={@content} reasoning_content={@reasoning_content} tool_calls={@tool_calls} expanded={@_expanded} /> """ end attr :content, :string, required: true attr :reasoning_content, :string, default: nil attr :tool_calls, :list, default: [] # Renders the in-progress streaming message. Content and reasoning are rendered # entirely client-side via the MarkdownStream hook — the server sends push_event # chunks instead of re-rendering the full markdown on every token. def streaming_assistant_message(assigns) do ~H"""
<%= if @reasoning_content && @reasoning_content != "" do %> <% end %>
<%= for tool_call <- @tool_calls do %> <.tool_call_item tool_call={tool_call} /> <% end %>
""" end attr :content, :string, required: true attr :reasoning_content, :string, default: nil attr :tool_calls, :list, default: [] attr :reasoning_id, :string, required: true attr :expanded, :boolean, default: false defp message_bubble(assigns) do ~H"""
<%= if @reasoning_content && @reasoning_content != "" do %>
<% end %> <%= for tool_call <- @tool_calls do %> <.tool_call_item tool_call={tool_call} /> <% end %> <%= if @content && @content != "" do %>
<% end %>
""" end # Dispatches to the appropriate tool call component based on result state. # Four states: # :error key present → error (runtime failure) # :result key present → success (runtime completed) # :index key present → pending (streaming in-progress) # none of the above → called (DB-loaded completed call; result is a separate message) attr :tool_call, :map, required: true defp tool_call_item(%{tool_call: tool_call} = assigns) do cond do Map.has_key?(tool_call, :error) -> assigns = assigns |> assign(:name, tool_call.name) |> assign(:arguments, tool_call[:arguments]) |> assign(:error, tool_call.error) ~H"<.error_tool_call name={@name} arguments={@arguments} error={@error} />" Map.has_key?(tool_call, :result) -> assigns = assigns |> assign(:name, tool_call.name) |> assign(:arguments, tool_call[:arguments]) |> assign(:result, tool_call.result) ~H"<.success_tool_call name={@name} arguments={@arguments} result={@result} />" Map.has_key?(tool_call, :index) -> assigns = assigns |> assign(:name, tool_call.name) |> assign(:arguments, tool_call[:arguments]) ~H"<.pending_tool_call name={@name} arguments={@arguments} />" true -> assigns = assigns |> assign(:name, tool_call.name) |> assign(:arguments, tool_call[:arguments]) ~H"<.called_tool_call name={@name} arguments={@arguments} />" end end attr :name, :string, required: true attr :arguments, :any, default: nil defp called_tool_call(assigns) do assigns = assigns |> assign(:_id, "tc-#{:erlang.phash2({assigns.name, assigns.arguments})}") |> assign(:_truncated, truncate_args(assigns.arguments)) ~H"""
<.tool_call_icon /> {@name} {@_truncated} called
""" end attr :name, :string, required: true attr :arguments, :any, default: nil defp pending_tool_call(assigns) do assigns = assigns |> assign(:_id, "tc-#{:erlang.phash2({assigns.name, assigns.arguments})}") |> assign(:_truncated, truncate_args(assigns.arguments)) ~H"""
<.tool_call_icon /> {@name} {@_truncated} running
""" end attr :name, :string, required: true attr :arguments, :any, default: nil attr :result, :any, required: true defp success_tool_call(assigns) do assigns = assigns |> assign( :result_str, case assigns.result do s when is_binary(s) -> s other -> inspect(other, pretty: true, limit: :infinity) end ) |> assign(:_id, "tc-#{:erlang.phash2({assigns.name, assigns.arguments})}") |> assign(:_truncated, truncate_args(assigns.arguments)) ~H"""
<.tool_call_icon /> {@name} {@_truncated} done
result
{@result_str}
""" end attr :name, :string, required: true attr :arguments, :any, default: nil attr :error, :string, required: true defp error_tool_call(assigns) do assigns = assigns |> assign(:_id, "tc-#{:erlang.phash2({assigns.name, assigns.arguments})}") |> assign(:_truncated, truncate_args(assigns.arguments)) ~H"""
<.tool_call_icon /> {@name} {@_truncated} error
error
{@error}
""" end defp truncate_args(nil), do: nil defp truncate_args(""), do: nil defp truncate_args(args) when is_binary(args) do compact = case Jason.decode(args) do {:ok, decoded} -> Jason.encode!(decoded) _ -> args end if String.length(compact) > 72, do: String.slice(compact, 0, 69) <> "\u2026", else: compact end defp truncate_args(args) do compact = Jason.encode!(args) if String.length(compact) > 72, do: String.slice(compact, 0, 69) <> "\u2026", else: compact end attr :arguments, :any, default: nil defp tool_call_args(%{arguments: args} = assigns) when not is_nil(args) and args != "" do assigns = assign( assigns, :pretty_args, case args do s when is_binary(s) -> case Jason.decode(s) do {:ok, decoded} -> Jason.encode!(decoded, pretty: true) _ -> s end other -> Jason.encode!(other, pretty: true) end ) ~H"""
arguments
{@pretty_args}
""" end defp tool_call_args(assigns), do: ~H"" defp tool_call_icon(assigns) do ~H""" """ end end