defmodule BDS.Desktop.ShellLive.ChatEditor do
@moduledoc false
use Phoenix.Component
import Phoenix.HTML, only: [raw: 1]
alias BDS.{AI, Repo}
alias BDS.AI.ChatConversation
alias BDS.Desktop.ShellData
@render_tool_names MapSet.new([
"render_card",
"render_chart",
"render_form",
"render_list",
"render_metric",
"render_mindmap",
"render_table",
"render_tabs"
])
@tool_args_max_length 30
embed_templates "chat_editor_html/*"
def assign_socket(socket) do
assign(socket, :chat_editor, build(socket.assigns))
end
def toggle_model_selector(socket, reload) do
%{id: conversation_id} = socket.assigns.current_tab
current = Map.get(socket.assigns.chat_model_selectors_open, conversation_id, false)
socket
|> assign(:chat_model_selectors_open, Map.put(socket.assigns.chat_model_selectors_open, conversation_id, not current))
|> reload.(socket.assigns.workbench)
end
def set_model(socket, model_id, reload, append_output) do
%{id: conversation_id} = socket.assigns.current_tab
case AI.set_conversation_model(conversation_id, model_id) do
{:ok, _conversation} ->
socket
|> assign(:chat_model_selectors_open, Map.put(socket.assigns.chat_model_selectors_open, conversation_id, false))
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("Chat"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
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
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
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
def current_surface_data(socket, surface_id) when is_binary(surface_id) do
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{})
end
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
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
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)
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
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
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: tool_call_name(tool_call), arguments: tool_call_arguments(tool_call)}]))
end, reload)
end
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
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
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
def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do
case Repo.get(ChatConversation, conversation_id) do
nil -> nil
%ChatConversation{} = conversation ->
messages = AI.list_chat_messages(conversation.id)
request = Map.get(assigns.chat_editor_requests, conversation.id)
%{
id: conversation.id,
title: conversation.title || translated("chat.newChat"),
model: conversation.model,
available_models: AI.available_chat_models(conversation.model),
model_selector_open?: Map.get(assigns.chat_model_selectors_open, conversation.id, false),
input: Map.get(assigns.chat_editor_inputs, conversation.id, ""),
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: tool_markers_from_events(request),
offline?: Map.get(assigns, :offline_mode, true),
needs_api_key?: needs_api_key?(Map.get(assigns, :offline_mode, true)),
action_error: Map.get(assigns.chat_editor_action_errors, conversation.id),
send_disabled?: String.trim(Map.get(assigns.chat_editor_inputs, conversation.id, "")) == "" or not is_nil(request)
}
end
end
def build(_assigns), do: nil
def message_role_label(:user), do: translated("chat.role.you")
def message_role_label(_role), do: translated("chat.role.assistant")
def tool_call_name(tool_call) when is_map(tool_call) do
Map.get(tool_call, "name") || Map.get(tool_call, :name) || "tool"
end
def tool_call_arguments(tool_call) when is_map(tool_call) do
Map.get(tool_call, "arguments") || Map.get(tool_call, :arguments) || Map.get(tool_call, "args") || Map.get(tool_call, :args) || %{}
end
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
def markdown_html(_content), do: ""
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
def chart_width(_max_value, _value), do: 0
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
def truthy?(_value), do: false
attr :markers, :list, required: true
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 %>
<% end %>
<% end %>
"""
end
attr :surface, :map, required: true
def chat_surface(assigns) do
~H"""
<%= 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 %>
<%= column %>
<% end %>
<%= for row <- @surface.rows do %>
<%= for value <- row do %>
<%= value %>
<% end %>
<% end %>
<% "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 %>
<%= for item <- @surface.items do %>
<%= item %>
<% end %>
<% "mindmap" -> %>
<%= if present?(@surface.title) do %>
<%= @surface.title %>
<% end %>
<%= for node <- @surface.nodes do %>
<%= node.label %>
<%= if node.children != [] do %>
<%= Enum.join(node.children, ", ") %>
<% end %>
<% 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 %>
<%= if present?(@surface.submit_label) do %>