defmodule BDS.Desktop.ShellLive.ChatEditor do @moduledoc false 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(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) |> 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)) ) |> 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)) |> 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 defaults = %{ conversation_id: conversation_id, input: "", model_selector_open?: false, request: nil, surface_data: %{}, surface_tabs: %{}, dismissed_surfaces: MapSet.new(), 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 %><%= 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 %>