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 %>
<%= for marker <- @markers do %>
<%= if marker.complete?, do: "✓", else: "●" %> <%= marker.name %> <%= if marker.args_preview not in [nil, ""] do %> (<%= marker.args_preview %>) <% end %>
<%= translated("chat.toolArguments") %>
<%= Jason.encode!(marker.arguments || %{}, pretty: true) %>
<%= if marker.result not in [nil, ""] do %>
<%= translated("chat.toolResult") %>
<%= marker.result %>
<% end %>
<% end %>
<% end %> """ end attr(:surface, :map, required: true) @spec chat_surface(term()) :: term() def chat_surface(assigns) do ~H"""
<%= surface_icon(@surface.type) %> <%= surface_title(@surface) %>
<%= case @surface.type do %> <% "card" -> %>
<%= if present?(@surface.title) do %>

<%= @surface.title %>

<% end %> <%= if present?(@surface.subtitle) do %>

<%= @surface.subtitle %>

<% end %>

<%= @surface.body %>

<%= if @surface.actions != [] do %>
<%= for action <- @surface.actions do %> <% end %>
<% end %>
<% "table" -> %> <%= if present?(@surface.title) do %>

<%= @surface.title %>

<% end %>
<%= for column <- @surface.columns do %> <% end %> <%= for row <- @surface.rows do %> <%= for value <- row do %> <% end %> <% end %>
<%= column %>
<%= value %>
<% "chart" -> %> <%= if present?(@surface.title) do %>

<%= @surface.title %>

<% end %>

<%= @surface.chart_type %>

<%= for series <- @surface.series do %>
<%= series.label %> <%= series.value %>
<% end %>
<% "metric" -> %>
<%= @surface.label %> <%= @surface.value %>
<% "list" -> %> <%= if present?(@surface.title) do %>

<%= @surface.title %>

<% end %> <% "mindmap" -> %> <%= if present?(@surface.title) do %>

<%= @surface.title %>

<% end %> <% "tabs" -> %> <%= if present?(@surface.title) do %>

<%= @surface.title %>

<% end %>
<%= for {tab, index} <- Enum.with_index(@surface.tabs) do %> <% end %>
<%= case Enum.at(@surface.tabs, @surface.selected_index || 0) do %> <% nil -> %> <% tab -> %>
<%= for content <- tab.content do %> <.chat_surface surface={content} /> <% end %>
<% end %>
<% "form" -> %> <%= if present?(@surface.title) do %>

<%= @surface.title %>

<% end %>
<%= for field <- @surface.fields do %> <% end %>
<%= if present?(@surface.submit_label) do %>
<% end %> <% "text" -> %>
<%= @surface.body %>
<% _other -> %>
<%= Jason.encode!(@surface.raw || %{}, pretty: true) %>
<% end %>
""" end defp surface_icon("chart"), do: "▥" defp surface_icon("table"), do: "▦" defp surface_icon("form"), do: "▤" defp surface_icon("card"), do: "▣" defp surface_icon("metric"), do: "#" defp surface_icon("list"), do: "☰" defp surface_icon("tabs"), do: "▧" defp surface_icon(_type), do: "■" defp surface_expanded_attr(surface) do if Map.get(surface, :expanded?, false), do: "true", else: "false" end defp surface_title(surface) do cond do present?(Map.get(surface, :title)) -> Map.get(surface, :title) present?(Map.get(surface, :label)) -> Map.get(surface, :label) true -> surface.type |> to_string() |> String.capitalize() end end # ── Private helpers ─────────────────────────────────────────────────────── defp update_request(socket, conversation_id, updater, reload) do case Map.get(socket.assigns.chat_editor_requests, conversation_id) do nil -> socket request -> socket |> assign( :chat_editor_requests, Map.put(socket.assigns.chat_editor_requests, conversation_id, updater.(request)) ) |> reload.(socket.assigns.workbench) end end defp allow_repo_sandbox(pid) when is_pid(pid) do if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do try do Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid) rescue _error -> :ok end else :ok end :ok end defp rewrite_external_images(html) do html = Regex.replace( ~r/]*\bsrc="(https?:\/\/[^\"]+)")(?=[^>]*\balt="([^\"]*)")[^>]*\/?>/i, html, fn _match, src, alt -> external_image_link(src, alt) end ) Regex.replace(~r/]*\bsrc="(https?:\/\/[^\"]+)")[^>]*\/?>/i, html, fn _match, src -> external_image_link(src, src) end) end defp external_image_link(src, text) do escaped_src = src |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() escaped_text = (text || src) |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string() ~s(#{escaped_text}) end defp surface_input_type("number"), do: "number" defp surface_input_type("date"), do: "date" defp surface_input_type(_type), do: "text" defp present?(value) when is_binary(value), do: String.trim(value) != "" defp present?(value), do: not is_nil(value) defp format_error(%{kind: :endpoint_not_configured}), do: translated("chat.apiKeyRequiredDescription") defp format_error(reason), do: inspect(reason) @spec translated(term(), term()) :: term() def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current()) end