defmodule BDS.Desktop.ShellLive.ChatEditor do @moduledoc false require Logger use Phoenix.LiveComponent import Phoenix.HTML, only: [raw: 1] alias BDS.{AI, BoundedAtoms, MapUtils, Persistence} alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking} alias BDS.Desktop.ShellLive.Notify alias BDS.Desktop.ShellLive.TabHelpers use Gettext, backend: BDS.Gettext embed_templates("chat_editor_html/*") # ── LiveComponent lifecycle ──────────────────────────────────────────────── @spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()} @impl true def update(%{action: :finish_request}, %{assigns: %{request: nil}} = socket) do {:ok, socket} end def update(%{action: :finish_request, result: result}, socket) do {:ok, do_finish_request(socket, result)} end def update(%{action: :note_tool_call, tool_call: tool_call}, socket) do {:ok, do_note_tool_call(socket, tool_call)} end def update(%{action: :note_tool_result, name: name}, socket) do {:ok, do_note_tool_result(socket, name)} end def update(%{action: :note_streaming_content, content: content}, socket) do {:ok, do_note_streaming_content(socket, content)} end def update(%{action: :persist_surface_state}, socket) do {:ok, persist_surface_state(socket)} end def update(assigns, socket) do socket = socket |> assign(assigns) |> ensure_state() |> build_data() {:ok, socket} end @spec render(map()) :: Phoenix.LiveView.Rendered.t() @impl true def render(%{chat_editor: nil} = assigns), do: ~H"
" def render(assigns) do chat_editor(assigns) end # ── Event handlers ───────────────────────────────────────────────────────── @spec handle_event(String.t(), map(), Phoenix.LiveView.Socket.t()) :: {:noreply, Phoenix.LiveView.Socket.t()} @impl true def handle_event("change_chat_editor_input", %{"message" => message}, socket) do {:noreply, assign(socket, :input, to_string(message || "")) |> build_data()} end def handle_event("toggle_chat_model_selector", _params, socket) do {:noreply, assign(socket, :model_selector_open?, not socket.assigns.model_selector_open?) |> build_data()} end def handle_event("select_chat_model", %{"model" => model_id}, socket) do conversation_id = socket.assigns.conversation_id case AI.set_conversation_model(conversation_id, model_id) do {:ok, _conversation} -> {:noreply, assign(socket, :model_selector_open?, false) |> build_data()} {:error, reason} -> Notify.output(dgettext("ui", "Chat"), inspect(reason), "error") {:noreply, assign(socket, :model_selector_open?, false) |> build_data()} end end def handle_event("send_chat_editor_message", _params, socket) do {:noreply, do_send_message(socket)} end def handle_event("abort_chat_editor_message", _params, socket) do {:noreply, do_abort_message(socket)} end def handle_event( "change_chat_surface_form", %{"surface" => %{"id" => surface_id, "fields" => fields}}, socket ) do next_data = Map.put(socket.assigns.surface_data, surface_id, fields) {:noreply, assign(socket, :surface_data, next_data) |> schedule_surface_state_persist() |> build_data()} end def handle_event( "select_chat_surface_tab", %{"surface-id" => surface_id, "index" => index}, socket ) do socket = socket |> assign( :surface_tabs, Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index)) ) |> persist_surface_state() |> build_data() {:noreply, socket} end def handle_event("dismiss_chat_surface", %{"surface-id" => surface_id}, socket) do socket = socket |> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id)) |> persist_surface_state() |> build_data() {:noreply, socket} end def handle_event("chat_surface_action", params, socket) do {:noreply, do_handle_surface_action(socket, params)} end def handle_event("open_chat_settings", _params, socket) do Notify.open_sidebar_item( %{ "route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI" }, :pin ) {:noreply, socket} end # ── State initialisation ────────────────────────────────────────────────── defp ensure_state(socket) do conversation_id = socket.assigns.current_tab.id persisted = AI.get_surface_state(conversation_id) {surface_data, surface_tabs, dismissed_surfaces} = case persisted do state when is_map(state) and map_size(state) > 0 -> { state["surface_data"] || %{}, state["surface_tabs"] || %{}, MapSet.new(state["dismissed_surfaces"] || []) } _other -> {%{}, %{}, MapSet.new()} end defaults = %{ conversation_id: conversation_id, input: "", model_selector_open?: false, request: nil, surface_data: surface_data, surface_tabs: surface_tabs, dismissed_surfaces: dismissed_surfaces, action_error: nil } Enum.reduce(defaults, socket, fn {key, default}, acc -> if is_nil(Map.get(acc.assigns, key)) do assign(acc, key, default) else acc end end) end # ── Data builder ────────────────────────────────────────────────────────── defp build_data(socket) do conversation_id = socket.assigns.conversation_id request = socket.assigns.request fake_assigns = %{ current_tab: socket.assigns.current_tab, chat_editor_requests: if(request, do: %{conversation_id => request}, else: %{}), chat_model_selectors_open: %{conversation_id => socket.assigns.model_selector_open?}, chat_editor_inputs: %{conversation_id => socket.assigns.input}, chat_editor_surface_data: socket.assigns.surface_data, chat_editor_surface_tabs: socket.assigns.surface_tabs, chat_editor_dismissed_surfaces: socket.assigns.dismissed_surfaces, chat_editor_action_errors: %{conversation_id => socket.assigns.action_error}, offline_mode: socket.assigns.offline_mode } chat_editor = MessageBuild.build(fake_assigns) assign(socket, :chat_editor, chat_editor) end # ── Messaging ────────────────────────────────────────────────────────────── defp do_send_message(socket) do conversation_id = socket.assigns.conversation_id message = String.trim(socket.assigns.input || "") cond do message == "" -> build_data(socket) not is_nil(socket.assigns.request) -> build_data(socket) socket.assigns.offline_mode -> Notify.output(dgettext("ui", "Chat"), dgettext("ui", "Automatic AI actions stay gated by airplane mode."), "info") build_data(socket) ModelSelection.needs_api_key?(false) -> build_data(socket) true -> parent = self() started_at = Persistence.now_ms() task = Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn -> AI.send_chat_message(conversation_id, message, project_id: active_project_id(socket), event_target: parent ) end) :ok = allow_repo_sandbox(task.pid) Notify.parent({:chat_editor_task_started, conversation_id, task.ref}) socket |> assign(:input, "") |> assign(:request, %{ ref: task.ref, pid: task.pid, started_at: started_at, message: message, content: "", tool_events: [] }) |> assign(:action_error, nil) |> build_data() end end defp do_abort_message(socket) do conversation_id = socket.assigns.conversation_id case socket.assigns.request do nil -> build_data(socket) %{ref: ref} = _request -> :ok = AI.cancel_chat(conversation_id) Notify.parent({:chat_editor_task_cancelled, conversation_id, ref}) socket |> assign(:request, nil) |> clear_streaming_state() end end defp clear_streaming_state(socket) do input = socket.assigns.input || "" chat_editor = socket.assigns.chat_editor || %{} chat_editor = chat_editor |> Map.put(:is_streaming, false) |> Map.put(:pending_user_message, nil) |> Map.put(:streaming_content, "") |> Map.put(:streaming_tool_markers, []) |> Map.put(:streaming_inline_surfaces, []) |> Map.put(:send_disabled?, String.trim(input) == "") assign(socket, :chat_editor, chat_editor) end defp do_finish_request(socket, result) do case result do {:ok, reply} -> socket |> update_tab_meta_from_reply(reply) |> assign(:request, nil) |> build_data() {:error, :cancelled} -> assign(socket, :request, nil) |> build_data() {:error, %{kind: :endpoint_not_configured}} -> assign(socket, :request, nil) |> build_data() {:error, reason} -> Notify.output(dgettext("ui", "Chat"), format_error(reason), "error") assign(socket, :request, nil) |> build_data() end end defp do_note_tool_call(socket, tool_call) when is_map(tool_call) do update_request(socket, fn request -> update_in( request.tool_events, &(&1 ++ [ %{ type: :call, id: ToolTracking.tool_call_id(tool_call), name: ToolTracking.tool_call_name(tool_call), arguments: ToolTracking.tool_call_arguments(tool_call) } ]) ) end) end defp do_note_tool_result(socket, name) when is_binary(name) do update_request(socket, fn request -> update_in(request.tool_events, &(&1 ++ [%{type: :result, name: name}])) end) end defp do_note_streaming_content(socket, content) when is_binary(content) do update_request(socket, fn request -> %{request | content: content} end) end defp update_request(socket, updater) do case socket.assigns.request do nil -> build_data(socket) request -> socket |> assign(:request, updater.(request)) |> build_data() end end defp update_tab_meta_from_reply(socket, reply) do title = reply |> MapUtils.attr(:conversation, %{}) |> MapUtils.attr(:title) if is_binary(title) and String.trim(title) != "" do Notify.tab_meta(:chat, socket.assigns.conversation_id, title, "") end socket end # ── Surface actions ──────────────────────────────────────────────────────── defp do_handle_surface_action(socket, params) do surface_id = Map.get(params, "surface-id", "") payload = params |> Map.get("payload") |> decode_payload() |> maybe_put_form_data(socket, surface_id) case normalize_action(Map.get(params, "action", "")) do :open_post -> case Map.get(payload, "postId") || Map.get(payload, "post_id") do post_id when is_binary(post_id) and post_id != "" -> Notify.open_sidebar_item( %{ "route" => "post", "id" => post_id, "title" => TabHelpers.post_title(post_id), "subtitle" => TabHelpers.post_subtitle(post_id) }, :pin ) assign(socket, :action_error, nil) |> build_data() _other -> set_action_error(socket, "Invalid payload for openPost action") end :open_media -> case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do media_id when is_binary(media_id) and media_id != "" -> Notify.open_sidebar_item( %{ "route" => "media", "id" => media_id, "title" => TabHelpers.media_title(media_id), "subtitle" => TabHelpers.media_subtitle(media_id) }, :pin ) assign(socket, :action_error, nil) |> build_data() _other -> set_action_error(socket, "Invalid payload for openMedia action") end :open_settings -> Notify.open_sidebar_item( %{ "route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI" }, :pin ) assign(socket, :action_error, nil) |> build_data() :open_chat -> chat_id = Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") || socket.assigns.conversation_id Notify.open_sidebar_item( %{ "route" => "chat", "id" => chat_id, "title" => Map.get(payload, "title", "Chat"), "subtitle" => Map.get(payload, "subtitle", "") }, :pin ) assign(socket, :action_error, nil) |> build_data() :switch_view -> case BoundedAtoms.sidebar_view(Map.get(payload, "view")) do nil -> set_action_error(socket, "Invalid payload for switchView action") view -> Notify.parent({:chat_editor_switch_view, view}) assign(socket, :action_error, nil) |> build_data() end :toggle_sidebar -> Notify.parent({:chat_editor_toggle_sidebar}) assign(socket, :action_error, nil) |> build_data() :toggle_panel -> Notify.parent({:chat_editor_toggle_panel}) assign(socket, :action_error, nil) |> build_data() :toggle_assistant_sidebar -> Notify.parent({:chat_editor_toggle_assistant_sidebar}) assign(socket, :action_error, nil) |> build_data() :unknown -> set_action_error(socket, "Unsupported assistant action") end end defp set_action_error(socket, message) do assign(socket, :action_error, message) |> build_data() end defp decode_payload(nil), do: %{} defp decode_payload(""), do: %{} defp decode_payload(payload) when is_binary(payload) do case Jason.decode(payload) do {:ok, decoded} when is_map(decoded) -> decoded _other -> %{} end end defp decode_payload(_payload), do: %{} defp maybe_put_form_data(payload, socket, surface_id) when is_binary(surface_id) and surface_id != "" do form_data = Map.get(socket.assigns.surface_data, surface_id, %{}) if form_data == %{}, do: payload, else: Map.put(payload, "formData", form_data) end defp maybe_put_form_data(payload, _socket, _surface_id), do: payload defp normalize_action(action) do action |> to_string() |> String.replace("_", "") |> String.downcase() |> case do "openpost" -> :open_post "openmedia" -> :open_media "opensettings" -> :open_settings "openchat" -> :open_chat "switchview" -> :switch_view "setactiveview" -> :switch_view "togglesidebar" -> :toggle_sidebar "togglepanel" -> :toggle_panel "openpanel" -> :toggle_panel "toggleassistantsidebar" -> :toggle_assistant_sidebar _other -> :unknown end end # ── HEEx-callable helpers ───────────────────────────────────────────────── @spec message_role_label(atom()) :: String.t() def message_role_label(:user), do: dgettext("ui", "You") def message_role_label(_role), do: dgettext("ui", "Assistant") defdelegate tool_call_name(tool_call), to: ToolTracking defdelegate tool_call_arguments(tool_call), to: ToolTracking @spec tool_surface_type(map()) :: String.t() def tool_surface_type(surface), do: Map.get(surface, :type, "json") @spec markdown_html(binary()) :: Phoenix.HTML.Safe.t() 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 def markdown_html(_content), do: "" @spec payload_json(map() | nil) :: String.t() def payload_json(nil), do: "{}" def payload_json(payload) when is_map(payload), do: Jason.encode!(payload) @spec chart_width(number(), term()) :: number() 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 def chart_width(_max_value, _value), do: 0 @spec truthy?(term()) :: boolean() def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true def truthy?(_value), do: false # ── HEEx components ─────────────────────────────────────────────────────── attr(:markers, :list, required: true) @spec chat_tool_markers(map()) :: Phoenix.LiveView.Rendered.t() 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 %>
<%= dgettext("ui", "Arguments") %>
<%= Jason.encode!(marker.arguments || %{}, pretty: true) %>
<%= if marker.result not in [nil, ""] do %>
<%= dgettext("ui", "Result") %>
<%= marker.result %>
<% end %>
<% end %>
<% end %> """ end attr(:surface, :map, required: true) attr(:myself, :any, required: false) @spec chat_surface(map()) :: Phoenix.LiveView.Rendered.t() 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 ─────────────────────────────────────────────────────── @surface_state_debounce_ms 500 defp persist_surface_state(socket) do conversation_id = socket.assigns.conversation_id surface_data = socket.assigns.surface_data surface_tabs = socket.assigns.surface_tabs dismissed_surfaces = socket.assigns.dismissed_surfaces case AI.put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces) do {:ok, _state} -> :ok {:error, reason} -> Logger.warning("Failed to persist surface state for conversation #{conversation_id}", reason: inspect(reason) ) end socket end defp schedule_surface_state_persist(socket) do if socket.assigns[:surface_state_timer] do Process.cancel_timer(socket.assigns[:surface_state_timer]) end timer = Process.send_after( self(), {:persist_surface_state, socket.assigns.conversation_id}, @surface_state_debounce_ms ) assign(socket, :surface_state_timer, timer) end defp active_project_id(socket) do socket.assigns[:project_id] 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: dgettext("ui", "Configure an API key in Settings to enable AI chat.") defp format_error(reason), do: inspect(reason) defp parse_integer(value) when is_integer(value), do: value defp parse_integer(value) do case Integer.parse(to_string(value)) do {int, _} -> int :error -> 0 end end end