more improvements on tool calling
This commit is contained in:
@@ -19,6 +19,7 @@ defmodule ElixirAi.ChatUtils do
|
|||||||
|
|
||||||
headers = [{"authorization", "Bearer #{api_key}"}]
|
headers = [{"authorization", "Bearer #{api_key}"}]
|
||||||
|
|
||||||
|
Logger.info("sending AI request with body: #{inspect(body)}")
|
||||||
case Req.post(api_url,
|
case Req.post(api_url,
|
||||||
json: body,
|
json: body,
|
||||||
headers: headers,
|
headers: headers,
|
||||||
@@ -39,6 +40,28 @@ defmodule ElixirAi.ChatUtils do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def api_message(%{role: :assistant, tool_calls: [_ | _] = tool_calls} = msg) do
|
||||||
|
%{
|
||||||
|
role: "assistant",
|
||||||
|
content: Map.get(msg, :content, ""),
|
||||||
|
tool_calls:
|
||||||
|
Enum.map(tool_calls, fn call ->
|
||||||
|
%{
|
||||||
|
id: call.id,
|
||||||
|
type: "function",
|
||||||
|
function: %{
|
||||||
|
name: call.name,
|
||||||
|
arguments: call.arguments
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def api_message(%{role: :tool, tool_call_id: tool_call_id, content: content}) do
|
||||||
|
%{role: "tool", tool_call_id: tool_call_id, content: content}
|
||||||
|
end
|
||||||
|
|
||||||
def api_message(%{role: role, content: content}) do
|
def api_message(%{role: role, content: content}) do
|
||||||
%{role: Atom.to_string(role), content: content}
|
%{role: Atom.to_string(role), content: content}
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -76,72 +76,49 @@ defmodule ElixirAi.AiUtils.StreamLineUtils do
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
# start tool call
|
# start and middle tool call
|
||||||
def handle_stream_line(server, %{
|
def handle_stream_line(server, %{
|
||||||
"choices" => [
|
"choices" => [
|
||||||
%{
|
%{
|
||||||
"delta" => %{
|
"delta" => %{
|
||||||
"tool_calls" => [
|
"tool_calls" => tool_calls
|
||||||
%{
|
|
||||||
"function" => %{
|
|
||||||
"name" => tool_name,
|
|
||||||
"arguments" => tool_args_start
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"finish_reason" => nil,
|
"finish_reason" => nil
|
||||||
"index" => tool_index
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"id" => id
|
"id" => id
|
||||||
}) do
|
})
|
||||||
send(
|
when is_list(tool_calls) do
|
||||||
server,
|
Enum.each(tool_calls, fn
|
||||||
{:ai_tool_call_start, id, {tool_name, tool_args_start, tool_index}}
|
%{
|
||||||
)
|
"id" => tool_call_id,
|
||||||
end
|
"index" => tool_index,
|
||||||
|
"type" => "function",
|
||||||
|
"function" => %{"name" => tool_name, "arguments" => tool_args_start}
|
||||||
|
} ->
|
||||||
|
Logger.info("Received tool call start for tool #{tool_name}")
|
||||||
|
|
||||||
# middle tool call
|
|
||||||
def handle_stream_line(server, %{
|
|
||||||
"choices" => [
|
|
||||||
%{
|
|
||||||
"delta" => %{
|
|
||||||
"tool_calls" => [
|
|
||||||
%{
|
|
||||||
"function" => %{
|
|
||||||
"arguments" => tool_args_diff
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"finish_reason" => nil,
|
|
||||||
"index" => tool_index
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id" => id
|
|
||||||
}) do
|
|
||||||
send(
|
send(
|
||||||
server,
|
server,
|
||||||
{:ai_tool_call_middle, id, {tool_args_diff, tool_index}}
|
{:ai_tool_call_start, id, {tool_name, tool_args_start, tool_index, tool_call_id}}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
%{"index" => tool_index, "function" => %{"arguments" => tool_args_diff}} ->
|
||||||
|
Logger.info("Received tool call middle for index #{tool_index}")
|
||||||
|
send(server, {:ai_tool_call_middle, id, {tool_args_diff, tool_index}})
|
||||||
|
|
||||||
|
other ->
|
||||||
|
Logger.warning("Unmatched tool call item: #{inspect(other)}")
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
# end tool call
|
# end tool call
|
||||||
def handle_stream_line(server, %{
|
def handle_stream_line(server, %{
|
||||||
"choices" => [
|
"choices" => [%{"finish_reason" => "tool_calls"}],
|
||||||
%{
|
|
||||||
"delta" => %{},
|
|
||||||
"finish_reason" => "tool_calls",
|
|
||||||
"index" => tool_index
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"id" => id
|
"id" => id
|
||||||
}) do
|
}) do
|
||||||
send(
|
Logger.info("Received tool call end")
|
||||||
server,
|
send(server, {:ai_tool_call_end, id})
|
||||||
{:ai_tool_call_end, id, tool_index}
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_stream_line(_server, %{"error" => error_info}) do
|
def handle_stream_line(_server, %{"error" => error_info}) do
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ defmodule ElixirAi.Application do
|
|||||||
{DNSCluster, query: Application.get_env(:elixir_ai, :dns_cluster_query) || :ignore},
|
{DNSCluster, query: Application.get_env(:elixir_ai, :dns_cluster_query) || :ignore},
|
||||||
{Phoenix.PubSub, name: ElixirAi.PubSub},
|
{Phoenix.PubSub, name: ElixirAi.PubSub},
|
||||||
ElixirAi.ChatRunner,
|
ElixirAi.ChatRunner,
|
||||||
|
ElixirAi.ToolTesting,
|
||||||
ElixirAiWeb.Endpoint
|
ElixirAiWeb.Endpoint
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ defmodule ElixirAi.ChatRunner do
|
|||||||
},
|
},
|
||||||
"read_thing" => %{
|
"read_thing" => %{
|
||||||
definition: ElixirAi.ToolTesting.read_thing_definition("read_thing"),
|
definition: ElixirAi.ToolTesting.read_thing_definition("read_thing"),
|
||||||
function: &ElixirAi.ToolTesting.get_thing/0
|
function: &ElixirAi.ToolTesting.get_thing/1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -102,6 +102,7 @@ defmodule ElixirAi.ChatRunner do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:ai_stream_finish, _id}, state) do
|
def handle_info({:ai_stream_finish, _id}, state) do
|
||||||
|
Logger.info("AI stream finished for id #{state.streaming_response.id}, broadcasting end of AI response")
|
||||||
broadcast(:end_ai_response)
|
broadcast(:end_ai_response)
|
||||||
|
|
||||||
final_message = %{
|
final_message = %{
|
||||||
@@ -120,7 +121,10 @@ defmodule ElixirAi.ChatRunner do
|
|||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:ai_tool_call_start, _id, {tool_name, tool_args_start, tool_index}}, state) do
|
def handle_info(
|
||||||
|
{:ai_tool_call_start, _id, {tool_name, tool_args_start, tool_index, tool_call_id}},
|
||||||
|
state
|
||||||
|
) do
|
||||||
Logger.info("AI started tool call #{tool_name}")
|
Logger.info("AI started tool call #{tool_name}")
|
||||||
|
|
||||||
new_streaming_response = %{
|
new_streaming_response = %{
|
||||||
@@ -131,7 +135,8 @@ defmodule ElixirAi.ChatRunner do
|
|||||||
%{
|
%{
|
||||||
name: tool_name,
|
name: tool_name,
|
||||||
arguments: tool_args_start,
|
arguments: tool_args_start,
|
||||||
index: tool_index
|
index: tool_index,
|
||||||
|
id: tool_call_id
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -161,68 +166,45 @@ defmodule ElixirAi.ChatRunner do
|
|||||||
{:noreply, %{state | streaming_response: new_streaming_response}}
|
{:noreply, %{state | streaming_response: new_streaming_response}}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:ai_tool_call_end, _id, tool_index}, state) do
|
def handle_info({:ai_tool_call_end, _id}, state) do
|
||||||
|
Logger.info("ending tool call with tools: #{inspect(state.streaming_response.tool_calls)}")
|
||||||
|
|
||||||
tool_calls =
|
tool_calls =
|
||||||
Enum.map(state.streaming_response.tool_calls, fn
|
Enum.map(state.streaming_response.tool_calls, fn tool_call ->
|
||||||
%{
|
case Jason.decode(tool_call.arguments) do
|
||||||
arguments: existing_args,
|
|
||||||
index: ^tool_index
|
|
||||||
} = tool_call ->
|
|
||||||
case Jason.decode(existing_args) do
|
|
||||||
{:ok, decoded_args} ->
|
{:ok, decoded_args} ->
|
||||||
tool_function = tools()[tool_call.name].function
|
tool_function = tools()[tool_call.name].function
|
||||||
res = tool_function.(decoded_args)
|
res = tool_function.(decoded_args)
|
||||||
|
|
||||||
Map.put(tool_call, :result, res)
|
Map.put(tool_call, :result, res)
|
||||||
|
|
||||||
{:error, e} ->
|
{:error, e} ->
|
||||||
Map.put(tool_call, :error, "Failed to decode tool arguments: #{inspect(e)}")
|
Map.put(tool_call, :error, "Failed to decode tool arguments: #{inspect(e)}")
|
||||||
end
|
end
|
||||||
|
|
||||||
other ->
|
|
||||||
other
|
|
||||||
end)
|
end)
|
||||||
|
|
||||||
all_tool_calls_finished =
|
tool_request_message = %{
|
||||||
Enum.all?(tool_calls, fn call ->
|
|
||||||
Map.has_key?(call, :result) or Map.has_key?(call, :error)
|
|
||||||
end)
|
|
||||||
|
|
||||||
state =
|
|
||||||
case all_tool_calls_finished do
|
|
||||||
true ->
|
|
||||||
Logger.info("All tool calls finished, broadcasting updated tool calls with results")
|
|
||||||
|
|
||||||
new_message = %{
|
|
||||||
role: :assistant,
|
role: :assistant,
|
||||||
content: state.streaming_response.content,
|
content: state.streaming_response.content,
|
||||||
reasoning_content: state.streaming_response.reasoning_content,
|
reasoning_content: state.streaming_response.reasoning_content,
|
||||||
tool_calls: tool_calls
|
tool_calls: tool_calls
|
||||||
}
|
}
|
||||||
|
|
||||||
new_state = %{
|
result_messages =
|
||||||
state
|
Enum.map(tool_calls, fn call ->
|
||||||
| messages:
|
if Map.has_key?(call, :result) do
|
||||||
state.messages ++
|
%{role: :tool, content: "#{inspect(call.result)}", tool_call_id: call.id}
|
||||||
[
|
else
|
||||||
new_message
|
%{role: :tool, content: "Error in #{call.name}: #{call.error}", tool_call_id: call.id}
|
||||||
],
|
|
||||||
streaming_response: nil
|
|
||||||
}
|
|
||||||
|
|
||||||
broadcast({:tool_calls_finished, new_message})
|
|
||||||
|
|
||||||
false ->
|
|
||||||
%{
|
|
||||||
state
|
|
||||||
| streaming_response: %{
|
|
||||||
state.streaming_response
|
|
||||||
| tool_calls: tool_calls
|
|
||||||
}
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
{:noreply, state}
|
new_messages = [tool_request_message] ++ result_messages
|
||||||
|
|
||||||
|
Logger.info("All tool calls finished, broadcasting updated tool calls with results")
|
||||||
|
broadcast({:tool_calls_finished, new_messages})
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
%{state | messages: state.messages ++ new_messages, streaming_response: nil}}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_call(:get_conversation, _from, state) do
|
def handle_call(:get_conversation, _from, state) do
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ defmodule ElixirAi.ToolTesting do
|
|||||||
GenServer.call(__MODULE__, :get_thing)
|
GenServer.call(__MODULE__, :get_thing)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_thing(_) do
|
||||||
|
GenServer.call(__MODULE__, :get_thing)
|
||||||
|
end
|
||||||
|
|
||||||
def store_thing_definition(name) do
|
def store_thing_definition(name) do
|
||||||
%{
|
%{
|
||||||
"type" => "function",
|
"type" => "function",
|
||||||
|
|||||||
@@ -3,6 +3,26 @@ defmodule ElixirAiWeb.ChatMessage do
|
|||||||
alias ElixirAiWeb.Markdown
|
alias ElixirAiWeb.Markdown
|
||||||
alias Phoenix.LiveView.JS
|
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
|
attr :content, :string, required: true
|
||||||
|
|
||||||
def user_message(assigns) do
|
def user_message(assigns) do
|
||||||
@@ -104,29 +124,7 @@ defmodule ElixirAiWeb.ChatMessage do
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= for tool_call <- @tool_calls do %>
|
<%= 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">
|
<.tool_call_item tool_call={tool_call} />
|
||||||
<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>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= if @content && @content != "" do %>
|
<%= if @content && @content != "" do %>
|
||||||
<div class="inline-block px-3 py-2 rounded-lg max-w-prose markdown bg-cyan-950/50">
|
<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>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
defmodule ElixirAiWeb.ChatLive do
|
defmodule ElixirAiWeb.ChatLive do
|
||||||
use ElixirAiWeb, :live_view
|
use ElixirAiWeb, :live_view
|
||||||
|
require Logger
|
||||||
import ElixirAiWeb.Spinner
|
import ElixirAiWeb.Spinner
|
||||||
import ElixirAiWeb.ChatMessage
|
import ElixirAiWeb.ChatMessage
|
||||||
alias ElixirAi.ChatRunner
|
alias ElixirAi.ChatRunner
|
||||||
@@ -28,9 +29,12 @@ defmodule ElixirAiWeb.ChatLive do
|
|||||||
<p class="text-sm text-center mt-4">No messages yet.</p>
|
<p class="text-sm text-center mt-4">No messages yet.</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= for msg <- @messages do %>
|
<%= for msg <- @messages do %>
|
||||||
<%= if msg.role == :user do %>
|
<%= cond do %>
|
||||||
|
<% msg.role == :user -> %>
|
||||||
<.user_message content={msg.content} />
|
<.user_message content={msg.content} />
|
||||||
<% else %>
|
<% msg.role == :tool -> %>
|
||||||
|
<.tool_result_message content={msg.content} tool_call_id={msg.tool_call_id} />
|
||||||
|
<% true -> %>
|
||||||
<.assistant_message
|
<.assistant_message
|
||||||
content={msg.content}
|
content={msg.content}
|
||||||
reasoning_content={msg.reasoning_content}
|
reasoning_content={msg.reasoning_content}
|
||||||
@@ -102,18 +106,25 @@ defmodule ElixirAiWeb.ChatLive do
|
|||||||
{:noreply, assign(socket, streaming_response: updated_response)}
|
{:noreply, assign(socket, streaming_response: updated_response)}
|
||||||
end
|
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,
|
{:noreply,
|
||||||
socket
|
socket
|
||||||
|> update(:messages, &(&1 ++ [final_message]))
|
|> update(:messages, &(&1 ++ tool_messages))
|
||||||
|> assign(streaming_response: nil)}
|
|> assign(streaming_response: nil)}
|
||||||
end
|
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
|
def handle_info(:end_ai_response, socket) do
|
||||||
final_response = %{
|
final_response = %{
|
||||||
role: :assistant,
|
role: :assistant,
|
||||||
content: socket.assigns.streaming_response.content,
|
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,
|
{:noreply,
|
||||||
|
|||||||
Reference in New Issue
Block a user