diff --git a/lib/bds/ai.ex b/lib/bds/ai.ex
index b41c997..eb57c39 100644
--- a/lib/bds/ai.ex
+++ b/lib/bds/ai.ex
@@ -521,11 +521,19 @@ defmodule BDS.AI do
with {:ok, response} <- runtime.generate(endpoint_with_model(endpoint, model), request, opts),
{:ok, assistant_message} <- persist_assistant_response(conversation.id, response),
:ok <- touch_conversation(conversation.id) do
+ if is_binary(Map.get(response, :content)) and String.trim(Map.get(response, :content)) != "" do
+ notify_chat_event(opts, {:chat_streaming_content, conversation.id, Map.get(response, :content)})
+ end
+
tool_calls = decode_tool_calls(Map.get(response, :tool_calls))
+ Enum.each(tool_calls, fn tool_call ->
+ notify_chat_event(opts, {:chat_tool_call, conversation.id, tool_call})
+ end)
+
cond do
tool_calls != [] and tools != [] ->
- with {:ok, tool_messages} <- execute_tool_calls(conversation.id, tool_calls, project_id),
+ with {:ok, tool_messages} <- execute_tool_calls(conversation.id, tool_calls, project_id, opts),
updated_messages <- load_chat_messages(conversation.id),
{:ok, reply} <-
chat_round(
@@ -575,7 +583,7 @@ defmodule BDS.AI do
})
end
- defp execute_tool_calls(conversation_id, tool_calls, project_id) do
+ defp execute_tool_calls(conversation_id, tool_calls, project_id, opts) do
tool_messages =
Enum.map(tool_calls, fn tool_call ->
result = execute_tool(tool_call.name, tool_call.arguments || %{}, project_id)
@@ -589,6 +597,8 @@ defmodule BDS.AI do
created_at: Persistence.now_ms()
})
+ notify_chat_event(opts, {:chat_tool_result, conversation_id, tool_call.name})
+
format_chat_message(message)
end)
@@ -652,7 +662,51 @@ defmodule BDS.AI do
%{
type: "form",
title: arguments["title"],
- fields: arguments["fields"] || []
+ fields: arguments["fields"] || [],
+ submit_label: arguments["submit_label"] || arguments["submitLabel"],
+ submit_action: arguments["submit_action"] || arguments["submitAction"]
+ }
+ end
+
+ defp execute_tool("render_card", arguments, _project_id) do
+ %{
+ type: "card",
+ title: arguments["title"],
+ subtitle: arguments["subtitle"],
+ body: arguments["body"],
+ actions: arguments["actions"] || []
+ }
+ end
+
+ defp execute_tool("render_metric", arguments, _project_id) do
+ %{
+ type: "metric",
+ label: arguments["label"],
+ value: arguments["value"]
+ }
+ end
+
+ defp execute_tool("render_list", arguments, _project_id) do
+ %{
+ type: "list",
+ title: arguments["title"],
+ items: arguments["items"] || []
+ }
+ end
+
+ defp execute_tool("render_tabs", arguments, _project_id) do
+ %{
+ type: "tabs",
+ title: arguments["title"],
+ tabs: arguments["tabs"] || []
+ }
+ end
+
+ defp execute_tool("render_mindmap", arguments, _project_id) do
+ %{
+ type: "mindmap",
+ title: arguments["title"],
+ nodes: arguments["nodes"] || []
}
end
@@ -736,9 +790,14 @@ defmodule BDS.AI do
project_tools ++
[
+ %{name: "render_card", spec: tool_spec("render_card", "Return a structured card payload", render_card_schema())},
%{name: "render_table", spec: tool_spec("render_table", "Return a structured table payload", render_table_schema())},
%{name: "render_chart", spec: tool_spec("render_chart", "Return a structured chart payload", render_chart_schema())},
- %{name: "render_form", spec: tool_spec("render_form", "Return a structured form payload", render_form_schema())}
+ %{name: "render_form", spec: tool_spec("render_form", "Return a structured form payload", render_form_schema())},
+ %{name: "render_metric", spec: tool_spec("render_metric", "Return a structured metric payload", render_metric_schema())},
+ %{name: "render_list", spec: tool_spec("render_list", "Return a structured list payload", render_list_schema())},
+ %{name: "render_tabs", spec: tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema())},
+ %{name: "render_mindmap", spec: tool_spec("render_mindmap", "Return a structured mindmap payload", render_mindmap_schema())}
]
else
[]
@@ -1162,7 +1221,61 @@ defmodule BDS.AI do
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
- "fields" => %{"type" => "array"}
+ "fields" => %{"type" => "array"},
+ "submitLabel" => %{"type" => "string"},
+ "submitAction" => %{"type" => "string"}
+ }
+ }
+ end
+
+ defp render_card_schema do
+ %{
+ "type" => "object",
+ "properties" => %{
+ "title" => %{"type" => "string"},
+ "subtitle" => %{"type" => "string"},
+ "body" => %{"type" => "string"},
+ "actions" => %{"type" => "array"}
+ }
+ }
+ end
+
+ defp render_metric_schema do
+ %{
+ "type" => "object",
+ "properties" => %{
+ "label" => %{"type" => "string"},
+ "value" => %{"type" => "string"}
+ }
+ }
+ end
+
+ defp render_list_schema do
+ %{
+ "type" => "object",
+ "properties" => %{
+ "title" => %{"type" => "string"},
+ "items" => %{"type" => "array"}
+ }
+ }
+ end
+
+ defp render_tabs_schema do
+ %{
+ "type" => "object",
+ "properties" => %{
+ "title" => %{"type" => "string"},
+ "tabs" => %{"type" => "array"}
+ }
+ }
+ end
+
+ defp render_mindmap_schema do
+ %{
+ "type" => "object",
+ "properties" => %{
+ "title" => %{"type" => "string"},
+ "nodes" => %{"type" => "array"}
}
}
end
@@ -1170,6 +1283,16 @@ defmodule BDS.AI do
defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
defp normalize_limit(_value), do: 10
+ defp notify_chat_event(opts, event) do
+ case Keyword.get(opts, :event_target) do
+ pid when is_pid(pid) -> send(pid, event)
+ callback when is_function(callback, 1) -> callback.(event)
+ _other -> :ok
+ end
+
+ :ok
+ end
+
defp truncate_text(nil, _max_length), do: ""
defp truncate_text(text, max_length) when is_binary(text) do
diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex
index 2e4f23e..bc8022a 100644
--- a/lib/bds/desktop/shell_live.ex
+++ b/lib/bds/desktop/shell_live.ex
@@ -98,6 +98,11 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:template_editor_drafts, %{})
|> assign(:chat_editor_inputs, %{})
|> assign(:chat_model_selectors_open, %{})
+ |> assign(:chat_editor_requests, %{})
+ |> assign(:chat_editor_request_refs, %{})
+ |> assign(:chat_editor_surface_data, %{})
+ |> assign(:chat_editor_surface_tabs, %{})
+ |> assign(:chat_editor_action_errors, %{})
|> assign(:misc_editor_selected_pairs, %{})
|> assign(:misc_editor_git_selected_files, %{})
|> assign(:metadata_diff_active_tabs, %{})
@@ -697,6 +702,29 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, ChatEditor.send_message(socket, &reload_shell/2, &append_output_entry/5)}
end
+ def handle_event("abort_chat_editor_message", _params, socket) do
+ {:noreply, ChatEditor.abort_message(socket, &reload_shell/2)}
+ end
+
+ def handle_event("open_chat_settings", _params, socket) do
+ {:noreply,
+ socket
+ |> clear_chat_action_error()
+ |> open_sidebar_item(%{"route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI"}, :pin)}
+ end
+
+ def handle_event("change_chat_surface_form", %{"surface" => %{"id" => surface_id, "fields" => fields}}, socket) do
+ {:noreply, ChatEditor.update_surface_form(socket, surface_id, fields, &reload_shell/2)}
+ end
+
+ def handle_event("select_chat_surface_tab", %{"surface-id" => surface_id, "index" => index}, socket) do
+ {:noreply, ChatEditor.select_surface_tab(socket, surface_id, parse_integer(index), &reload_shell/2)}
+ end
+
+ def handle_event("chat_surface_action", params, socket) do
+ {:noreply, handle_chat_surface_action(socket, params)}
+ end
+
def handle_event("rerun_misc_editor", _params, socket) do
case MiscEditor.rerun(socket) do
{:command, action} -> {:noreply, apply_shell_command(socket, action)}
@@ -1068,6 +1096,33 @@ defmodule BDS.Desktop.ShellLive do
end
@impl true
+ def handle_info({ref, result}, socket) when is_reference(ref) do
+ Process.demonitor(ref, [:flush])
+ {:noreply, ChatEditor.finish_request(socket, ref, result, &reload_shell/2, &append_output_entry/5)}
+ end
+
+ def handle_info({:DOWN, ref, :process, _pid, reason}, socket) when is_reference(ref) do
+ next_socket =
+ case reason do
+ :normal -> socket
+ _other -> ChatEditor.finish_request(socket, ref, {:error, :cancelled}, &reload_shell/2, &append_output_entry/5)
+ end
+
+ {:noreply, next_socket}
+ end
+
+ def handle_info({:chat_tool_call, conversation_id, tool_call}, socket) do
+ {:noreply, ChatEditor.note_tool_call(socket, conversation_id, tool_call, &reload_shell/2)}
+ end
+
+ def handle_info({:chat_tool_result, conversation_id, name}, socket) do
+ {:noreply, ChatEditor.note_tool_result(socket, conversation_id, name, &reload_shell/2)}
+ end
+
+ def handle_info({:chat_streaming_content, conversation_id, content}, socket) do
+ {:noreply, ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)}
+ end
+
def handle_info(:refresh_task_status, socket) do
raw_task_status = BDS.Tasks.status_snapshot()
@@ -2011,6 +2066,166 @@ defmodule BDS.Desktop.ShellLive do
defp git_history_target(_tab), do: nil
+ defp handle_chat_surface_action(socket, params) do
+ surface_id = Map.get(params, "surface-id", "")
+
+ payload =
+ params
+ |> Map.get("payload")
+ |> decode_chat_surface_payload()
+ |> maybe_put_chat_surface_form_data(socket, surface_id)
+
+ case normalize_chat_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 != "" ->
+ socket
+ |> clear_chat_action_error()
+ |> open_sidebar_item(%{"route" => "post", "id" => post_id, "title" => post_title(post_id), "subtitle" => post_subtitle(post_id)}, :pin)
+
+ _other ->
+ ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Invalid payload for openPost action", &reload_shell/2)
+ end
+
+ :open_media ->
+ case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do
+ media_id when is_binary(media_id) and media_id != "" ->
+ socket
+ |> clear_chat_action_error()
+ |> open_sidebar_item(%{"route" => "media", "id" => media_id, "title" => media_title(media_id), "subtitle" => media_subtitle(media_id)}, :pin)
+
+ _other ->
+ ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Invalid payload for openMedia action", &reload_shell/2)
+ end
+
+ :open_settings ->
+ socket
+ |> clear_chat_action_error()
+ |> open_sidebar_item(%{"route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI"}, :pin)
+
+ :open_chat ->
+ chat_id = Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") || socket.assigns.current_tab.id
+
+ socket
+ |> clear_chat_action_error()
+ |> open_sidebar_item(%{"route" => "chat", "id" => chat_id, "title" => Map.get(payload, "title", "Chat"), "subtitle" => Map.get(payload, "subtitle", "")}, :pin)
+
+ :switch_view ->
+ case safe_existing_atom(Map.get(payload, "view")) do
+ nil -> ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Invalid payload for switchView action", &reload_shell/2)
+ view ->
+ socket
+ |> clear_chat_action_error()
+ |> reload_shell(Workbench.click_activity(socket.assigns.workbench, view))
+ end
+
+ :toggle_sidebar ->
+ socket
+ |> clear_chat_action_error()
+ |> reload_shell(Workbench.toggle_sidebar(socket.assigns.workbench))
+
+ :toggle_panel ->
+ socket
+ |> clear_chat_action_error()
+ |> reload_shell(Workbench.toggle_panel(socket.assigns.workbench))
+
+ :toggle_assistant_sidebar ->
+ socket
+ |> clear_chat_action_error()
+ |> reload_shell(Workbench.toggle_assistant_sidebar(socket.assigns.workbench))
+
+ :unknown ->
+ ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Unsupported assistant action", &reload_shell/2)
+ end
+ end
+
+ defp clear_chat_action_error(%{assigns: %{current_tab: %{type: :chat, id: conversation_id}}} = socket) do
+ assign(socket, :chat_editor_action_errors, Map.delete(socket.assigns.chat_editor_action_errors, conversation_id))
+ end
+
+ defp clear_chat_action_error(socket), do: socket
+
+ defp decode_chat_surface_payload(nil), do: %{}
+ defp decode_chat_surface_payload(""), do: %{}
+
+ defp decode_chat_surface_payload(payload) when is_binary(payload) do
+ case Jason.decode(payload) do
+ {:ok, decoded} when is_map(decoded) -> decoded
+ _other -> %{}
+ end
+ end
+
+ defp decode_chat_surface_payload(_payload), do: %{}
+
+ defp maybe_put_chat_surface_form_data(payload, socket, surface_id) when is_binary(surface_id) and surface_id != "" do
+ form_data = ChatEditor.current_surface_data(socket, surface_id)
+
+ if form_data == %{} do
+ payload
+ else
+ Map.put(payload, "formData", form_data)
+ end
+ end
+
+ defp maybe_put_chat_surface_form_data(payload, _socket, _surface_id), do: payload
+
+ defp normalize_chat_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
+
+ defp post_title(post_id) do
+ case Repo.get(Post, post_id) do
+ %Post{} = post -> post.title || post.slug || post.id
+ _other -> "Post"
+ end
+ end
+
+ defp post_subtitle(post_id) do
+ case Repo.get(Post, post_id) do
+ %Post{} = post -> post.slug || "draft"
+ _other -> "draft"
+ end
+ end
+
+ defp media_title(media_id) do
+ case Repo.get(Media, media_id) do
+ %Media{} = media -> media.title || media.filename || media.id
+ _other -> "Media"
+ end
+ end
+
+ defp media_subtitle(media_id) do
+ case Repo.get(Media, media_id) do
+ %Media{} = media -> media.filename || media.mime_type || "media"
+ _other -> "media"
+ end
+ end
+
+ defp parse_integer(value) when is_integer(value), do: value
+
+ defp parse_integer(value) do
+ case Integer.parse(to_string(value || "0")) do
+ {parsed, _rest} -> parsed
+ :error -> 0
+ end
+ end
+
defp short_commit_hash(hash) when is_binary(hash), do: String.slice(hash, 0, 7)
defp short_commit_hash(_hash), do: "-------"
diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex
index 6d92b2b..49f38f9 100644
--- a/lib/bds/desktop/shell_live/chat_editor.ex
+++ b/lib/bds/desktop/shell_live/chat_editor.ex
@@ -2,11 +2,24 @@ 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
@@ -46,27 +59,137 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> 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 ->
- case AI.send_chat_message(conversation_id, message, project_id: socket.assigns.projects.active_project_id) do
- {:ok, _result} ->
- socket
- |> assign(:chat_editor_inputs, Map.put(socket.assigns.chat_editor_inputs, conversation_id, ""))
- |> reload.(socket.assigns.workbench)
+ 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"), inspect(reason), nil, "error")
+ |> append_output.(translated("Chat"), format_error(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
@@ -76,6 +199,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor 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"),
@@ -83,8 +209,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
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(AI.list_chat_messages(conversation.id)),
- offline?: Map.get(assigns, :offline_mode, true)
+ 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
@@ -98,25 +231,288 @@ defmodule BDS.Desktop.ShellLive.ChatEditor 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")
- defp build_entries(messages) do
- {entries, current_entry} =
- Enum.reduce(messages, {[], nil}, fn message, {entries, current_entry} ->
+ 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 %>
+
+ <% 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 %>
+
+ <%= action.label %>
+
+ <% end %>
+
+ <% end %>
+
+
+ <% "table" -> %>
+ <%= if present?(@surface.title) do %>
+ <%= @surface.title %>
+ <% 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 %>
+
+ <%= tab.label %>
+
+ <% 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 %>
+
+
+ <%= @surface.submit_label %>
+
+
+ <% end %>
+
+ <% "text" -> %>
+ <%= @surface.body %>
+
+ <% _other -> %>
+ <%= Jason.encode!(@surface.raw || %{}, pretty: true) %>
+ <% end %>
+
+ """
+ end
+
+ defp build_entries(messages, assigns) do
+ {entries, current_entry, _turn_index} =
+ Enum.reduce(messages, {[], nil, -1}, fn message, {entries, current_entry, turn_index} ->
case message.role do
:tool ->
if current_entry && current_entry.role == :assistant do
- {entries, append_tool_surface(current_entry, message)}
+ {entries, append_tool_surface(current_entry, message), turn_index}
else
- {entries, current_entry}
+ {entries, current_entry, turn_index}
end
:system ->
- {entries, current_entry}
+ {entries, current_entry, turn_index}
+
+ :user ->
+ entries = finalize_entry(entries, current_entry)
+ next_turn_index = turn_index + 1
+ {entries, start_entry(message, next_turn_index, assigns), next_turn_index}
_other ->
entries = finalize_entry(entries, current_entry)
- {entries, start_entry(message)}
+ {entries, start_entry(message, turn_index, assigns), turn_index}
end
end)
@@ -128,17 +524,23 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp finalize_entry(entries, nil), do: entries
defp finalize_entry(entries, entry), do: [entry | entries]
- defp start_entry(message) do
+ defp start_entry(message, turn_index, assigns) do
+ tool_markers = normalize_tool_calls(message.tool_calls)
+
%{
id: message.id,
role: message.role,
content: message.content || "",
- tool_markers: normalize_tool_calls(message.tool_calls),
+ turn_index: turn_index,
+ tool_markers: tool_markers,
+ inline_surfaces: build_render_surfaces(tool_markers, message.id, assigns),
tool_surfaces: []
}
end
defp append_tool_surface(entry, message) do
+ entry = mark_tool_call_completed(entry, message.tool_call_id)
+
case normalize_tool_surface(message.content) do
nil -> entry
surface -> update_in(entry.tool_surfaces, &(&1 ++ [surface]))
@@ -147,15 +549,254 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp normalize_tool_calls(tool_calls) when is_list(tool_calls) do
Enum.map(tool_calls, fn tool_call ->
+ arguments = tool_call_arguments(tool_call)
+
%{
+ id: Map.get(tool_call, "id") || Map.get(tool_call, :id),
name: tool_call_name(tool_call),
- arguments: Map.get(tool_call, "arguments") || Map.get(tool_call, :arguments) || Map.get(tool_call, "args") || Map.get(tool_call, :args) || %{}
+ arguments: arguments,
+ args_preview: tool_arguments_preview(arguments),
+ complete?: false
}
end)
end
defp normalize_tool_calls(_tool_calls), do: []
+ defp build_render_surfaces(tool_calls, message_id, assigns) do
+ tool_calls
+ |> Enum.with_index()
+ |> Enum.flat_map(fn {tool_call, index} ->
+ case build_render_surface(tool_call, "#{message_id}-surface-#{index}", assigns) do
+ nil -> []
+ surface -> [surface]
+ end
+ end)
+ end
+
+ defp build_render_surface(%{name: name, arguments: arguments}, surface_id, assigns) do
+ if MapSet.member?(@render_tool_names, name) do
+ do_build_render_surface(name, arguments || %{}, surface_id, assigns)
+ end
+ end
+
+ defp do_build_render_surface("render_card", arguments, surface_id, _assigns) do
+ %{
+ id: surface_id,
+ type: "card",
+ title: map_value(arguments, "title"),
+ subtitle: map_value(arguments, "subtitle"),
+ body: map_value(arguments, "body", ""),
+ actions: decode_surface_actions(map_value(arguments, "actions", []))
+ }
+ end
+
+ defp do_build_render_surface("render_table", arguments, surface_id, _assigns) do
+ %{
+ id: surface_id,
+ type: "table",
+ title: map_value(arguments, "title"),
+ columns: stringify_list(map_value(arguments, "columns", [])),
+ rows: Enum.map(List.wrap(map_value(arguments, "rows", [])), &stringify_list/1)
+ }
+ end
+
+ defp do_build_render_surface("render_chart", arguments, surface_id, _assigns) do
+ series =
+ map_value(arguments, "series", [])
+ |> List.wrap()
+ |> Enum.map(fn entry ->
+ %{
+ label: map_value(entry, "label", translated("chat.role.assistant")),
+ value: numeric_value(map_value(entry, "value", 0)),
+ segments: List.wrap(map_value(entry, "segments", []))
+ }
+ end)
+
+ %{
+ id: surface_id,
+ type: "chart",
+ title: map_value(arguments, "title"),
+ chart_type: map_value(arguments, "chart_type", "bar"),
+ series: series,
+ max_value: Enum.max([0 | Enum.map(series, & &1.value)])
+ }
+ end
+
+ defp do_build_render_surface("render_metric", arguments, surface_id, _assigns) do
+ %{
+ id: surface_id,
+ type: "metric",
+ label: map_value(arguments, "label", "Metric"),
+ value: map_value(arguments, "value", "")
+ }
+ end
+
+ defp do_build_render_surface("render_list", arguments, surface_id, _assigns) do
+ %{
+ id: surface_id,
+ type: "list",
+ title: map_value(arguments, "title"),
+ items: stringify_list(map_value(arguments, "items", []))
+ }
+ end
+
+ defp do_build_render_surface("render_mindmap", arguments, surface_id, _assigns) do
+ nodes =
+ arguments
+ |> map_value("nodes", [])
+ |> List.wrap()
+ |> Enum.map(fn node ->
+ %{
+ id: map_value(node, "id"),
+ label: map_value(node, "label", "Node"),
+ children: stringify_list(map_value(node, "children", []))
+ }
+ end)
+
+ %{
+ id: surface_id,
+ type: "mindmap",
+ title: map_value(arguments, "title"),
+ nodes: nodes
+ }
+ end
+
+ defp do_build_render_surface("render_form", arguments, surface_id, assigns) do
+ stored_fields = Map.get(assigns.chat_editor_surface_data, surface_id, %{})
+
+ fields =
+ arguments
+ |> map_value("fields", [])
+ |> List.wrap()
+ |> Enum.map(fn field ->
+ key = map_value(field, "key", "field")
+
+ %{
+ key: key,
+ label: map_value(field, "label", key),
+ input_type: map_value(field, "inputType") || map_value(field, "input_type", "text"),
+ placeholder: map_value(field, "placeholder"),
+ value: Map.get(stored_fields, key, map_value(field, "defaultValue") || map_value(field, "default_value")),
+ options: decode_surface_options(map_value(field, "options", [])),
+ required?: truthy?(map_value(field, "required", false))
+ }
+ end)
+
+ %{
+ id: surface_id,
+ type: "form",
+ title: map_value(arguments, "title"),
+ fields: fields,
+ submit_label: map_value(arguments, "submitLabel") || map_value(arguments, "submit_label", translated("chat.stop")),
+ submit_action: map_value(arguments, "submitAction") || map_value(arguments, "submit_action", "submitForm")
+ }
+ end
+
+ defp do_build_render_surface("render_tabs", arguments, surface_id, assigns) do
+ tabs =
+ arguments
+ |> map_value("tabs", [])
+ |> List.wrap()
+ |> Enum.with_index()
+ |> Enum.map(fn {tab, tab_index} ->
+ %{
+ label: map_value(tab, "label", "Tab #{tab_index + 1}"),
+ content:
+ tab
+ |> map_value("content", [])
+ |> List.wrap()
+ |> Enum.with_index()
+ |> Enum.map(fn {content, content_index} ->
+ build_tab_surface(content, "#{surface_id}-tab-#{tab_index}-#{content_index}", assigns)
+ end)
+ }
+ end)
+
+ %{
+ id: surface_id,
+ type: "tabs",
+ title: map_value(arguments, "title"),
+ tabs: tabs,
+ selected_index: Map.get(assigns.chat_editor_surface_tabs, surface_id, 0)
+ }
+ end
+
+ defp do_build_render_surface(_name, arguments, surface_id, _assigns) do
+ %{id: surface_id, type: "json", raw: arguments}
+ end
+
+ defp build_tab_surface(%{} = content, surface_id, assigns) do
+ type = map_value(content, "type", "text")
+
+ case type do
+ render_type when render_type in ["card", "chart", "form", "list", "metric", "mindmap", "table", "tabs"] ->
+ do_build_render_surface("render_#{render_type}", Map.delete(content, "type"), surface_id, assigns)
+
+ "text" ->
+ %{id: surface_id, type: "text", body: map_value(content, "body") || map_value(content, "text", "")}
+
+ _other ->
+ %{id: surface_id, type: "json", raw: content}
+ end
+ end
+
+ defp build_tab_surface(content, surface_id, _assigns) do
+ %{id: surface_id, type: "text", body: to_string(content || "")}
+ end
+
+ defp mark_tool_call_completed(entry, tool_call_id) when is_binary(tool_call_id) do
+ update_in(entry.tool_markers, fn markers ->
+ Enum.map(markers, fn marker ->
+ if marker.id == tool_call_id do
+ %{marker | complete?: true}
+ else
+ marker
+ end
+ end)
+ end)
+ end
+
+ defp mark_tool_call_completed(entry, _tool_call_id), do: entry
+
+ defp decode_surface_actions(actions) when is_list(actions) do
+ Enum.map(actions, fn action ->
+ %{
+ label: map_value(action, "label", translated("chat.openSettings")),
+ action: map_value(action, "action", "openSettings"),
+ payload: map_value(action, "payload", %{})
+ }
+ end)
+ end
+
+ defp decode_surface_actions(_actions), do: []
+
+ defp decode_surface_options(options) when is_list(options) do
+ Enum.map(options, fn option ->
+ %{
+ label: map_value(option, "label", ""),
+ value: map_value(option, "value", "")
+ }
+ end)
+ end
+
+ defp decode_surface_options(_options), do: []
+
+ defp tool_arguments_preview(arguments) when is_map(arguments) do
+ arguments
+ |> Enum.map(fn {key, value} -> "#{key}: #{preview_value(value)}" end)
+ |> Enum.join(", ")
+ end
+
+ defp tool_arguments_preview(_arguments), do: ""
+
+ defp preview_value(value) when is_binary(value) do
+ quoted = if String.length(value) > @tool_args_max_length, do: String.slice(value, 0, @tool_args_max_length) <> "...", else: value
+ inspect(quoted)
+ end
+
+ defp preview_value(value), do: inspect(value)
+
defp normalize_tool_surface(content) when is_binary(content) do
case Jason.decode(content) do
{:ok, %{"type" => type} = decoded} ->
@@ -175,5 +816,138 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp normalize_tool_surface(_content), do: nil
+ defp pending_user_message(_messages, nil), do: nil
+
+ defp pending_user_message(messages, %{message: message}) when is_binary(message) do
+ case messages |> Enum.reverse() |> Enum.find(&(&1.role not in [:system, :tool])) do
+ %{role: :user, content: ^message} -> nil
+ _other -> message
+ end
+ end
+
+ defp pending_user_message(_messages, _request), do: nil
+
+ defp streaming_content(nil), do: ""
+ defp streaming_content(%{content: content}) when is_binary(content), do: content
+ defp streaming_content(_request), do: ""
+
+ defp tool_markers_from_events(nil), do: []
+
+ defp tool_markers_from_events(%{tool_events: tool_events}) do
+ Enum.reduce(tool_events || [], [], fn event, markers ->
+ case event.type do
+ :call -> markers ++ [%{id: nil, name: event.name, arguments: event.arguments, args_preview: tool_arguments_preview(event.arguments || %{}), complete?: false}]
+
+ :result ->
+ Enum.reverse(markers)
+ |> mark_last_matching_complete(event.name)
+ |> Enum.reverse()
+ end
+ end)
+ end
+
+ defp mark_last_matching_complete(markers, name) do
+ {updated, found?} =
+ Enum.map_reduce(markers, false, fn marker, found? ->
+ cond do
+ found? -> {marker, true}
+ marker.name == name and not marker.complete? -> {%{marker | complete?: true}, true}
+ true -> {marker, false}
+ end
+ end)
+
+ if found?, do: updated, else: updated
+ end
+
+ 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 needs_api_key?(true), do: false
+
+ defp needs_api_key?(false) do
+ case AI.get_endpoint(:online) do
+ {:ok, %{url: url, model: model, api_key: api_key}} -> blank?(url) or blank?(model) or blank?(api_key)
+ _other -> true
+ end
+ end
+
+ defp blank?(value) when is_binary(value), do: String.trim(value) == ""
+ defp blank?(nil), do: true
+ defp blank?(_value), do: false
+
+ 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 stringify_list(values) when is_list(values), do: Enum.map(values, &to_string/1)
+ defp stringify_list(value), do: List.wrap(value) |> Enum.map(&to_string/1)
+
+ defp numeric_value(value) when is_integer(value), do: value
+ defp numeric_value(value) when is_float(value), do: value
+
+ defp numeric_value(value) when is_binary(value) do
+ case Float.parse(value) do
+ {parsed, ""} -> parsed
+ _other -> 0
+ end
+ end
+
+ defp numeric_value(_value), do: 0
+
+ defp map_value(map, key, default \\ nil)
+
+ defp map_value(map, key, default) when is_map(map) and is_binary(key) do
+ Map.get(map, key, Map.get(map, String.to_atom(key), default))
+ rescue
+ ArgumentError -> Map.get(map, key, default)
+ end
+
+ defp map_value(_map, _key, default), do: default
+
+ defp format_error(%{kind: :endpoint_not_configured}), do: translated("chat.apiKeyRequiredDescription")
+ defp format_error(reason), do: inspect(reason)
+
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
end
diff --git a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex
index 35674b9..0776e6a 100644
--- a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex
+++ b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex
@@ -1,40 +1,58 @@
-