diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index 2585f1e..e3d058d 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -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) } diff --git a/lib/bds/desktop/shell_live/chat_editor/message_build.ex b/lib/bds/desktop/shell_live/chat_editor/message_build.ex index 3da7b66..9479722 100644 --- a/lib/bds/desktop/shell_live/chat_editor/message_build.ex +++ b/lib/bds/desktop/shell_live/chat_editor/message_build.ex @@ -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 diff --git a/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex b/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex index 9285ab8..ce6735c 100644 --- a/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex +++ b/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex @@ -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 || %{}), diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index 7fad2c9..2fa83e3 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -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