fix: fixed duplicate elements in ai chat

This commit is contained in:
2026-05-02 10:55:14 +02:00
parent 4cf0f5281b
commit 45040f9f66
4 changed files with 220 additions and 9 deletions

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
alias BDS.AI
alias BDS.MapUtils
alias BDS.Persistence
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
@@ -125,6 +126,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
true ->
live_view_pid = self()
started_at = Persistence.now_ms()
task =
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
@@ -146,6 +148,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
Map.put(socket.assigns.chat_editor_requests, conversation_id, %{
ref: task.ref,
pid: task.pid,
started_at: started_at,
message: message,
content: "",
tool_events: []
@@ -200,6 +203,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
[
%{
type: :call,
id: ToolTracking.tool_call_id(tool_call),
name: ToolTracking.tool_call_name(tool_call),
arguments: ToolTracking.tool_call_arguments(tool_call)
}

View File

@@ -17,6 +17,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
request = Map.get(assigns.chat_editor_requests, conversation.id)
effective_model = AI.effective_chat_model(conversation)
available_models = AI.available_chat_models(effective_model)
streaming_tool_markers = streaming_tool_markers(messages, request)
streaming_content = streaming_content(messages, request)
%{
id: conversation.id,
@@ -31,9 +33,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
messages: build_entries(messages, assigns),
pending_user_message: pending_user_message(messages, request),
is_streaming: not is_nil(request),
streaming_content: streaming_content(request),
streaming_tool_markers: ToolTracking.tool_markers_from_events(request),
streaming_inline_surfaces: streaming_inline_surfaces(conversation.id, request, assigns),
streaming_content: streaming_content,
streaming_tool_markers: streaming_tool_markers,
streaming_inline_surfaces:
streaming_inline_surfaces(conversation.id, streaming_tool_markers, assigns),
offline?: Map.get(assigns, :offline_mode, true),
needs_api_key?: ModelSelection.needs_api_key?(Map.get(assigns, :offline_mode, true)),
action_error: Map.get(assigns.chat_editor_action_errors, conversation.id),
@@ -137,28 +140,135 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
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} = request) when is_binary(message) do
cond do
persisted_user_message_for_request?(messages, request) ->
nil
true ->
legacy_pending_user_message(messages, message)
end
end
defp pending_user_message(_messages, _request), do: nil
defp legacy_pending_user_message(messages, message) do
case messages |> Enum.reverse() |> Enum.find(&(&1.role not in [:system, :tool])) do
%{role: :user, content: ^message} -> nil
_other -> message
end
end
defp pending_user_message(_messages, _request), do: nil
defp streaming_content(nil), do: ""
defp streaming_content(%{content: content}) when is_binary(content), do: content
defp streaming_content(_request), do: ""
defp streaming_inline_surfaces(_conversation_id, nil, _assigns), do: []
defp streaming_content(messages, request) do
content = streaming_content(request)
defp streaming_inline_surfaces(conversation_id, request, assigns) do
if content != "" and persisted_assistant_content_for_request?(messages, request, content) do
""
else
content
end
end
defp streaming_tool_markers(_messages, nil), do: []
defp streaming_tool_markers(messages, request) do
request
|> ToolTracking.tool_markers_from_events()
|> drop_persisted_tool_markers(messages, request)
end
defp streaming_inline_surfaces(_conversation_id, [], _assigns), do: []
defp streaming_inline_surfaces(conversation_id, tool_markers, assigns) do
tool_markers
|> ToolSurfaces.build_render_surfaces("streaming-#{conversation_id}", assigns)
|> mark_surfaces_expanded(assigns)
end
defp persisted_user_message_for_request?(messages, %{message: message} = request)
when is_binary(message) do
messages
|> persisted_messages_for_request(request)
|> Enum.any?(fn persisted_message ->
persisted_message.role == :user and persisted_message.content == message
end)
end
defp persisted_user_message_for_request?(_messages, _request), do: false
defp persisted_assistant_content_for_request?(messages, request, content)
when is_binary(content) and content != "" do
messages
|> persisted_messages_for_request(request)
|> Enum.any?(fn persisted_message ->
persisted_message.role == :assistant and (persisted_message.content || "") == content
end)
end
defp persisted_assistant_content_for_request?(_messages, _request, _content), do: false
defp drop_persisted_tool_markers(tool_markers, messages, request) do
persisted_markers = persisted_tool_markers_for_request(messages, request)
{remaining, _persisted_markers} =
Enum.reduce(tool_markers, {[], persisted_markers}, fn marker, {remaining, persisted_markers} ->
case pop_matching_tool_marker(persisted_markers, marker) do
{nil, persisted_markers} -> {remaining ++ [marker], persisted_markers}
{_matched, persisted_markers} -> {remaining, persisted_markers}
end
end)
remaining
end
defp persisted_tool_markers_for_request(messages, request) do
messages
|> persisted_messages_for_request(request)
|> Enum.flat_map(fn message ->
if message.role == :assistant do
ToolTracking.normalize_tool_calls(message.tool_calls)
else
[]
end
end)
end
defp pop_matching_tool_marker(tool_markers, marker) do
case Enum.find_index(tool_markers, &same_tool_marker?(&1, marker)) do
nil -> {nil, tool_markers}
index -> {Enum.at(tool_markers, index), List.delete_at(tool_markers, index)}
end
end
defp same_tool_marker?(left, right) do
cond do
is_binary(left.id) and is_binary(right.id) ->
left.id == right.id
true ->
left.name == right.name and (left.arguments || %{}) == (right.arguments || %{})
end
end
defp persisted_messages_for_request(messages, request) do
case request_started_at(request) do
started_at when is_integer(started_at) ->
Enum.filter(messages, fn message ->
is_integer(message.created_at) and message.created_at >= started_at
end)
_other ->
[]
end
end
defp request_started_at(%{started_at: started_at}) when is_integer(started_at), do: started_at
defp request_started_at(_request), do: nil
defp translated(text, bindings \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

@@ -8,6 +8,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
BDS.MapUtils.attr(tool_call, :name) || "tool"
end
@spec tool_call_id(term()) :: term()
def tool_call_id(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :id)
end
@spec tool_call_id(term()) :: term()
def tool_call_id(_tool_call), do: nil
@spec tool_call_arguments(term()) :: term()
def tool_call_arguments(tool_call) when is_map(tool_call) do
BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{}
@@ -72,7 +80,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
markers ++
[
%{
id: nil,
id: Map.get(event, :id),
name: event.name,
arguments: event.arguments,
args_preview: tool_arguments_preview(event.arguments || %{}),

View File

@@ -3331,6 +3331,95 @@ defmodule BDS.Desktop.ShellLiveTest do
refute render(view) =~ "Delayed response"
end
test "chat editor does not duplicate persisted turn artifacts while the request is still active" do
assert :ok = AI.set_airplane_mode(false)
server =
start_supervised!({Bandit, plug: DelayedChatServer, port: 0, startup_log: false})
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
assert {:ok, _endpoint} =
AI.put_endpoint(:online, %{
url: "http://127.0.0.1:#{port}/v1",
api_key: "online-secret",
model: "gpt-4.1"
})
assert {:ok, conversation} = AI.start_chat(%{title: "Streaming Dedupe", model: "gpt-4.1"})
{: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"
})
_html = render_change(view, "change_chat_editor_input", %{"message" => "Newest question"})
_html =
view
|> element("[data-testid='chat-send-button']")
|> render_click()
assert Enum.count(AI.list_chat_messages(conversation.id), fn message ->
message.role == :user and message.content == "Newest question"
end) == 1
now = Persistence.now_ms()
Repo.insert!(
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
conversation_id: conversation.id,
role: :assistant,
content: "",
tool_calls:
Jason.encode!([
%{
"id" => "call-card-new",
"name" => "render_card",
"arguments" => %{
"title" => "Latest Missing Data",
"body" => "The second data request needs review."
}
}
]),
created_at: now + 1
})
)
send(view.pid, {
:chat_tool_call,
conversation.id,
%{
id: "call-card-new",
name: "render_card",
arguments: %{
"title" => "Latest Missing Data",
"body" => "The second data request needs review."
}
}
})
html = render(view)
refute html =~ ~s(data-testid="chat-pending-user-message")
assert length(:binary.matches(html, ~s(data-testid="chat-user-message-text"))) == 1
assert length(:binary.matches(html, ~s(data-testid="chat-inline-surface"))) == 1
refute html =~ ~s(data-testid="chat-streaming-message")
assert html =~ ~s(data-testid="chat-streaming-thinking")
_html =
view
|> element("[data-testid='chat-abort-button']")
|> render_click()
Process.sleep(350)
end
test "translation validation route renders dedicated cards and fix controls", %{
project: project,
temp_dir: temp_dir