defmodule BDS.Desktop.ShellLive.ChatEditor do @moduledoc false use Phoenix.Component import Phoenix.HTML, only: [raw: 1] alias BDS.AI alias BDS.Desktop.ShellData alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking} embed_templates("chat_editor_html/*") # ── Public API: state assignment ─────────────────────────────────────────── @spec assign_socket(term()) :: term() def assign_socket(socket) do assign(socket, :chat_editor, MessageBuild.build(socket.assigns)) end defdelegate build(assigns), to: MessageBuild # ── Public API: model selection ──────────────────────────────────────────── defdelegate toggle_model_selector(socket, reload), to: ModelSelection defdelegate set_model(socket, model_id, reload, append_output), to: ModelSelection # ── Public API: input + surface state ────────────────────────────────────── @spec update_input(term(), term(), term()) :: term() def update_input(socket, value, reload) do %{id: conversation_id} = socket.assigns.current_tab socket |> assign( :chat_editor_inputs, Map.put(socket.assigns.chat_editor_inputs, conversation_id, to_string(value || "")) ) |> reload.(socket.assigns.workbench) end @spec update_surface_form(term(), term(), term(), term()) :: term() def update_surface_form(socket, surface_id, fields, reload) when is_binary(surface_id) and is_map(fields) do next_data = Map.put(socket.assigns.chat_editor_surface_data, surface_id, fields) socket |> assign(:chat_editor_surface_data, next_data) |> reload.(socket.assigns.workbench) end @spec select_surface_tab(term(), term(), term(), term()) :: term() def select_surface_tab(socket, surface_id, index, reload) when is_binary(surface_id) and is_integer(index) and index >= 0 do socket |> assign( :chat_editor_surface_tabs, Map.put(socket.assigns.chat_editor_surface_tabs, surface_id, index) ) |> reload.(socket.assigns.workbench) end @spec dismiss_surface(term(), term(), term()) :: term() def dismiss_surface(socket, surface_id, reload) when is_binary(surface_id) do socket |> assign( :chat_editor_dismissed_surfaces, MapSet.put(socket.assigns.chat_editor_dismissed_surfaces, surface_id) ) |> reload.(socket.assigns.workbench) end @spec current_surface_data(term(), term()) :: term() def current_surface_data(socket, surface_id) when is_binary(surface_id) do Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{}) end @spec set_action_error(term(), term(), term(), term()) :: term() def set_action_error(socket, conversation_id, message, reload) when is_binary(conversation_id) and is_binary(message) do socket |> assign( :chat_editor_action_errors, Map.put(socket.assigns.chat_editor_action_errors, conversation_id, message) ) |> reload.(socket.assigns.workbench) end @spec clear_action_error(term(), term(), term()) :: term() def clear_action_error(socket, conversation_id, reload) when is_binary(conversation_id) do socket |> assign( :chat_editor_action_errors, Map.delete(socket.assigns.chat_editor_action_errors, conversation_id) ) |> reload.(socket.assigns.workbench) end # ── Public API: messaging ────────────────────────────────────────────────── @spec send_message(term(), term(), term()) :: term() def send_message(socket, reload, append_output) do %{id: conversation_id} = socket.assigns.current_tab message = socket.assigns.chat_editor_inputs |> Map.get(conversation_id, "") |> String.trim() cond do message == "" -> reload.(socket, socket.assigns.workbench) Map.has_key?(socket.assigns.chat_editor_requests, conversation_id) -> reload.(socket, socket.assigns.workbench) socket.assigns.offline_mode -> socket |> append_output.( translated("Chat"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info" ) |> reload.(socket.assigns.workbench) ModelSelection.needs_api_key?(false) -> reload.(socket, socket.assigns.workbench) true -> live_view_pid = self() task = Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn -> AI.send_chat_message(conversation_id, message, project_id: socket.assigns.projects.active_project_id, event_target: live_view_pid ) end) :ok = allow_repo_sandbox(task.pid) socket |> assign( :chat_editor_inputs, Map.put(socket.assigns.chat_editor_inputs, conversation_id, "") ) |> assign( :chat_editor_requests, Map.put(socket.assigns.chat_editor_requests, conversation_id, %{ ref: task.ref, pid: task.pid, message: message, content: "", tool_events: [] }) ) |> assign( :chat_editor_request_refs, Map.put(socket.assigns.chat_editor_request_refs, task.ref, conversation_id) ) |> assign( :chat_editor_action_errors, Map.delete(socket.assigns.chat_editor_action_errors, conversation_id) ) |> reload.(socket.assigns.workbench) end end @spec abort_message(term(), term()) :: term() def abort_message(socket, reload) do %{id: conversation_id} = socket.assigns.current_tab case Map.get(socket.assigns.chat_editor_requests, conversation_id) do nil -> reload.(socket, socket.assigns.workbench) %{ref: ref} = _request -> :ok = AI.cancel_chat(conversation_id) socket |> assign( :chat_editor_requests, Map.delete(socket.assigns.chat_editor_requests, conversation_id) ) |> assign( :chat_editor_request_refs, Map.delete(socket.assigns.chat_editor_request_refs, ref) ) |> reload.(socket.assigns.workbench) end end @spec note_tool_call(term(), term(), term(), term()) :: term() def note_tool_call(socket, conversation_id, tool_call, reload) when is_binary(conversation_id) and is_map(tool_call) do update_request( socket, conversation_id, fn request -> update_in( request.tool_events, &(&1 ++ [ %{ type: :call, name: ToolTracking.tool_call_name(tool_call), arguments: ToolTracking.tool_call_arguments(tool_call) } ]) ) end, reload ) end @spec note_tool_result(term(), term(), term(), term()) :: term() def note_tool_result(socket, conversation_id, name, reload) when is_binary(conversation_id) and is_binary(name) do update_request( socket, conversation_id, fn request -> update_in(request.tool_events, &(&1 ++ [%{type: :result, name: name}])) end, reload ) end @spec note_streaming_content(term(), term(), term(), term()) :: term() def note_streaming_content(socket, conversation_id, content, reload) when is_binary(conversation_id) and is_binary(content) do update_request( socket, conversation_id, fn request -> %{request | content: content} end, reload ) end @spec finish_request(term(), term(), term(), term(), term()) :: term() def finish_request(socket, ref, result, reload, append_output) when is_reference(ref) do case Map.pop(socket.assigns.chat_editor_request_refs, ref) do {nil, _remaining_refs} -> socket {conversation_id, remaining_refs} -> socket = socket |> assign(:chat_editor_request_refs, remaining_refs) |> assign( :chat_editor_requests, Map.delete(socket.assigns.chat_editor_requests, conversation_id) ) case result do {:ok, _reply} -> reload.(socket, socket.assigns.workbench) {:error, :cancelled} -> reload.(socket, socket.assigns.workbench) {:error, %{kind: :endpoint_not_configured}} -> reload.(socket, socket.assigns.workbench) {:error, reason} -> socket |> append_output.(translated("Chat"), format_error(reason), nil, "error") |> reload.(socket.assigns.workbench) end end end # ── HEEx-callable helpers ───────────────────────────────────────────────── @spec message_role_label(term()) :: term() def message_role_label(:user), do: translated("chat.role.you") def message_role_label(_role), do: translated("chat.role.assistant") defdelegate tool_call_name(tool_call), to: ToolTracking defdelegate tool_call_arguments(tool_call), to: ToolTracking @spec tool_surface_type(term()) :: term() def tool_surface_type(surface), do: Map.get(surface, :type, "json") def markdown_html(content) when is_binary(content) do html = case Earmark.as_html(content, escape: true) do {:ok, rendered, _messages} -> rendered {:error, rendered, _messages} -> rendered end |> rewrite_external_images() raw(html) end @spec markdown_html(term()) :: term() def markdown_html(_content), do: "" @spec payload_json(term()) :: term() def payload_json(nil), do: "{}" def payload_json(payload) when is_map(payload), do: Jason.encode!(payload) def chart_width(_max_value, value) when not is_number(value), do: 0 def chart_width(max_value, value) when is_number(max_value) and max_value > 0 do value |> Kernel./(max_value) |> Kernel.*(100) |> min(100) |> max(0) |> Float.round(2) end @spec chart_width(term(), term()) :: term() def chart_width(_max_value, _value), do: 0 def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true @spec truthy?(term()) :: term() def truthy?(_value), do: false # ── HEEx components ─────────────────────────────────────────────────────── attr(:markers, :list, required: true) @spec chat_tool_markers(term()) :: term() def chat_tool_markers(assigns) do ~H""" <%= if @markers != [] do %>
<%= Jason.encode!(marker.arguments || %{}, pretty: true) %>
<%= if marker.result not in [nil, ""] do %>
<%= marker.result %><% end %>
<%= @surface.subtitle %>
<% end %><%= @surface.body %>
<%= if @surface.actions != [] do %>| <%= column %> | <% end %>
|---|
| <%= value %> | <% end %>
<%= @surface.chart_type %>
<%= Jason.encode!(@surface.raw || %{}, pretty: true) %>
<% end %>