fix: more fixes to AI chat

This commit is contained in:
2026-05-01 22:13:21 +02:00
parent c25720bf6e
commit 391a7f216f
4 changed files with 139 additions and 1 deletions

View File

@@ -479,10 +479,44 @@ defmodule BDS.AI.Chat do
case Catalog.decode_nullable_json(message.tool_calls) do case Catalog.decode_nullable_json(message.tool_calls) do
nil -> base nil -> base
tool_calls -> Map.put(base, "tool_calls", tool_calls) tool_calls -> Map.put(base, "tool_calls", tool_calls_for_runtime(tool_calls))
end end
end end
defp tool_calls_for_runtime(tool_calls) when is_list(tool_calls) do
Enum.map(tool_calls, &tool_call_for_runtime/1)
end
defp tool_calls_for_runtime(tool_calls), do: tool_calls
defp tool_call_for_runtime(%{"type" => "function", "function" => %{} = _function} = tool_call) do
tool_call
end
defp tool_call_for_runtime(%{"id" => id, "name" => name} = tool_call) do
%{
"id" => id,
"type" => "function",
"function" => %{
"name" => name,
"arguments" => Jason.encode!(tool_call["arguments"] || %{})
}
}
end
defp tool_call_for_runtime(%{id: id, name: name} = tool_call) do
%{
"id" => id,
"type" => "function",
"function" => %{
"name" => name,
"arguments" => Jason.encode!(Map.get(tool_call, :arguments) || %{})
}
}
end
defp tool_call_for_runtime(tool_call), do: tool_call
defp truncate_chat_messages(messages, model, tools) do defp truncate_chat_messages(messages, model, tools) do
context_window = model_context_window(model) context_window = model_context_window(model)
reserve = min(@default_max_output_tokens, max(div(context_window, 4), 512)) reserve = min(@default_max_output_tokens, max(div(context_window, 4), 512))

View File

@@ -62,6 +62,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
next_turn_index = turn_index + 1 next_turn_index = turn_index + 1
{entries, start_entry(message, next_turn_index, assigns), next_turn_index} {entries, start_entry(message, next_turn_index, assigns), next_turn_index}
:assistant ->
next_entry = start_entry(message, turn_index, assigns)
if tool_only_assistant_entry?(current_entry) do
{entries, merge_tool_only_entry(current_entry, next_entry), turn_index}
else
entries = finalize_entry(entries, current_entry)
{entries, next_entry, turn_index}
end
_other -> _other ->
entries = finalize_entry(entries, current_entry) entries = finalize_entry(entries, current_entry)
{entries, start_entry(message, turn_index, assigns), turn_index} {entries, start_entry(message, turn_index, assigns), turn_index}
@@ -99,6 +109,22 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
end end
end end
defp tool_only_assistant_entry?(%{role: :assistant, content: content} = entry) do
String.trim(content || "") == "" and
(entry.tool_markers != [] or entry.inline_surfaces != [] or entry.tool_surfaces != [])
end
defp tool_only_assistant_entry?(_entry), do: false
defp merge_tool_only_entry(tool_entry, assistant_entry) do
%{
assistant_entry
| tool_markers: tool_entry.tool_markers ++ assistant_entry.tool_markers,
inline_surfaces: tool_entry.inline_surfaces ++ assistant_entry.inline_surfaces,
tool_surfaces: tool_entry.tool_surfaces ++ assistant_entry.tool_surfaces
}
end
defp pending_user_message(_messages, nil), do: nil defp pending_user_message(_messages, nil), do: nil
defp pending_user_message(messages, %{message: message}) when is_binary(message) do defp pending_user_message(messages, %{message: message}) when is_binary(message) do

View File

@@ -602,6 +602,17 @@ defmodule BDS.AITest do
assert get_in(render_chart_schema, ["series", "items", "properties", "segments"]) != nil assert get_in(render_chart_schema, ["series", "items", "properties", "segments"]) != nil
assert Enum.any?(second_request.messages, fn message -> message["role"] == "tool" end) assert Enum.any?(second_request.messages, fn message -> message["role"] == "tool" end)
assert Enum.any?(second_request.messages, fn message ->
message["role"] == "assistant" and
message["tool_calls"] == [
%{
"id" => "call-blog-stats",
"type" => "function",
"function" => %{"name" => "blog_stats", "arguments" => "{}"}
}
]
end)
end end
test "chat does not prompt models to emit textual tool calls when tools are unavailable" do test "chat does not prompt models to emit textual tool calls when tools are unavailable" do

View File

@@ -2216,6 +2216,73 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "Posts" assert html =~ "Posts"
end end
test "chat editor folds tool-only assistant steps into the final assistant answer" do
assert {:ok, conversation} = AI.start_chat(%{title: "Tool Chat", model: "gpt-4.1"})
now = Persistence.now_ms()
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :user,
content: "Show posts per month",
created_at: now
})
)
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :assistant,
content: nil,
tool_calls:
Jason.encode!([
%{
"id" => "call-count-posts",
"name" => "count_posts",
"arguments" => %{"groupBy" => ["month"], "year" => 2026}
}
]),
created_at: now + 1
})
)
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :tool,
tool_call_id: "call-count-posts",
content: Jason.encode!([%{"month" => 5, "count" => 3}]),
created_at: now + 2
})
)
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :assistant,
content: "Here is the chart.",
created_at: now + 3
})
)
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
render_click(view, "pin_sidebar_item", %{
"route" => "chat",
"id" => conversation.id,
"title" => conversation.title,
"subtitle" => conversation.model || "chat"
})
assert html =~ "count_posts"
assert html =~ "Here is the chart."
assert html =~ ~s(<span class="chat-message-role">Assistant</span>)
assert length(:binary.matches(html, ~s(<span class="chat-message-role">Assistant</span>))) == 1
end
test "chat editor marks user message text as compact" do test "chat editor marks user message text as compact" do
assert {:ok, conversation} = AI.start_chat(%{title: "Compact Chat", model: "gpt-4.1"}) assert {:ok, conversation} = AI.start_chat(%{title: "Compact Chat", model: "gpt-4.1"})