more improvements on tool calling

This commit is contained in:
2026-03-06 09:08:16 -07:00
parent b89d4e5a28
commit 7c7e763809
7 changed files with 275 additions and 142 deletions

View File

@@ -3,6 +3,26 @@ defmodule ElixirAiWeb.ChatMessage do
alias ElixirAiWeb.Markdown
alias Phoenix.LiveView.JS
attr :content, :string, required: true
attr :tool_call_id, :string, required: true
def tool_result_message(assigns) do
~H"""
<div class="mb-1 max-w-prose rounded-lg border border-cyan-900/40 bg-cyan-950/20 text-xs font-mono overflow-hidden">
<div class="flex items-center gap-2 px-3 py-1.5 border-b border-cyan-900/40 bg-cyan-900/10 text-cyan-600">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3 shrink-0">
<path fill-rule="evenodd" d="M10 2a.75.75 0 0 1 .75.75v.258a33.186 33.186 0 0 1 6.668 2.372.75.75 0 1 1-.636 1.354 31.66 31.66 0 0 0-1.598-.632l1.44 7.402a.75.75 0 0 1-.26.726A18.698 18.698 0 0 1 10 15.75a18.698 18.698 0 0 1-6.364-1.518.75.75 0 0 1-.26-.726l1.44-7.402a31.66 31.66 0 0 0-1.598.632.75.75 0 1 1-.636-1.354 33.186 33.186 0 0 1 6.668-2.372V2.75A.75.75 0 0 1 10 2Z" clip-rule="evenodd" />
</svg>
<span class="text-cyan-600/70 flex-1 truncate">tool result</span>
<span class="text-cyan-800 text-[10px] truncate max-w-[12rem]">{@tool_call_id}</span>
</div>
<div class="px-3 py-2">
<pre class="text-cyan-500/70 whitespace-pre-wrap break-all">{@content}</pre>
</div>
</div>
"""
end
attr :content, :string, required: true
def user_message(assigns) do
@@ -104,29 +124,7 @@ defmodule ElixirAiWeb.ChatMessage do
</div>
<% end %>
<%= for tool_call <- @tool_calls do %>
<div class="mb-1 max-w-prose rounded-lg border border-cyan-900 bg-cyan-950/40 text-xs font-mono overflow-hidden">
<div class="flex items-center gap-2 px-3 py-1 border-b border-cyan-900 bg-cyan-900/30 text-cyan-400">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3">
<path fill-rule="evenodd" d="M6.28 5.22a.75.75 0 0 1 0 1.06L2.56 10l3.72 3.72a.75.75 0 0 1-1.06 1.06L.97 10.53a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Zm7.44 0a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L17.44 10l-3.72-3.72a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
<span class="text-cyan-300 font-semibold">{tool_call.name}</span>
</div>
<%= if tool_call[:arguments] && tool_call[:arguments] != "" do %>
<div class="px-3 py-2 text-cyan-500 border-b border-cyan-900/50">
<span class="text-cyan-700 mr-1">args</span>{tool_call.arguments}
</div>
<% end %>
<%= if Map.has_key?(tool_call, :result) do %>
<div class="px-3 py-2 text-cyan-200">
<span class="text-cyan-700 mr-1">result</span>{inspect(tool_call.result)}
</div>
<% end %>
<%= if Map.has_key?(tool_call, :error) do %>
<div class="px-3 py-2 text-red-400">
<span class="text-red-600 mr-1">error</span>{tool_call.error}
</div>
<% end %>
</div>
<.tool_call_item tool_call={tool_call} />
<% end %>
<%= if @content && @content != "" do %>
<div class="inline-block px-3 py-2 rounded-lg max-w-prose markdown bg-cyan-950/50">
@@ -136,4 +134,141 @@ defmodule ElixirAiWeb.ChatMessage do
</div>
"""
end
# Dispatches to the appropriate tool call component based on result state
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} />"
true ->
assigns =
assigns
|> assign(:name, tool_call.name)
|> assign(:arguments, tool_call[:arguments] || "")
~H"<.pending_tool_call name={@name} arguments={@arguments} />"
end
end
attr :name, :string, required: true
attr :arguments, :string, default: ""
defp pending_tool_call(assigns) do
~H"""
<div class="mb-1 max-w-prose rounded-lg border border-cyan-900 bg-cyan-950/40 text-xs font-mono overflow-hidden">
<div class="flex items-center gap-2 px-3 py-1.5 border-b border-cyan-900 bg-cyan-900/30 text-cyan-400">
<.tool_call_icon />
<span class="text-cyan-300 font-semibold flex-1">{@name}</span>
<span class="flex items-center gap-1 text-cyan-600">
<span class="w-1.5 h-1.5 rounded-full bg-cyan-600 animate-pulse inline-block"></span>
<span class="text-[10px]">running</span>
</span>
</div>
<.tool_call_args arguments={@arguments} />
</div>
"""
end
attr :name, :string, required: true
attr :arguments, :string, default: ""
attr :result, :any, required: true
defp success_tool_call(assigns) do
assigns =
assign(assigns, :result_str, case assigns.result do
s when is_binary(s) -> s
other -> inspect(other, pretty: true, limit: :infinity)
end)
~H"""
<div class="mb-1 max-w-prose rounded-lg border border-cyan-900 bg-cyan-950/40 text-xs font-mono overflow-hidden">
<div class="flex items-center gap-2 px-3 py-1.5 border-b border-cyan-900 bg-cyan-900/30 text-cyan-400">
<.tool_call_icon />
<span class="text-cyan-300 font-semibold flex-1">{@name}</span>
<span class="flex items-center gap-1 text-emerald-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
<path fill-rule="evenodd" d="M12.416 3.376a.75.75 0 0 1 .208 1.04l-5 7.5a.75.75 0 0 1-1.154.114l-3-3a.75.75 0 0 1 1.06-1.06l2.353 2.353 4.493-6.74a.75.75 0 0 1 1.04-.207Z" clip-rule="evenodd" />
</svg>
<span class="text-[10px]">done</span>
</span>
</div>
<.tool_call_args arguments={@arguments} />
<div class="px-3 py-2">
<div class="text-cyan-700 mb-1 uppercase tracking-wider text-[10px]">result</div>
<pre class="text-emerald-300/80 whitespace-pre-wrap break-all">{@result_str}</pre>
</div>
</div>
"""
end
attr :name, :string, required: true
attr :arguments, :string, default: ""
attr :error, :string, required: true
defp error_tool_call(assigns) do
~H"""
<div class="mb-1 max-w-prose rounded-lg border border-red-900/50 bg-cyan-950/40 text-xs font-mono overflow-hidden">
<div class="flex items-center gap-2 px-3 py-1.5 border-b border-red-900/50 bg-red-900/20 text-cyan-400">
<.tool_call_icon />
<span class="text-cyan-300 font-semibold flex-1">{@name}</span>
<span class="flex items-center gap-1 text-red-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="w-3 h-3">
<path d="M8 15A7 7 0 1 0 8 1a7 7 0 0 0 0 14Zm0-10a.75.75 0 0 1 .75.75v3a.75.75 0 0 1-1.5 0v-3A.75.75 0 0 1 8 5Zm0 6.5a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
</svg>
<span class="text-[10px]">error</span>
</span>
</div>
<.tool_call_args arguments={@arguments} />
<div class="px-3 py-2 bg-red-950/20">
<div class="text-red-700 mb-1 uppercase tracking-wider text-[10px]">error</div>
<pre class="text-red-400 whitespace-pre-wrap break-all">{@error}</pre>
</div>
</div>
"""
end
attr :arguments, :string, default: ""
defp tool_call_args(%{arguments: args} = assigns) when args != "" do
assigns =
assign(assigns, :pretty_args, case Jason.decode(args) do
{:ok, decoded} -> Jason.encode!(decoded, pretty: true)
_ -> args
end)
~H"""
<div class="px-3 py-2 border-b border-cyan-900/50">
<div class="text-cyan-700 mb-1 uppercase tracking-wider text-[10px]">arguments</div>
<pre class="text-cyan-400 whitespace-pre-wrap break-all">{@pretty_args}</pre>
</div>
"""
end
defp tool_call_args(assigns), do: ~H""
defp tool_call_icon(assigns) do
~H"""
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-3 h-3 shrink-0">
<path fill-rule="evenodd" d="M6.28 5.22a.75.75 0 0 1 0 1.06L2.56 10l3.72 3.72a.75.75 0 0 1-1.06 1.06L.97 10.53a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Zm7.44 0a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L17.44 10l-3.72-3.72a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
"""
end
end

View File

@@ -1,5 +1,6 @@
defmodule ElixirAiWeb.ChatLive do
use ElixirAiWeb, :live_view
require Logger
import ElixirAiWeb.Spinner
import ElixirAiWeb.ChatMessage
alias ElixirAi.ChatRunner
@@ -28,14 +29,17 @@ defmodule ElixirAiWeb.ChatLive do
<p class="text-sm text-center mt-4">No messages yet.</p>
<% end %>
<%= for msg <- @messages do %>
<%= if msg.role == :user do %>
<.user_message content={msg.content} />
<% else %>
<.assistant_message
content={msg.content}
reasoning_content={msg.reasoning_content}
tool_calls={Map.get(msg, :tool_calls, [])}
/>
<%= cond do %>
<% msg.role == :user -> %>
<.user_message content={msg.content} />
<% msg.role == :tool -> %>
<.tool_result_message content={msg.content} tool_call_id={msg.tool_call_id} />
<% true -> %>
<.assistant_message
content={msg.content}
reasoning_content={msg.reasoning_content}
tool_calls={Map.get(msg, :tool_calls, [])}
/>
<% end %>
<% end %>
<%= if @streaming_response do %>
@@ -102,18 +106,25 @@ defmodule ElixirAiWeb.ChatLive do
{:noreply, assign(socket, streaming_response: updated_response)}
end
def handle_info({:tool_calls_finished, final_message}, socket) do
def handle_info({:tool_calls_finished, tool_messages}, socket) do
Logger.info("Received tool_calls_finished with #{inspect(tool_messages)}")
{:noreply,
socket
|> update(:messages, &(&1 ++ [final_message]))
|> update(:messages, &(&1 ++ tool_messages))
|> assign(streaming_response: nil)}
end
# tool_calls_finished already cleared streaming_response and committed messages — ignore
def handle_info(:end_ai_response, %{assigns: %{streaming_response: nil}} = socket) do
{:noreply, socket}
end
def handle_info(:end_ai_response, socket) do
final_response = %{
role: :assistant,
content: socket.assigns.streaming_response.content,
reasoning_content: socket.assigns.streaming_response.reasoning_content
reasoning_content: socket.assigns.streaming_response.reasoning_content,
tool_calls: socket.assigns.streaming_response.tool_calls
}
{:noreply,