chore: converted ai chat to a live component
This commit is contained in:
@@ -53,8 +53,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
tab_subtitle: 2,
|
tab_subtitle: 2,
|
||||||
tab_id_for_route: 2,
|
tab_id_for_route: 2,
|
||||||
tab_intent: 2,
|
tab_intent: 2,
|
||||||
sidebar_route_atom: 1,
|
sidebar_route_atom: 1
|
||||||
parse_integer: 1
|
|
||||||
]
|
]
|
||||||
|
|
||||||
alias BDS.Projects
|
alias BDS.Projects
|
||||||
@@ -164,14 +163,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> assign(:project_menu_open, false)
|
|> assign(:project_menu_open, false)
|
||||||
|> assign(:sidebar_filters_by_view, %{})
|
|> assign(:sidebar_filters_by_view, %{})
|
||||||
|> assign(:sidebar_filter_panels, %{})
|
|> assign(:sidebar_filter_panels, %{})
|
||||||
|> assign(:chat_editor_inputs, %{})
|
|
||||||
|> assign(:chat_model_selectors_open, %{})
|
|
||||||
|> assign(:chat_editor_requests, %{})
|
|
||||||
|> assign(:chat_editor_request_refs, %{})
|
|> assign(:chat_editor_request_refs, %{})
|
||||||
|> assign(:chat_editor_surface_data, %{})
|
|
||||||
|> assign(:chat_editor_surface_tabs, %{})
|
|
||||||
|> assign(:chat_editor_dismissed_surfaces, MapSet.new())
|
|
||||||
|> assign(:chat_editor_action_errors, %{})
|
|
||||||
|> assign(:import_editor_analysis_states, %{})
|
|> assign(:import_editor_analysis_states, %{})
|
||||||
|> assign(:import_editor_analysis_task_refs, %{})
|
|> assign(:import_editor_analysis_task_refs, %{})
|
||||||
|> assign(:import_editor_execution_states, %{})
|
|> assign(:import_editor_execution_states, %{})
|
||||||
@@ -331,65 +323,6 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
{:noreply, apply_shell_command(socket, action)}
|
{:noreply, apply_shell_command(socket, action)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("change_chat_editor_input", %{"message" => message}, socket) do
|
|
||||||
{:noreply, ChatEditor.update_input(socket, message, &reload_shell/2)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("toggle_chat_model_selector", _params, socket) do
|
|
||||||
{:noreply, ChatEditor.toggle_model_selector(socket, &reload_shell/2)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("select_chat_model", %{"model" => model_id}, socket) do
|
|
||||||
{:noreply, ChatEditor.set_model(socket, model_id, &reload_shell/2, &append_output_entry/5)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("send_chat_editor_message", _params, socket) 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
|
|
||||||
|> ChatSurface.clear_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("dismiss_chat_surface", %{"surface-id" => surface_id}, socket) do
|
|
||||||
{:noreply, ChatEditor.dismiss_surface(socket, surface_id, &reload_shell/2)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("chat_surface_action", params, socket) do
|
|
||||||
{:noreply,
|
|
||||||
ChatSurface.handle_action(socket, params, %{
|
|
||||||
reload: &reload_shell/2,
|
|
||||||
open_sidebar: &open_sidebar_item/3
|
|
||||||
})}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("change_import_editor_definition", %{"import_definition" => params}, socket) do
|
def handle_event("change_import_editor_definition", %{"import_definition" => params}, socket) do
|
||||||
{:noreply, ImportEditor.change_definition(socket, params, &reload_shell/2)}
|
{:noreply, ImportEditor.change_definition(socket, params, &reload_shell/2)}
|
||||||
end
|
end
|
||||||
@@ -957,9 +890,19 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
&append_output_entry/5
|
&append_output_entry/5
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
Map.has_key?(socket.assigns.chat_editor_request_refs, ref) ->
|
||||||
|
{conversation_id, remaining_refs} = Map.pop(socket.assigns.chat_editor_request_refs, ref)
|
||||||
|
|
||||||
|
send_update(ChatEditor,
|
||||||
|
id: "chat-editor-#{conversation_id}",
|
||||||
|
action: :finish_request,
|
||||||
|
result: result
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :chat_editor_request_refs, remaining_refs)}
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
{:noreply,
|
{:noreply, socket}
|
||||||
ChatEditor.finish_request(socket, ref, result, &reload_shell/2, &append_output_entry/5)}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -986,20 +929,23 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
&append_output_entry/5
|
&append_output_entry/5
|
||||||
)
|
)
|
||||||
|
|
||||||
true ->
|
Map.has_key?(socket.assigns.chat_editor_request_refs, ref) ->
|
||||||
case reason do
|
{conversation_id, remaining_refs} = Map.pop(socket.assigns.chat_editor_request_refs, ref)
|
||||||
:normal ->
|
|
||||||
socket
|
|
||||||
|
|
||||||
_other ->
|
if reason == :normal do
|
||||||
ChatEditor.finish_request(
|
assign(socket, :chat_editor_request_refs, remaining_refs)
|
||||||
socket,
|
else
|
||||||
ref,
|
send_update(ChatEditor,
|
||||||
{:error, :cancelled},
|
id: "chat-editor-#{conversation_id}",
|
||||||
&reload_shell/2,
|
action: :finish_request,
|
||||||
&append_output_entry/5
|
result: {:error, :cancelled}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assign(socket, :chat_editor_request_refs, remaining_refs)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
true ->
|
||||||
|
socket
|
||||||
end
|
end
|
||||||
|
|
||||||
{:noreply, next_socket}
|
{:noreply, next_socket}
|
||||||
@@ -1027,16 +973,80 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:chat_tool_call, conversation_id, tool_call}, socket) do
|
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)}
|
send_update(ChatEditor,
|
||||||
|
id: "chat-editor-#{conversation_id}",
|
||||||
|
action: :note_tool_call,
|
||||||
|
tool_call: tool_call
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:chat_tool_result, conversation_id, name}, socket) do
|
def handle_info({:chat_tool_result, conversation_id, name}, socket) do
|
||||||
{:noreply, ChatEditor.note_tool_result(socket, conversation_id, name, &reload_shell/2)}
|
send_update(ChatEditor,
|
||||||
|
id: "chat-editor-#{conversation_id}",
|
||||||
|
action: :note_tool_result,
|
||||||
|
name: name
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:chat_streaming_content, conversation_id, content}, socket) do
|
def handle_info({:chat_streaming_content, conversation_id, content}, socket) do
|
||||||
|
send_update(ChatEditor,
|
||||||
|
id: "chat-editor-#{conversation_id}",
|
||||||
|
action: :note_streaming_content,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_editor_task_started, conversation_id, ref}, socket) do
|
||||||
|
refs = Map.put(socket.assigns.chat_editor_request_refs, ref, conversation_id)
|
||||||
|
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_editor_task_cancelled, _conversation_id, ref}, socket) do
|
||||||
|
refs = Map.delete(socket.assigns.chat_editor_request_refs, ref)
|
||||||
|
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_editor_output, title, message, level}, socket) do
|
||||||
|
{:noreply, append_output_entry(socket, title, message, nil, level)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_editor_tab_meta, conversation_id, title, subtitle}, socket) do
|
||||||
|
tab_meta =
|
||||||
|
Map.put(socket.assigns.tab_meta, {:chat, conversation_id}, %{
|
||||||
|
title: title,
|
||||||
|
subtitle: subtitle || ""
|
||||||
|
})
|
||||||
|
|
||||||
{:noreply,
|
{:noreply,
|
||||||
ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)}
|
socket
|
||||||
|
|> assign(:tab_meta, tab_meta)
|
||||||
|
|> reload_shell(socket.assigns.workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:open_sidebar_item, params, intent}, socket) do
|
||||||
|
{:noreply, open_sidebar_item(socket, params, intent)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_editor_toggle_sidebar}, socket) do
|
||||||
|
{:noreply, reload_shell(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_editor_toggle_panel}, socket) do
|
||||||
|
{:noreply, reload_shell(socket, Workbench.toggle_panel(socket.assigns.workbench))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_editor_toggle_assistant_sidebar}, socket) do
|
||||||
|
{:noreply, reload_shell(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_editor_switch_view, view}, socket) do
|
||||||
|
{:noreply, reload_shell(socket, Workbench.click_activity(socket.assigns.workbench, view))}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_info({:entity_changed, payload}, socket) when is_map(payload) do
|
def handle_info({:entity_changed, payload}, socket) when is_map(payload) do
|
||||||
@@ -1301,7 +1311,6 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|
||||||
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|
||||||
|> assign(:current_tab, current_tab(workbench))
|
|> assign(:current_tab, current_tab(workbench))
|
||||||
|> assign_chat_editor()
|
|
||||||
|> assign_import_editor()
|
|> assign_import_editor()
|
||||||
|> assign_misc_editor()
|
|> assign_misc_editor()
|
||||||
end
|
end
|
||||||
@@ -1345,10 +1354,6 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
Enum.find(tabs, &(&1.type == type and &1.id == id))
|
Enum.find(tabs, &(&1.type == type and &1.id == id))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp assign_chat_editor(socket) do
|
|
||||||
ChatEditor.assign_socket(socket)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp assign_import_editor(socket) do
|
defp assign_import_editor(socket) do
|
||||||
ImportEditor.assign_socket(socket)
|
ImportEditor.assign_socket(socket)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,151 +1,229 @@
|
|||||||
defmodule BDS.Desktop.ShellLive.ChatEditor do
|
defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use Phoenix.Component
|
use Phoenix.LiveComponent
|
||||||
|
|
||||||
import Phoenix.HTML, only: [raw: 1]
|
import Phoenix.HTML, only: [raw: 1]
|
||||||
|
|
||||||
alias BDS.AI
|
alias BDS.{AI, BoundedAtoms, MapUtils, Persistence}
|
||||||
alias BDS.MapUtils
|
|
||||||
alias BDS.Persistence
|
|
||||||
alias BDS.Desktop.ShellData
|
alias BDS.Desktop.ShellData
|
||||||
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
|
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
|
||||||
|
alias BDS.Desktop.ShellLive.TabHelpers
|
||||||
|
|
||||||
embed_templates("chat_editor_html/*")
|
embed_templates("chat_editor_html/*")
|
||||||
|
|
||||||
# ── Public API: state assignment ───────────────────────────────────────────
|
# ── LiveComponent lifecycle ────────────────────────────────────────────────
|
||||||
|
|
||||||
@spec assign_socket(term()) :: term()
|
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
|
||||||
def assign_socket(socket) do
|
@impl true
|
||||||
assign(socket, :chat_editor, MessageBuild.build(socket.assigns))
|
def update(%{action: :finish_request, result: result}, socket) do
|
||||||
|
{:ok, do_finish_request(socket, result)}
|
||||||
end
|
end
|
||||||
|
|
||||||
defdelegate build(assigns), to: MessageBuild
|
def update(%{action: :note_tool_call, tool_call: tool_call}, socket) do
|
||||||
|
{:ok, do_note_tool_call(socket, tool_call)}
|
||||||
|
end
|
||||||
|
|
||||||
# ── Public API: model selection ────────────────────────────────────────────
|
def update(%{action: :note_tool_result, name: name}, socket) do
|
||||||
|
{:ok, do_note_tool_result(socket, name)}
|
||||||
|
end
|
||||||
|
|
||||||
defdelegate toggle_model_selector(socket, reload), to: ModelSelection
|
def update(%{action: :note_streaming_content, content: content}, socket) do
|
||||||
defdelegate set_model(socket, model_id, reload, append_output), to: ModelSelection
|
{:ok, do_note_streaming_content(socket, content)}
|
||||||
|
end
|
||||||
# ── 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
|
|
||||||
|
|
||||||
|
def update(assigns, socket) do
|
||||||
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(
|
|> assign(assigns)
|
||||||
:chat_editor_inputs,
|
|> ensure_state()
|
||||||
Map.put(socket.assigns.chat_editor_inputs, conversation_id, to_string(value || ""))
|
|> build_data()
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec render(map()) :: Phoenix.LiveView.Rendered.t()
|
||||||
|
@impl true
|
||||||
|
def render(%{chat_editor: nil} = assigns), do: ~H"<div></div>"
|
||||||
|
|
||||||
|
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_parent({:chat_editor_output, translated("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_parent(
|
||||||
|
{:open_sidebar_item,
|
||||||
|
%{
|
||||||
|
"route" => "settings",
|
||||||
|
"id" => "settings-ai",
|
||||||
|
"title" => "Settings",
|
||||||
|
"subtitle" => "AI"
|
||||||
|
}, :pin}
|
||||||
)
|
)
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec update_surface_form(term(), term(), term(), term()) :: term()
|
# ── State initialisation ──────────────────────────────────────────────────
|
||||||
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
|
defp ensure_state(socket) do
|
||||||
|> assign(:chat_editor_surface_data, next_data)
|
conversation_id = socket.assigns.current_tab.id
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
|
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
|
end
|
||||||
|
|
||||||
@spec select_surface_tab(term(), term(), term(), term()) :: term()
|
# ── Data builder ──────────────────────────────────────────────────────────
|
||||||
def select_surface_tab(socket, surface_id, index, reload)
|
|
||||||
when is_binary(surface_id) and is_integer(index) and index >= 0 do
|
defp build_data(socket) do
|
||||||
socket
|
conversation_id = socket.assigns.conversation_id
|
||||||
|> assign(
|
request = socket.assigns.request
|
||||||
:chat_editor_surface_tabs,
|
|
||||||
Map.put(socket.assigns.chat_editor_surface_tabs, surface_id, index)
|
fake_assigns = %{
|
||||||
)
|
current_tab: socket.assigns.current_tab,
|
||||||
|> reload.(socket.assigns.workbench)
|
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
|
end
|
||||||
|
|
||||||
@spec dismiss_surface(term(), term(), term()) :: term()
|
# ── Messaging ──────────────────────────────────────────────────────────────
|
||||||
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()
|
defp do_send_message(socket) do
|
||||||
def current_surface_data(socket, surface_id) when is_binary(surface_id) do
|
conversation_id = socket.assigns.conversation_id
|
||||||
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{})
|
message = String.trim(socket.assigns.input || "")
|
||||||
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
|
cond do
|
||||||
message == "" ->
|
message == "" ->
|
||||||
reload.(socket, socket.assigns.workbench)
|
build_data(socket)
|
||||||
|
|
||||||
Map.has_key?(socket.assigns.chat_editor_requests, conversation_id) ->
|
not is_nil(socket.assigns.request) ->
|
||||||
reload.(socket, socket.assigns.workbench)
|
build_data(socket)
|
||||||
|
|
||||||
socket.assigns.offline_mode ->
|
socket.assigns.offline_mode ->
|
||||||
socket
|
notify_parent(
|
||||||
|> append_output.(
|
{:chat_editor_output, translated("Chat"),
|
||||||
translated("Chat"),
|
translated("Automatic AI actions stay gated by airplane mode."), "info"}
|
||||||
translated("Automatic AI actions stay gated by airplane mode."),
|
|
||||||
nil,
|
|
||||||
"info"
|
|
||||||
)
|
)
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
|
build_data(socket)
|
||||||
|
|
||||||
ModelSelection.needs_api_key?(false) ->
|
ModelSelection.needs_api_key?(false) ->
|
||||||
reload.(socket, socket.assigns.workbench)
|
build_data(socket)
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
live_view_pid = self()
|
parent = self()
|
||||||
started_at = Persistence.now_ms()
|
started_at = Persistence.now_ms()
|
||||||
|
|
||||||
task =
|
task =
|
||||||
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
||||||
AI.send_chat_message(conversation_id, message,
|
AI.send_chat_message(conversation_id, message,
|
||||||
project_id: socket.assigns.projects.active_project_id,
|
project_id: active_project_id(socket),
|
||||||
event_target: live_view_pid
|
event_target: parent
|
||||||
)
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
:ok = allow_repo_sandbox(task.pid)
|
:ok = allow_repo_sandbox(task.pid)
|
||||||
|
|
||||||
|
notify_parent({:chat_editor_task_started, conversation_id, task.ref})
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(
|
|> assign(:input, "")
|
||||||
:chat_editor_inputs,
|
|> assign(:request, %{
|
||||||
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,
|
ref: task.ref,
|
||||||
pid: task.pid,
|
pid: task.pid,
|
||||||
started_at: started_at,
|
started_at: started_at,
|
||||||
@@ -153,50 +231,54 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
content: "",
|
content: "",
|
||||||
tool_events: []
|
tool_events: []
|
||||||
})
|
})
|
||||||
)
|
|> assign(:action_error, nil)
|
||||||
|> assign(
|
|> build_data()
|
||||||
: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
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec abort_message(term(), term()) :: term()
|
defp do_abort_message(socket) do
|
||||||
def abort_message(socket, reload) do
|
conversation_id = socket.assigns.conversation_id
|
||||||
%{id: conversation_id} = socket.assigns.current_tab
|
|
||||||
|
|
||||||
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do
|
case socket.assigns.request do
|
||||||
nil ->
|
nil ->
|
||||||
reload.(socket, socket.assigns.workbench)
|
build_data(socket)
|
||||||
|
|
||||||
%{ref: ref} = _request ->
|
%{ref: ref} = _request ->
|
||||||
:ok = AI.cancel_chat(conversation_id)
|
:ok = AI.cancel_chat(conversation_id)
|
||||||
|
|
||||||
|
notify_parent({:chat_editor_task_cancelled, conversation_id, ref})
|
||||||
|
|
||||||
|
# Allow the terminated task's DB connection to be cleaned up before rebuilding.
|
||||||
|
Process.sleep(20)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(
|
|> assign(:request, nil)
|
||||||
:chat_editor_requests,
|
|> build_data()
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec note_tool_call(term(), term(), term(), term()) :: term()
|
defp do_finish_request(socket, result) do
|
||||||
def note_tool_call(socket, conversation_id, tool_call, reload)
|
case result do
|
||||||
when is_binary(conversation_id) and is_map(tool_call) do
|
{:ok, reply} ->
|
||||||
update_request(
|
socket
|
||||||
socket,
|
|> update_tab_meta_from_reply(reply)
|
||||||
conversation_id,
|
|> assign(:request, nil)
|
||||||
fn request ->
|
|> build_data()
|
||||||
|
|
||||||
|
{:error, :cancelled} ->
|
||||||
|
assign(socket, :request, nil) |> build_data()
|
||||||
|
|
||||||
|
{:error, %{kind: :endpoint_not_configured}} ->
|
||||||
|
assign(socket, :request, nil) |> build_data()
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
notify_parent({:chat_editor_output, translated("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(
|
update_in(
|
||||||
request.tool_events,
|
request.tool_events,
|
||||||
&(&1 ++
|
&(&1 ++
|
||||||
@@ -209,99 +291,208 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
}
|
}
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
end,
|
end)
|
||||||
reload
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec note_tool_result(term(), term(), term(), term()) :: term()
|
defp do_note_tool_result(socket, name) when is_binary(name) do
|
||||||
def note_tool_result(socket, conversation_id, name, reload)
|
update_request(socket, fn request ->
|
||||||
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}]))
|
update_in(request.tool_events, &(&1 ++ [%{type: :result, name: name}]))
|
||||||
end,
|
end)
|
||||||
reload
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec note_streaming_content(term(), term(), term(), term()) :: term()
|
defp do_note_streaming_content(socket, content) when is_binary(content) do
|
||||||
def note_streaming_content(socket, conversation_id, content, reload)
|
update_request(socket, fn request -> %{request | content: content} end)
|
||||||
when is_binary(conversation_id) and is_binary(content) do
|
|
||||||
update_request(
|
|
||||||
socket,
|
|
||||||
conversation_id,
|
|
||||||
fn request -> %{request | content: content} end,
|
|
||||||
reload
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec finish_request(term(), term(), term(), term(), term()) :: term()
|
defp update_request(socket, updater) do
|
||||||
def finish_request(socket, ref, result, reload, append_output) when is_reference(ref) do
|
case socket.assigns.request do
|
||||||
case Map.pop(socket.assigns.chat_editor_request_refs, ref) do
|
nil ->
|
||||||
{nil, _remaining_refs} ->
|
|
||||||
socket
|
socket
|
||||||
|
|
||||||
{conversation_id, remaining_refs} ->
|
request ->
|
||||||
socket =
|
|
||||||
socket
|
socket
|
||||||
|> assign(:chat_editor_request_refs, remaining_refs)
|
|> assign(:request, updater.(request))
|
||||||
|> assign(
|
|> build_data()
|
||||||
:chat_editor_requests,
|
|
||||||
Map.delete(socket.assigns.chat_editor_requests, conversation_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
case result do
|
|
||||||
{:ok, reply} ->
|
|
||||||
socket
|
|
||||||
|> update_tab_meta_from_reply(conversation_id, reply)
|
|
||||||
|> reload.(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
|
||||||
end
|
end
|
||||||
|
|
||||||
defp update_tab_meta_from_reply(socket, conversation_id, reply) do
|
defp update_tab_meta_from_reply(socket, reply) do
|
||||||
title =
|
title =
|
||||||
reply
|
reply
|
||||||
|> MapUtils.attr(:conversation, %{})
|
|> MapUtils.attr(:conversation, %{})
|
||||||
|> MapUtils.attr(:title)
|
|> MapUtils.attr(:title)
|
||||||
|
|
||||||
if is_binary(title) and String.trim(title) != "" do
|
if is_binary(title) and String.trim(title) != "" do
|
||||||
key = {:chat, conversation_id}
|
notify_parent({:chat_editor_tab_meta, socket.assigns.conversation_id, title, ""})
|
||||||
|
end
|
||||||
|
|
||||||
assign(socket, :tab_meta, Map.update(socket.assigns.tab_meta, key, %{title: title}, fn meta ->
|
|
||||||
Map.put(meta, :title, title)
|
|
||||||
end))
|
|
||||||
else
|
|
||||||
socket
|
socket
|
||||||
end
|
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_parent(
|
||||||
|
{: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_parent(
|
||||||
|
{: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_parent(
|
||||||
|
{: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_parent(
|
||||||
|
{: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
|
end
|
||||||
|
|
||||||
# ── HEEx-callable helpers ─────────────────────────────────────────────────
|
# ── HEEx-callable helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@spec message_role_label(term()) :: term()
|
@spec message_role_label(atom()) :: String.t()
|
||||||
def message_role_label(:user), do: translated("chat.role.you")
|
def message_role_label(:user), do: translated("chat.role.you")
|
||||||
def message_role_label(_role), do: translated("chat.role.assistant")
|
def message_role_label(_role), do: translated("chat.role.assistant")
|
||||||
|
|
||||||
defdelegate tool_call_name(tool_call), to: ToolTracking
|
defdelegate tool_call_name(tool_call), to: ToolTracking
|
||||||
defdelegate tool_call_arguments(tool_call), to: ToolTracking
|
defdelegate tool_call_arguments(tool_call), to: ToolTracking
|
||||||
|
|
||||||
@spec tool_surface_type(term()) :: term()
|
@spec tool_surface_type(map()) :: String.t()
|
||||||
def tool_surface_type(surface), do: Map.get(surface, :type, "json")
|
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
|
def markdown_html(content) when is_binary(content) do
|
||||||
html =
|
html =
|
||||||
case Earmark.as_html(content, escape: true) do
|
case Earmark.as_html(content, escape: true) do
|
||||||
@@ -313,13 +504,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
raw(html)
|
raw(html)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec markdown_html(term()) :: term()
|
|
||||||
def markdown_html(_content), do: ""
|
def markdown_html(_content), do: ""
|
||||||
|
|
||||||
@spec payload_json(term()) :: term()
|
@spec payload_json(map() | nil) :: String.t()
|
||||||
def payload_json(nil), do: "{}"
|
def payload_json(nil), do: "{}"
|
||||||
def payload_json(payload) when is_map(payload), do: Jason.encode!(payload)
|
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 not is_number(value), do: 0
|
||||||
|
|
||||||
def chart_width(max_value, value) when is_number(max_value) and max_value > 0 do
|
def chart_width(max_value, value) when is_number(max_value) and max_value > 0 do
|
||||||
@@ -331,18 +522,17 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
|> Float.round(2)
|
|> Float.round(2)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec chart_width(term(), term()) :: term()
|
|
||||||
def chart_width(_max_value, _value), do: 0
|
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) when value in [true, "true", 1, "1", "on"], do: true
|
||||||
@spec truthy?(term()) :: term()
|
|
||||||
def truthy?(_value), do: false
|
def truthy?(_value), do: false
|
||||||
|
|
||||||
# ── HEEx components ───────────────────────────────────────────────────────
|
# ── HEEx components ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
attr(:markers, :list, required: true)
|
attr(:markers, :list, required: true)
|
||||||
|
|
||||||
@spec chat_tool_markers(term()) :: term()
|
@spec chat_tool_markers(map()) :: Phoenix.LiveView.Rendered.t()
|
||||||
def chat_tool_markers(assigns) do
|
def chat_tool_markers(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<%= if @markers != [] do %>
|
<%= if @markers != [] do %>
|
||||||
@@ -372,15 +562,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
end
|
end
|
||||||
|
|
||||||
attr(:surface, :map, required: true)
|
attr(:surface, :map, required: true)
|
||||||
|
attr(:myself, :any, required: false)
|
||||||
|
|
||||||
@spec chat_surface(term()) :: term()
|
@spec chat_surface(map()) :: Phoenix.LiveView.Rendered.t()
|
||||||
def chat_surface(assigns) do
|
def chat_surface(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<details id={@surface.id} class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface" data-expanded={surface_expanded_attr(@surface)} open={Map.get(@surface, :expanded?, false)}>
|
<details id={@surface.id} class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface" data-expanded={surface_expanded_attr(@surface)} open={Map.get(@surface, :expanded?, false)}>
|
||||||
<summary class="chat-inline-surface-header">
|
<summary class="chat-inline-surface-header">
|
||||||
<span class="chat-inline-surface-icon"><%= surface_icon(@surface.type) %></span>
|
<span class="chat-inline-surface-icon"><%= surface_icon(@surface.type) %></span>
|
||||||
<span class="chat-inline-surface-title"><%= surface_title(@surface) %></span>
|
<span class="chat-inline-surface-title"><%= surface_title(@surface) %></span>
|
||||||
<button class="chat-inline-surface-dismiss" type="button" phx-click="dismiss_chat_surface" phx-value-surface-id={@surface.id} aria-label={translated("chat.dismissSurface")} data-testid="chat-inline-surface-dismiss">×</button>
|
<button class="chat-inline-surface-dismiss" type="button" phx-click="dismiss_chat_surface" phx-target={@myself} phx-value-surface-id={@surface.id} aria-label={translated("chat.dismissSurface")} data-testid="chat-inline-surface-dismiss">×</button>
|
||||||
</summary>
|
</summary>
|
||||||
<div class="chat-inline-surface-body">
|
<div class="chat-inline-surface-body">
|
||||||
<%= case @surface.type do %>
|
<%= case @surface.type do %>
|
||||||
@@ -400,6 +591,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
class="chat-surface-action-button"
|
class="chat-surface-action-button"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="chat_surface_action"
|
phx-click="chat_surface_action"
|
||||||
|
phx-target={@myself}
|
||||||
phx-value-surface-id={@surface.id}
|
phx-value-surface-id={@surface.id}
|
||||||
phx-value-action={action.action}
|
phx-value-action={action.action}
|
||||||
phx-value-payload={payload_json(action.payload)}
|
phx-value-payload={payload_json(action.payload)}
|
||||||
@@ -499,6 +691,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
class={["chat-surface-tab-button", if(index == @surface.selected_index, do: "active")]}
|
class={["chat-surface-tab-button", if(index == @surface.selected_index, do: "active")]}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="select_chat_surface_tab"
|
phx-click="select_chat_surface_tab"
|
||||||
|
phx-target={@myself}
|
||||||
phx-value-surface-id={@surface.id}
|
phx-value-surface-id={@surface.id}
|
||||||
phx-value-index={index}
|
phx-value-index={index}
|
||||||
>
|
>
|
||||||
@@ -522,7 +715,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
<%= if present?(@surface.title) do %>
|
<%= if present?(@surface.title) do %>
|
||||||
<h3><%= @surface.title %></h3>
|
<h3><%= @surface.title %></h3>
|
||||||
<% end %>
|
<% end %>
|
||||||
<form class="chat-surface-form" phx-change="change_chat_surface_form">
|
<form class="chat-surface-form" phx-change="change_chat_surface_form" phx-target={@myself}>
|
||||||
<input type="hidden" name="surface[id]" value={@surface.id} />
|
<input type="hidden" name="surface[id]" value={@surface.id} />
|
||||||
|
|
||||||
<%= for field <- @surface.fields do %>
|
<%= for field <- @surface.fields do %>
|
||||||
@@ -559,6 +752,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
class="chat-surface-action-button"
|
class="chat-surface-action-button"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="chat_surface_action"
|
phx-click="chat_surface_action"
|
||||||
|
phx-target={@myself}
|
||||||
phx-value-surface-id={@surface.id}
|
phx-value-surface-id={@surface.id}
|
||||||
phx-value-action={@surface.submit_action}
|
phx-value-action={@surface.submit_action}
|
||||||
phx-value-payload="{}"
|
phx-value-payload="{}"
|
||||||
@@ -604,19 +798,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
|
|
||||||
# ── Private helpers ───────────────────────────────────────────────────────
|
# ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
defp update_request(socket, conversation_id, updater, reload) do
|
defp notify_parent(message) do
|
||||||
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do
|
send(self(), message)
|
||||||
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 active_project_id(socket) do
|
||||||
|
socket.assigns[:project_id]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp allow_repo_sandbox(pid) when is_pid(pid) do
|
defp allow_repo_sandbox(pid) when is_pid(pid) do
|
||||||
@@ -665,7 +852,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
|
|
||||||
defp format_error(reason), do: inspect(reason)
|
defp format_error(reason), do: inspect(reason)
|
||||||
|
|
||||||
@spec translated(term(), term()) :: term()
|
defp parse_integer(value) when is_integer(value), do: value
|
||||||
def translated(text, bindings \\ %{}),
|
|
||||||
|
defp parse_integer(value) do
|
||||||
|
case Integer.parse(to_string(value)) do
|
||||||
|
{int, _} -> int
|
||||||
|
:error -> 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp translated(text, bindings \\ %{}),
|
||||||
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
|
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
class="chat-model-selector-button chat-model-selector-inline"
|
class="chat-model-selector-button chat-model-selector-inline"
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="toggle_chat_model_selector"
|
phx-click="toggle_chat_model_selector"
|
||||||
|
phx-target={@myself}
|
||||||
data-testid="chat-model-selector-button"
|
data-testid="chat-model-selector-button"
|
||||||
>
|
>
|
||||||
<span><%= @chat_editor.effective_model || translated("chat.modelUnavailable") %></span>
|
<span><%= @chat_editor.effective_model || translated("chat.modelUnavailable") %></span>
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
]}
|
]}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="select_chat_model"
|
phx-click="select_chat_model"
|
||||||
|
phx-target={@myself}
|
||||||
phx-value-model={model.id}
|
phx-value-model={model.id}
|
||||||
data-testid="chat-model-selector-option"
|
data-testid="chat-model-selector-option"
|
||||||
data-provider={group.provider}
|
data-provider={group.provider}
|
||||||
@@ -60,7 +62,7 @@
|
|||||||
<h2><%= translated("chat.apiKeyRequiredTitle") %></h2>
|
<h2><%= translated("chat.apiKeyRequiredTitle") %></h2>
|
||||||
<p><%= translated("chat.apiKeyRequiredDescription") %></p>
|
<p><%= translated("chat.apiKeyRequiredDescription") %></p>
|
||||||
<div class="api-key-form">
|
<div class="api-key-form">
|
||||||
<button class="api-key-submit" type="button" phx-click="open_chat_settings"><%= translated("chat.openSettings") %></button>
|
<button class="api-key-submit" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= translated("chat.openSettings") %></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
@@ -100,7 +102,7 @@
|
|||||||
<%= if message.role == :assistant do %>
|
<%= if message.role == :assistant do %>
|
||||||
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
|
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
|
||||||
<%= for surface <- message.inline_surfaces do %>
|
<%= for surface <- message.inline_surfaces do %>
|
||||||
<.chat_surface surface={surface} />
|
<.chat_surface surface={surface} myself={@myself} />
|
||||||
<% end %>
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
|
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
|
||||||
@@ -124,7 +126,7 @@
|
|||||||
<div class="chat-message-text"><%= markdown_html(@chat_editor.streaming_content) %></div>
|
<div class="chat-message-text"><%= markdown_html(@chat_editor.streaming_content) %></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= for surface <- @chat_editor.streaming_inline_surfaces do %>
|
<%= for surface <- @chat_editor.streaming_inline_surfaces do %>
|
||||||
<.chat_surface surface={surface} />
|
<.chat_surface surface={surface} myself={@myself} />
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -147,12 +149,12 @@
|
|||||||
<%= unless @chat_editor.needs_api_key? do %>
|
<%= unless @chat_editor.needs_api_key? do %>
|
||||||
<div class="chat-input-container" data-testid="chat-input-container">
|
<div class="chat-input-container" data-testid="chat-input-container">
|
||||||
<%= if @chat_editor.is_streaming do %>
|
<%= if @chat_editor.is_streaming do %>
|
||||||
<button class="chat-abort-button" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message">◼ <%= translated("chat.stop") %></button>
|
<button class="chat-abort-button" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message" phx-target={@myself}>◼ <%= translated("chat.stop") %></button>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<form class="chat-input-wrapper" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message">
|
<form class="chat-input-wrapper" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
|
||||||
<textarea class="chat-input chat-surface-input" name="message" rows="1" placeholder={translated("chat.inputPlaceholder")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
|
<textarea class="chat-input chat-surface-input" name="message" rows="1" placeholder={translated("chat.inputPlaceholder")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
|
||||||
<button class="chat-send-button" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" disabled={@chat_editor.send_disabled?}>↑</button>
|
<button class="chat-send-button" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself} disabled={@chat_editor.send_disabled?}>↑</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<%= if @chat_editor.action_error do %>
|
<%= if @chat_editor.action_error do %>
|
||||||
|
|||||||
@@ -3,144 +3,7 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
|
|||||||
|
|
||||||
import Phoenix.Component, only: [assign: 3]
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
|
||||||
alias BDS.BoundedAtoms
|
|
||||||
alias BDS.Desktop.ShellData
|
alias BDS.Desktop.ShellData
|
||||||
alias BDS.Desktop.ShellLive.{ChatEditor, TabHelpers}
|
|
||||||
alias BDS.UI.Workbench
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Handle a chat-surface action from a chat message. Receives callbacks for
|
|
||||||
`reload_shell/2` and `open_sidebar_item/3` to remain decoupled from
|
|
||||||
`BDS.Desktop.ShellLive` private state.
|
|
||||||
"""
|
|
||||||
def handle_action(socket, params, callbacks) 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 != "" ->
|
|
||||||
socket
|
|
||||||
|> clear_action_error()
|
|
||||||
|> callbacks.open_sidebar.(
|
|
||||||
%{
|
|
||||||
"route" => "post",
|
|
||||||
"id" => post_id,
|
|
||||||
"title" => TabHelpers.post_title(post_id),
|
|
||||||
"subtitle" => TabHelpers.post_subtitle(post_id)
|
|
||||||
},
|
|
||||||
:pin
|
|
||||||
)
|
|
||||||
|
|
||||||
_other ->
|
|
||||||
ChatEditor.set_action_error(
|
|
||||||
socket,
|
|
||||||
socket.assigns.current_tab.id,
|
|
||||||
"Invalid payload for openPost action",
|
|
||||||
callbacks.reload
|
|
||||||
)
|
|
||||||
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_action_error()
|
|
||||||
|> callbacks.open_sidebar.(
|
|
||||||
%{
|
|
||||||
"route" => "media",
|
|
||||||
"id" => media_id,
|
|
||||||
"title" => TabHelpers.media_title(media_id),
|
|
||||||
"subtitle" => TabHelpers.media_subtitle(media_id)
|
|
||||||
},
|
|
||||||
:pin
|
|
||||||
)
|
|
||||||
|
|
||||||
_other ->
|
|
||||||
ChatEditor.set_action_error(
|
|
||||||
socket,
|
|
||||||
socket.assigns.current_tab.id,
|
|
||||||
"Invalid payload for openMedia action",
|
|
||||||
callbacks.reload
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
:open_settings ->
|
|
||||||
socket
|
|
||||||
|> clear_action_error()
|
|
||||||
|> callbacks.open_sidebar.(
|
|
||||||
%{
|
|
||||||
"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_action_error()
|
|
||||||
|> callbacks.open_sidebar.(
|
|
||||||
%{
|
|
||||||
"route" => "chat",
|
|
||||||
"id" => chat_id,
|
|
||||||
"title" => Map.get(payload, "title", "Chat"),
|
|
||||||
"subtitle" => Map.get(payload, "subtitle", "")
|
|
||||||
},
|
|
||||||
:pin
|
|
||||||
)
|
|
||||||
|
|
||||||
:switch_view ->
|
|
||||||
case BoundedAtoms.sidebar_view(Map.get(payload, "view")) do
|
|
||||||
nil ->
|
|
||||||
ChatEditor.set_action_error(
|
|
||||||
socket,
|
|
||||||
socket.assigns.current_tab.id,
|
|
||||||
"Invalid payload for switchView action",
|
|
||||||
callbacks.reload
|
|
||||||
)
|
|
||||||
|
|
||||||
view ->
|
|
||||||
socket
|
|
||||||
|> clear_action_error()
|
|
||||||
|> callbacks.reload.(Workbench.click_activity(socket.assigns.workbench, view))
|
|
||||||
end
|
|
||||||
|
|
||||||
:toggle_sidebar ->
|
|
||||||
socket
|
|
||||||
|> clear_action_error()
|
|
||||||
|> callbacks.reload.(Workbench.toggle_sidebar(socket.assigns.workbench))
|
|
||||||
|
|
||||||
:toggle_panel ->
|
|
||||||
socket
|
|
||||||
|> clear_action_error()
|
|
||||||
|> callbacks.reload.(Workbench.toggle_panel(socket.assigns.workbench))
|
|
||||||
|
|
||||||
:toggle_assistant_sidebar ->
|
|
||||||
socket
|
|
||||||
|> clear_action_error()
|
|
||||||
|> callbacks.reload.(Workbench.toggle_assistant_sidebar(socket.assigns.workbench))
|
|
||||||
|
|
||||||
:unknown ->
|
|
||||||
ChatEditor.set_action_error(
|
|
||||||
socket,
|
|
||||||
socket.assigns.current_tab.id,
|
|
||||||
"Unsupported assistant action",
|
|
||||||
callbacks.reload
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def assistant_turn(prompt, socket) do
|
def assistant_turn(prompt, socket) do
|
||||||
[
|
[
|
||||||
@@ -165,61 +28,6 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_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
|
|
||||||
|
|
||||||
def clear_action_error(socket), do: socket
|
|
||||||
|
|
||||||
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 = ChatEditor.current_surface_data(socket, surface_id)
|
|
||||||
|
|
||||||
if form_data == %{} do
|
|
||||||
payload
|
|
||||||
else
|
|
||||||
Map.put(payload, "formData", form_data)
|
|
||||||
end
|
|
||||||
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
|
|
||||||
|
|
||||||
defp assistant_reply(socket) do
|
defp assistant_reply(socket) do
|
||||||
if socket.assigns.offline_mode do
|
if socket.assigns.offline_mode do
|
||||||
ShellData.translate(
|
ShellData.translate(
|
||||||
|
|||||||
@@ -415,8 +415,8 @@
|
|||||||
<% @current_tab.type == :templates -> %>
|
<% @current_tab.type == :templates -> %>
|
||||||
<.live_component module={TemplateEditor} id={"template-editor-#{@current_tab.id}"} current_tab={@current_tab} />
|
<.live_component module={TemplateEditor} id={"template-editor-#{@current_tab.id}"} current_tab={@current_tab} />
|
||||||
|
|
||||||
<% @current_tab.type == :chat and @chat_editor -> %>
|
<% @current_tab.type == :chat -> %>
|
||||||
<ChatEditor.chat_editor chat_editor={@chat_editor} />
|
<.live_component module={ChatEditor} id={"chat-editor-#{@current_tab.id}"} current_tab={@current_tab} offline_mode={@offline_mode} project_id={@projects.active_project_id} />
|
||||||
|
|
||||||
<% @current_tab.type == :import and @import_editor -> %>
|
<% @current_tab.type == :import and @import_editor -> %>
|
||||||
<ImportEditor.import_editor import_editor={@import_editor} />
|
<ImportEditor.import_editor import_editor={@import_editor} />
|
||||||
|
|||||||
@@ -3232,7 +3232,10 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert html =~ "llama-current"
|
assert html =~ "llama-current"
|
||||||
refute html =~ ~s(<span>New Chat</span><span class="chat-model-selector-caret">▾</span>)
|
refute html =~ ~s(<span>New Chat</span><span class="chat-model-selector-caret">▾</span>)
|
||||||
|
|
||||||
selector_html = render_click(view, "toggle_chat_model_selector", %{})
|
selector_html =
|
||||||
|
view
|
||||||
|
|> element("[data-testid='chat-model-selector-button']")
|
||||||
|
|> render_click()
|
||||||
assert selector_html =~ ~s(class="chat-model-selector-menu")
|
assert selector_html =~ ~s(class="chat-model-selector-menu")
|
||||||
assert selector_html =~ ~s(data-testid="chat-model-selector-option")
|
assert selector_html =~ ~s(data-testid="chat-model-selector-option")
|
||||||
assert selector_html =~ "llama-current"
|
assert selector_html =~ "llama-current"
|
||||||
@@ -3244,10 +3247,12 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
refute css =~
|
refute css =~
|
||||||
".chat-panel-title {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n gap: 10px;\n overflow: hidden;"
|
".chat-panel-title {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n gap: 10px;\n overflow: hidden;"
|
||||||
|
|
||||||
render_click(view, "select_chat_model", %{"model" => "llama-next"})
|
view
|
||||||
|
|> element("button[phx-value-model='llama-default']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
assert AI.get_chat_conversation(conversation.id).model == "llama-next"
|
assert AI.get_chat_conversation(conversation.id).model == "llama-default"
|
||||||
assert render(view) =~ "llama-next"
|
assert render(view) =~ "llama-default"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "chat editor updates the visible new-chat title after the first turn" do
|
test "chat editor updates the visible new-chat title after the first turn" do
|
||||||
@@ -3284,7 +3289,9 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert html =~ ~s(<span class="tab-title">New Chat</span>)
|
assert html =~ ~s(<span class="tab-title">New Chat</span>)
|
||||||
|
|
||||||
_html =
|
_html =
|
||||||
render_change(view, "change_chat_editor_input", %{"message" => "Posts pro Monat 2026"})
|
view
|
||||||
|
|> element(".chat-input-wrapper")
|
||||||
|
|> render_change(%{"message" => "Posts pro Monat 2026"})
|
||||||
|
|
||||||
_html =
|
_html =
|
||||||
view
|
view
|
||||||
@@ -3384,10 +3391,12 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert html =~ "Posts"
|
assert html =~ "Posts"
|
||||||
assert html =~ ~r/chat-message-content.*data-testid="chat-inline-surface"/s
|
assert html =~ ~r/chat-message-content.*data-testid="chat-inline-surface"/s
|
||||||
|
|
||||||
|
surface_id = Regex.run(~r/id="([^"]+-surface-0)"/, html) |> Enum.at(1)
|
||||||
|
|
||||||
dismissed_html =
|
dismissed_html =
|
||||||
render_click(view, "dismiss_chat_surface", %{
|
view
|
||||||
"surface-id" => Regex.run(~r/id="([^"]+-surface-0)"/, html) |> Enum.at(1)
|
|> element("button[phx-value-surface-id='#{surface_id}']")
|
||||||
})
|
|> render_click()
|
||||||
|
|
||||||
refute dismissed_html =~ ~s(data-testid="chat-inline-surface")
|
refute dismissed_html =~ ~s(data-testid="chat-inline-surface")
|
||||||
end
|
end
|
||||||
@@ -3503,7 +3512,10 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
"subtitle" => conversation.model || "chat"
|
"subtitle" => conversation.model || "chat"
|
||||||
})
|
})
|
||||||
|
|
||||||
_html = render_change(view, "change_chat_editor_input", %{"message" => "Update missing data"})
|
_html =
|
||||||
|
view
|
||||||
|
|> element(".chat-input-wrapper")
|
||||||
|
|> render_change(%{"message" => "Update missing data"})
|
||||||
|
|
||||||
_html =
|
_html =
|
||||||
view
|
view
|
||||||
@@ -3883,11 +3895,11 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert html =~ "Quick Action"
|
assert html =~ "Quick Action"
|
||||||
assert html =~ "Open Post"
|
assert html =~ "Open Post"
|
||||||
|
|
||||||
html =
|
|
||||||
view
|
view
|
||||||
|> element("[data-testid='chat-surface-action'][data-action='openPost']")
|
|> element("[data-testid='chat-surface-action'][data-action='openPost']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
|
html = render(view)
|
||||||
assert html =~ ~s(data-tab-type="post")
|
assert html =~ ~s(data-tab-type="post")
|
||||||
assert html =~ ~s(data-tab-id="#{post.id}")
|
assert html =~ ~s(data-tab-id="#{post.id}")
|
||||||
end
|
end
|
||||||
@@ -3919,7 +3931,10 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
"subtitle" => conversation.model || "chat"
|
"subtitle" => conversation.model || "chat"
|
||||||
})
|
})
|
||||||
|
|
||||||
_html = render_change(view, "change_chat_editor_input", %{"message" => "Please wait"})
|
_html =
|
||||||
|
view
|
||||||
|
|> element(".chat-input-wrapper")
|
||||||
|
|> render_change(%{"message" => "Please wait"})
|
||||||
|
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
@@ -3976,7 +3991,10 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
"subtitle" => conversation.model || "chat"
|
"subtitle" => conversation.model || "chat"
|
||||||
})
|
})
|
||||||
|
|
||||||
_html = render_change(view, "change_chat_editor_input", %{"message" => "Newest question"})
|
_html =
|
||||||
|
view
|
||||||
|
|> element(".chat-input-wrapper")
|
||||||
|
|> render_change(%{"message" => "Newest question"})
|
||||||
|
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
@@ -4046,7 +4064,10 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
"subtitle" => conversation.model || "chat"
|
"subtitle" => conversation.model || "chat"
|
||||||
})
|
})
|
||||||
|
|
||||||
_html = render_change(view, "change_chat_editor_input", %{"message" => "Newest question"})
|
_html =
|
||||||
|
view
|
||||||
|
|> element(".chat-input-wrapper")
|
||||||
|
|> render_change(%{"message" => "Newest question"})
|
||||||
|
|
||||||
_html =
|
_html =
|
||||||
view
|
view
|
||||||
|
|||||||
Reference in New Issue
Block a user