From fbc1cba52ee4b64754dd0641b6285100635a28d3 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 1 May 2026 15:26:22 +0200 Subject: [PATCH] chore: refactored chat_editor --- CODESMELL.md | 9 +- lib/bds/desktop/shell_live/chat_editor.ex | 656 ++++-------------- .../shell_live/chat_editor/message_build.ex | 118 ++++ .../shell_live/chat_editor/model_selection.ex | 80 +++ .../shell_live/chat_editor/tool_surfaces.ex | 274 ++++++++ .../shell_live/chat_editor/tool_tracking.ex | 101 +++ priv/ui/live.js | 9 +- scripts/desktop_automation_runner.mjs | 15 +- 8 files changed, 731 insertions(+), 531 deletions(-) create mode 100644 lib/bds/desktop/shell_live/chat_editor/message_build.ex create mode 100644 lib/bds/desktop/shell_live/chat_editor/model_selection.ex create mode 100644 lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex create mode 100644 lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex diff --git a/CODESMELL.md b/CODESMELL.md index 5ed5157..5293f3b 100644 --- a/CODESMELL.md +++ b/CODESMELL.md @@ -2,7 +2,7 @@ Living document. Each section lists **status**, then **open work in priority order**, then a brief notes block. Completed work is listed compactly at the bottom (`## Changelog`). -Last refreshed: 2026-05-07. +Last refreshed: 2026-05-08. --- @@ -14,7 +14,6 @@ Last refreshed: 2026-05-07. | # | Module | Current lines | Target | Strategy | |---|---|---|---|---| -| 8 | `BDS.Desktop.ShellLive.ChatEditor` | 972 | ≤ 400 | Extract `ToolSurfaces` (~280), `ToolTracking` (~140), `MessageBuild` (~160), `ModelSelection` (~100). Defer — highest internal coupling. | | 9 | `BDS.MCP` | 677 | ≤ 350 | Split tools / resources / proposals / serialization clusters. (Carried over from original priority list.) | **Established pattern:** extract cohesive helper clusters into submodules under `lib//.ex`; `import BDS.X.Y, only: [...]` from the main module so internal call sites are unchanged; `defdelegate` for any helper still needed through the public namespace; verify with `mix compile --warnings-as-errors`, `mix dialyzer --format short`, and full `mix test` after each extraction. @@ -33,6 +32,7 @@ Last refreshed: 2026-05-07. - `BDS.Desktop.ShellLive.MenuEditor` 871 → 335 (62 %) - `BDS.Desktop.ShellLive.PostEditor` 963 → 506 (47 %) - `BDS.Desktop.ShellLive.SettingsEditor` 872 → 226 (74 %) +- `BDS.Desktop.ShellLive.ChatEditor` 972 → 576 (41 %) --- @@ -166,6 +166,11 @@ Most tests share the SQLite repo and named GenServers (`BDS.Tasks`, `BDS.Search` ## Changelog +### 2026-05-08 + +- **God modules**: + - `BDS.Desktop.ShellLive.ChatEditor` 972 → 576 (41 %). Submodules under `lib/bds/desktop/shell_live/chat_editor/`: `ToolSurfaces` (274, build_render_surfaces + build_render_surface + do_build_render_surface (8 clauses) + build_tab_surface + decode_surface_actions/options + normalize_tool_surface + map_value + numeric_value + stringify_list + render_tool? + @render_tool_names), `ToolTracking` (101, tool_call_name + tool_call_arguments + normalize_tool_calls + tool_arguments_preview + mark_tool_call_completed + tool_markers_from_events + mark_last_matching_complete + preview_value + @tool_args_max_length), `MessageBuild` (118, build/1,2 + build_entries + finalize_entry + start_entry + append_tool_surface + pending_user_message + streaming_content), `ModelSelection` (80, toggle_model_selector + set_model + group_available_models + needs_api_key? + provider_group_label + blank?). Coordinator keeps the 3 HEEx components (chat_editor, chat_tool_markers, chat_surface), the 13 public event handlers (assign_socket, update_input, update_surface_form, select_surface_tab, current_surface_data, set_action_error, clear_action_error, send_message, abort_message, note_tool_call, note_tool_result, note_streaming_content, finish_request), the HEEx-callable helpers (markdown_html, message_role_label, payload_json, chart_width, truthy?, tool_surface_type, translated/1,2), private helpers (update_request, allow_repo_sandbox, rewrite_external_images, external_image_link, surface_input_type, present?, format_error), and `defdelegate` entries for `build/1`, `toggle_model_selector/2`, `set_model/4`, `tool_call_name/1`, `tool_call_arguments/1`. Cross-submodule deps are linear: MessageBuild → ModelSelection + ToolSurfaces + ToolTracking; ToolSurfaces, ToolTracking, ModelSelection are leaves. Each submodule that needs it duplicates the small `translated/2` and `blank?/1` helpers locally per the established convention; ToolSurfaces also duplicates `truthy?/1` privately. ModelSelection uses `Phoenix.Component.assign/3` via `import only:`. The 400-line target was not reachable while keeping all 3 HEEx components in the coordinator (the chat_surface component alone is ~200 lines). Validates clean: `mix compile --warnings-as-errors`, `mix dialyzer --format short` (Total errors: 0), `mix test` (342 tests, 0 failures, 4 skipped). + ### 2026-05-07 - **God modules**: diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index 7e7d844..e31d150 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -4,58 +4,35 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do use Phoenix.Component import Phoenix.HTML, only: [raw: 1] - alias BDS.{AI, Repo} - alias BDS.AI.ChatConversation + alias BDS.AI 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 + alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking} embed_templates "chat_editor_html/*" + # ── Public API: state assignment ─────────────────────────────────────────── + def assign_socket(socket) do - assign(socket, :chat_editor, build(socket.assigns)) + assign(socket, :chat_editor, MessageBuild.build(socket.assigns)) end - def toggle_model_selector(socket, reload) do - %{id: conversation_id} = socket.assigns.current_tab - current = Map.get(socket.assigns.chat_model_selectors_open, conversation_id, false) + defdelegate build(assigns), to: MessageBuild - socket - |> assign(:chat_model_selectors_open, Map.put(socket.assigns.chat_model_selectors_open, conversation_id, not current)) - |> reload.(socket.assigns.workbench) - end + # ── Public API: model selection ──────────────────────────────────────────── - def set_model(socket, model_id, reload, append_output) do - %{id: conversation_id} = socket.assigns.current_tab + defdelegate toggle_model_selector(socket, reload), to: ModelSelection + defdelegate set_model(socket, model_id, reload, append_output), to: ModelSelection - case AI.set_conversation_model(conversation_id, model_id) do - {:ok, _conversation} -> - socket - |> assign(:chat_model_selectors_open, Map.put(socket.assigns.chat_model_selectors_open, conversation_id, false)) - |> reload.(socket.assigns.workbench) - - {:error, reason} -> - socket - |> append_output.(translated("Chat"), inspect(reason), nil, "error") - |> reload.(socket.assigns.workbench) - end - end + # ── Public API: input + surface state ────────────────────────────────────── def update_input(socket, value, reload) do %{id: conversation_id} = socket.assigns.current_tab socket - |> assign(:chat_editor_inputs, Map.put(socket.assigns.chat_editor_inputs, conversation_id, to_string(value || ""))) + |> assign( + :chat_editor_inputs, + Map.put(socket.assigns.chat_editor_inputs, conversation_id, to_string(value || "")) + ) |> reload.(socket.assigns.workbench) end @@ -71,7 +48,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do 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)) + |> assign( + :chat_editor_surface_tabs, + Map.put(socket.assigns.chat_editor_surface_tabs, surface_id, index) + ) |> reload.(socket.assigns.workbench) end @@ -82,29 +62,46 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do 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)) + |> 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)) + |> assign( + :chat_editor_action_errors, + Map.delete(socket.assigns.chat_editor_action_errors, conversation_id) + ) |> reload.(socket.assigns.workbench) end + # ── Public API: messaging ────────────────────────────────────────────────── + 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) + 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") + |> append_output.( + translated("Chat"), + translated("Automatic AI actions stay gated by airplane mode."), + nil, + "info" + ) |> reload.(socket.assigns.workbench) - needs_api_key?(false) -> + ModelSelection.needs_api_key?(false) -> reload.(socket, socket.assigns.workbench) true -> @@ -121,10 +118,28 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do :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)) + |> 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 @@ -133,37 +148,67 @@ defmodule BDS.Desktop.ShellLive.ChatEditor 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) + 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)) + |> 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) + update_request( + socket, + conversation_id, + fn request -> + update_in( + request.tool_events, + &(&1 ++ + [ + %{ + type: :call, + name: ToolTracking.tool_call_name(tool_call), + arguments: ToolTracking.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) + 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) + 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 @@ -175,7 +220,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do socket = socket |> assign(:chat_editor_request_refs, remaining_refs) - |> assign(:chat_editor_requests, Map.delete(socket.assigns.chat_editor_requests, conversation_id)) + |> assign( + :chat_editor_requests, + Map.delete(socket.assigns.chat_editor_requests, conversation_id) + ) case result do {:ok, _reply} -> @@ -195,47 +243,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do end end - def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do - case Repo.get(ChatConversation, conversation_id) do - nil -> nil - %ChatConversation{} = conversation -> - messages = AI.list_chat_messages(conversation.id) - request = Map.get(assigns.chat_editor_requests, conversation.id) - available_models = AI.available_chat_models(conversation.model) - - %{ - id: conversation.id, - title: conversation.title || translated("chat.newChat"), - model: conversation.model, - available_models: available_models, - available_model_groups: group_available_models(available_models), - model_selector_open?: Map.get(assigns.chat_model_selectors_open, conversation.id, false), - input: Map.get(assigns.chat_editor_inputs, conversation.id, ""), - messages: build_entries(messages, assigns), - pending_user_message: pending_user_message(messages, request), - is_streaming: not is_nil(request), - streaming_content: streaming_content(request), - streaming_tool_markers: tool_markers_from_events(request), - offline?: Map.get(assigns, :offline_mode, true), - needs_api_key?: needs_api_key?(Map.get(assigns, :offline_mode, true)), - action_error: Map.get(assigns.chat_editor_action_errors, conversation.id), - send_disabled?: String.trim(Map.get(assigns.chat_editor_inputs, conversation.id, "")) == "" or not is_nil(request) - } - end - end - - def build(_assigns), do: nil + # ── HEEx-callable helpers ───────────────────────────────────────────────── def message_role_label(:user), do: translated("chat.role.you") def message_role_label(_role), do: translated("chat.role.assistant") - def tool_call_name(tool_call) when is_map(tool_call) do - Map.get(tool_call, "name") || Map.get(tool_call, :name) || "tool" - end - - def tool_call_arguments(tool_call) when is_map(tool_call) do - Map.get(tool_call, "arguments") || Map.get(tool_call, :arguments) || Map.get(tool_call, "args") || Map.get(tool_call, :args) || %{} - end + defdelegate tool_call_name(tool_call), to: ToolTracking + defdelegate tool_call_arguments(tool_call), to: ToolTracking def tool_surface_type(surface), do: Map.get(surface, :type, "json") @@ -252,24 +266,6 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do def markdown_html(_content), do: "" - defp group_available_models(models) when is_list(models) do - models - |> Enum.group_by(&Map.get(&1, :provider, "other")) - |> Enum.map(fn {provider, entries} -> - %{ - provider: provider, - label: provider_group_label(entries, provider), - models: Enum.sort_by(entries, &String.downcase(to_string(Map.get(&1, :name) || Map.get(&1, :id)))) - } - end) - |> Enum.sort_by(&String.downcase(to_string(&1.label))) - end - - defp provider_group_label([%{provider_name: name} | _entries], _provider) when is_binary(name) and name != "", - do: name - - defp provider_group_label(_entries, provider) when is_binary(provider), do: provider - def payload_json(nil), do: "{}" def payload_json(payload) when is_map(payload), do: Jason.encode!(payload) @@ -289,6 +285,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true def truthy?(_value), do: false + # ── HEEx components ─────────────────────────────────────────────────────── + attr :markers, :list, required: true def chat_tool_markers(assigns) do @@ -511,381 +509,19 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do """ 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), turn_index} - else - {entries, current_entry, turn_index} - end - - :system -> - {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, turn_index, assigns), turn_index} - end - end) - - entries - |> finalize_entry(current_entry) - |> Enum.reverse() - end - - defp finalize_entry(entries, nil), do: entries - defp finalize_entry(entries, entry), do: [entry | entries] - - 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 || "", - 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])) - end - end - - 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: 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} -> - %{ - type: type, - title: decoded["title"], - columns: List.wrap(decoded["columns"]), - rows: Enum.map(List.wrap(decoded["rows"]), &List.wrap/1), - fields: List.wrap(decoded["fields"]), - data: decoded - } - - _other -> - nil - end - end - - 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 + # ── Private helpers ─────────────────────────────────────────────────────── defp update_request(socket, conversation_id, updater, reload) do case Map.get(socket.assigns.chat_editor_requests, conversation_id) do - nil -> socket + nil -> + socket request -> socket - |> assign(:chat_editor_requests, Map.put(socket.assigns.chat_editor_requests, conversation_id, updater.(request))) + |> assign( + :chat_editor_requests, + Map.put(socket.assigns.chat_editor_requests, conversation_id, updater.(request)) + ) |> reload.(socket.assigns.workbench) end end @@ -904,23 +540,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do :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 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?:\/\/[^\"]+)")(?=[^>]*\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) @@ -940,33 +566,11 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do 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 format_error(%{kind: :endpoint_not_configured}), + do: translated("chat.apiKeyRequiredDescription") - 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)) + 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/message_build.ex b/lib/bds/desktop/shell_live/chat_editor/message_build.ex new file mode 100644 index 0000000..fe26138 --- /dev/null +++ b/lib/bds/desktop/shell_live/chat_editor/message_build.ex @@ -0,0 +1,118 @@ +defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do + @moduledoc false + + alias BDS.{AI, Repo} + alias BDS.AI.ChatConversation + alias BDS.Desktop.ShellData + alias BDS.Desktop.ShellLive.ChatEditor.{ModelSelection, ToolSurfaces, ToolTracking} + + def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do + case Repo.get(ChatConversation, conversation_id) do + nil -> + nil + + %ChatConversation{} = conversation -> + messages = AI.list_chat_messages(conversation.id) + request = Map.get(assigns.chat_editor_requests, conversation.id) + available_models = AI.available_chat_models(conversation.model) + + %{ + id: conversation.id, + title: conversation.title || translated("chat.newChat"), + model: conversation.model, + available_models: available_models, + available_model_groups: ModelSelection.group_available_models(available_models), + model_selector_open?: + Map.get(assigns.chat_model_selectors_open, conversation.id, false), + input: Map.get(assigns.chat_editor_inputs, conversation.id, ""), + messages: build_entries(messages, assigns), + pending_user_message: pending_user_message(messages, request), + is_streaming: not is_nil(request), + streaming_content: streaming_content(request), + streaming_tool_markers: ToolTracking.tool_markers_from_events(request), + offline?: Map.get(assigns, :offline_mode, true), + needs_api_key?: ModelSelection.needs_api_key?(Map.get(assigns, :offline_mode, true)), + action_error: Map.get(assigns.chat_editor_action_errors, conversation.id), + send_disabled?: + String.trim(Map.get(assigns.chat_editor_inputs, conversation.id, "")) == "" or + not is_nil(request) + } + end + end + + def build(_assigns), do: nil + + 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), turn_index} + else + {entries, current_entry, turn_index} + end + + :system -> + {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, turn_index, assigns), turn_index} + end + end) + + entries + |> finalize_entry(current_entry) + |> Enum.reverse() + end + + defp finalize_entry(entries, nil), do: entries + defp finalize_entry(entries, entry), do: [entry | entries] + + defp start_entry(message, turn_index, assigns) do + tool_markers = ToolTracking.normalize_tool_calls(message.tool_calls) + + %{ + id: message.id, + role: message.role, + content: message.content || "", + turn_index: turn_index, + tool_markers: tool_markers, + inline_surfaces: ToolSurfaces.build_render_surfaces(tool_markers, message.id, assigns), + tool_surfaces: [] + } + end + + defp append_tool_surface(entry, message) do + entry = ToolTracking.mark_tool_call_completed(entry, message.tool_call_id) + + case ToolSurfaces.normalize_tool_surface(message.content) do + nil -> entry + surface -> update_in(entry.tool_surfaces, &(&1 ++ [surface])) + end + end + + 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 translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) +end diff --git a/lib/bds/desktop/shell_live/chat_editor/model_selection.ex b/lib/bds/desktop/shell_live/chat_editor/model_selection.ex new file mode 100644 index 0000000..5dfe96f --- /dev/null +++ b/lib/bds/desktop/shell_live/chat_editor/model_selection.ex @@ -0,0 +1,80 @@ +defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do + @moduledoc false + + alias BDS.AI + alias BDS.Desktop.ShellData + + import Phoenix.Component, only: [assign: 3] + + def toggle_model_selector(socket, reload) do + %{id: conversation_id} = socket.assigns.current_tab + current = Map.get(socket.assigns.chat_model_selectors_open, conversation_id, false) + + socket + |> assign( + :chat_model_selectors_open, + Map.put(socket.assigns.chat_model_selectors_open, conversation_id, not current) + ) + |> reload.(socket.assigns.workbench) + end + + def set_model(socket, model_id, reload, append_output) do + %{id: conversation_id} = socket.assigns.current_tab + + case AI.set_conversation_model(conversation_id, model_id) do + {:ok, _conversation} -> + socket + |> assign( + :chat_model_selectors_open, + Map.put(socket.assigns.chat_model_selectors_open, conversation_id, false) + ) + |> reload.(socket.assigns.workbench) + + {:error, reason} -> + socket + |> append_output.(translated("Chat"), inspect(reason), nil, "error") + |> reload.(socket.assigns.workbench) + end + end + + def group_available_models(models) when is_list(models) do + models + |> Enum.group_by(&Map.get(&1, :provider, "other")) + |> Enum.map(fn {provider, entries} -> + %{ + provider: provider, + label: provider_group_label(entries, provider), + models: + Enum.sort_by( + entries, + &String.downcase(to_string(Map.get(&1, :name) || Map.get(&1, :id))) + ) + } + end) + |> Enum.sort_by(&String.downcase(to_string(&1.label))) + end + + def needs_api_key?(true), do: false + + def 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 provider_group_label([%{provider_name: name} | _entries], _provider) + when is_binary(name) and name != "", + do: name + + defp provider_group_label(_entries, provider) when is_binary(provider), do: provider + + defp blank?(value) when is_binary(value), do: String.trim(value) == "" + defp blank?(nil), do: true + + defp translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) +end diff --git a/lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex b/lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex new file mode 100644 index 0000000..1f5df32 --- /dev/null +++ b/lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex @@ -0,0 +1,274 @@ +defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do + @moduledoc false + + 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" + ]) + + def render_tool?(name) when is_binary(name), do: MapSet.member?(@render_tool_names, name) + def render_tool?(_name), do: false + + def 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 + + def 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 + + def normalize_tool_surface(content) when is_binary(content) do + case Jason.decode(content) do + {:ok, %{"type" => type} = decoded} -> + %{ + type: type, + title: decoded["title"], + columns: List.wrap(decoded["columns"]), + rows: Enum.map(List.wrap(decoded["rows"]), &List.wrap/1), + fields: List.wrap(decoded["fields"]), + data: decoded + } + + _other -> + nil + end + end + + def normalize_tool_surface(_content), do: nil + + 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 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 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 truthy?(value) when value in [true, "true", 1, "1", "on"], do: true + defp truthy?(_value), do: false + + defp translated(text, bindings \\ %{}), + do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale)) +end diff --git a/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex b/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex new file mode 100644 index 0000000..be912bc --- /dev/null +++ b/lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex @@ -0,0 +1,101 @@ +defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do + @moduledoc false + + @tool_args_max_length 30 + + def tool_call_name(tool_call) when is_map(tool_call) do + Map.get(tool_call, "name") || Map.get(tool_call, :name) || "tool" + end + + def tool_call_arguments(tool_call) when is_map(tool_call) do + Map.get(tool_call, "arguments") || Map.get(tool_call, :arguments) || + Map.get(tool_call, "args") || Map.get(tool_call, :args) || %{} + end + + def 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: arguments, + args_preview: tool_arguments_preview(arguments), + complete?: false + } + end) + end + + def normalize_tool_calls(_tool_calls), do: [] + + def tool_arguments_preview(arguments) when is_map(arguments) do + arguments + |> Enum.map(fn {key, value} -> "#{key}: #{preview_value(value)}" end) + |> Enum.join(", ") + end + + def tool_arguments_preview(_arguments), do: "" + + def 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 + + def mark_tool_call_completed(entry, _tool_call_id), do: entry + + def tool_markers_from_events(nil), do: [] + + def 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 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) +end diff --git a/priv/ui/live.js b/priv/ui/live.js index d80ceb1..d1b03f2 100644 --- a/priv/ui/live.js +++ b/priv/ui/live.js @@ -555,9 +555,16 @@ document.addEventListener("DOMContentLoaded", () => { this.handleNativeMenuAction = (event) => { const action = event.detail?.action; + const ackId = event.detail?.ackId; if (action) { - this.pushEvent("native_menu_action", { action }); + this.pushEvent("native_menu_action", { action }, () => { + if (ackId) { + window.dispatchEvent( + new CustomEvent("bds:native-menu-action-ack", { detail: { ackId } }) + ); + } + }); } }; diff --git a/scripts/desktop_automation_runner.mjs b/scripts/desktop_automation_runner.mjs index 5124b32..ef29d38 100644 --- a/scripts/desktop_automation_runner.mjs +++ b/scripts/desktop_automation_runner.mjs @@ -74,9 +74,20 @@ for await (const line of rl) { if (message.command === "native_menu_action") { await page.evaluate((action) => { - window.dispatchEvent(new CustomEvent("bds:native-menu-action", { detail: { action } })); + return new Promise((resolve) => { + const ackId = `ack-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const handler = (event) => { + if (event.detail?.ackId === ackId) { + window.removeEventListener("bds:native-menu-action-ack", handler); + resolve(); + } + }; + window.addEventListener("bds:native-menu-action-ack", handler); + window.dispatchEvent( + new CustomEvent("bds:native-menu-action", { detail: { action, ackId } }) + ); + }); }, message.action); - await page.waitForTimeout(50); console.log(JSON.stringify({ ref, status: "ok", result: "ok" })); continue; }