chore: refactored chat_editor
This commit is contained in:
@@ -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`).
|
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 |
|
| # | 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.) |
|
| 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/<context>/<sub>.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.
|
**Established pattern:** extract cohesive helper clusters into submodules under `lib/<context>/<sub>.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.MenuEditor` 871 → 335 (62 %)
|
||||||
- `BDS.Desktop.ShellLive.PostEditor` 963 → 506 (47 %)
|
- `BDS.Desktop.ShellLive.PostEditor` 963 → 506 (47 %)
|
||||||
- `BDS.Desktop.ShellLive.SettingsEditor` 872 → 226 (74 %)
|
- `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
|
## 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
|
### 2026-05-07
|
||||||
|
|
||||||
- **God modules**:
|
- **God modules**:
|
||||||
|
|||||||
@@ -4,58 +4,35 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
use Phoenix.Component
|
use Phoenix.Component
|
||||||
import Phoenix.HTML, only: [raw: 1]
|
import Phoenix.HTML, only: [raw: 1]
|
||||||
|
|
||||||
alias BDS.{AI, Repo}
|
alias BDS.AI
|
||||||
alias BDS.AI.ChatConversation
|
|
||||||
alias BDS.Desktop.ShellData
|
alias BDS.Desktop.ShellData
|
||||||
|
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
|
||||||
@render_tool_names MapSet.new([
|
|
||||||
"render_card",
|
|
||||||
"render_chart",
|
|
||||||
"render_form",
|
|
||||||
"render_list",
|
|
||||||
"render_metric",
|
|
||||||
"render_mindmap",
|
|
||||||
"render_table",
|
|
||||||
"render_tabs"
|
|
||||||
])
|
|
||||||
@tool_args_max_length 30
|
|
||||||
|
|
||||||
embed_templates "chat_editor_html/*"
|
embed_templates "chat_editor_html/*"
|
||||||
|
|
||||||
|
# ── Public API: state assignment ───────────────────────────────────────────
|
||||||
|
|
||||||
def assign_socket(socket) do
|
def assign_socket(socket) do
|
||||||
assign(socket, :chat_editor, build(socket.assigns))
|
assign(socket, :chat_editor, MessageBuild.build(socket.assigns))
|
||||||
end
|
end
|
||||||
|
|
||||||
def toggle_model_selector(socket, reload) do
|
defdelegate build(assigns), to: MessageBuild
|
||||||
%{id: conversation_id} = socket.assigns.current_tab
|
|
||||||
current = Map.get(socket.assigns.chat_model_selectors_open, conversation_id, false)
|
|
||||||
|
|
||||||
socket
|
# ── Public API: model selection ────────────────────────────────────────────
|
||||||
|> 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
|
defdelegate toggle_model_selector(socket, reload), to: ModelSelection
|
||||||
%{id: conversation_id} = socket.assigns.current_tab
|
defdelegate set_model(socket, model_id, reload, append_output), to: ModelSelection
|
||||||
|
|
||||||
case AI.set_conversation_model(conversation_id, model_id) do
|
# ── Public API: input + surface state ──────────────────────────────────────
|
||||||
{:ok, _conversation} ->
|
|
||||||
socket
|
|
||||||
|> assign(:chat_model_selectors_open, Map.put(socket.assigns.chat_model_selectors_open, conversation_id, false))
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
socket
|
|
||||||
|> append_output.(translated("Chat"), inspect(reason), nil, "error")
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_input(socket, value, reload) do
|
def update_input(socket, value, reload) do
|
||||||
%{id: conversation_id} = socket.assigns.current_tab
|
%{id: conversation_id} = socket.assigns.current_tab
|
||||||
|
|
||||||
socket
|
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)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -71,7 +48,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
def select_surface_tab(socket, surface_id, index, reload)
|
def select_surface_tab(socket, surface_id, index, reload)
|
||||||
when is_binary(surface_id) and is_integer(index) and index >= 0 do
|
when is_binary(surface_id) and is_integer(index) and index >= 0 do
|
||||||
socket
|
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)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -82,29 +62,46 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
def set_action_error(socket, conversation_id, message, reload)
|
def set_action_error(socket, conversation_id, message, reload)
|
||||||
when is_binary(conversation_id) and is_binary(message) do
|
when is_binary(conversation_id) and is_binary(message) do
|
||||||
socket
|
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)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_action_error(socket, conversation_id, reload) when is_binary(conversation_id) do
|
def clear_action_error(socket, conversation_id, reload) when is_binary(conversation_id) do
|
||||||
socket
|
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)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ── Public API: messaging ──────────────────────────────────────────────────
|
||||||
|
|
||||||
def send_message(socket, reload, append_output) do
|
def send_message(socket, reload, append_output) do
|
||||||
%{id: conversation_id} = socket.assigns.current_tab
|
%{id: conversation_id} = socket.assigns.current_tab
|
||||||
message = socket.assigns.chat_editor_inputs |> Map.get(conversation_id, "") |> String.trim()
|
message = socket.assigns.chat_editor_inputs |> Map.get(conversation_id, "") |> String.trim()
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
message == "" -> reload.(socket, socket.assigns.workbench)
|
message == "" ->
|
||||||
Map.has_key?(socket.assigns.chat_editor_requests, conversation_id) -> reload.(socket, socket.assigns.workbench)
|
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.assigns.offline_mode ->
|
||||||
socket
|
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)
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
needs_api_key?(false) ->
|
ModelSelection.needs_api_key?(false) ->
|
||||||
reload.(socket, socket.assigns.workbench)
|
reload.(socket, socket.assigns.workbench)
|
||||||
|
|
||||||
true ->
|
true ->
|
||||||
@@ -121,10 +118,28 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
:ok = allow_repo_sandbox(task.pid)
|
:ok = allow_repo_sandbox(task.pid)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:chat_editor_inputs, Map.put(socket.assigns.chat_editor_inputs, conversation_id, ""))
|
|> assign(
|
||||||
|> assign(:chat_editor_requests, Map.put(socket.assigns.chat_editor_requests, conversation_id, %{ref: task.ref, pid: task.pid, message: message, content: "", tool_events: []}))
|
:chat_editor_inputs,
|
||||||
|> assign(:chat_editor_request_refs, Map.put(socket.assigns.chat_editor_request_refs, task.ref, conversation_id))
|
Map.put(socket.assigns.chat_editor_inputs, conversation_id, "")
|
||||||
|> assign(:chat_editor_action_errors, Map.delete(socket.assigns.chat_editor_action_errors, 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)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -133,37 +148,67 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
%{id: conversation_id} = socket.assigns.current_tab
|
%{id: conversation_id} = socket.assigns.current_tab
|
||||||
|
|
||||||
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do
|
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 ->
|
%{ref: ref} = _request ->
|
||||||
:ok = AI.cancel_chat(conversation_id)
|
:ok = AI.cancel_chat(conversation_id)
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> assign(:chat_editor_requests, Map.delete(socket.assigns.chat_editor_requests, conversation_id))
|
|> assign(
|
||||||
|> assign(:chat_editor_request_refs, Map.delete(socket.assigns.chat_editor_request_refs, ref))
|
: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)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def note_tool_call(socket, conversation_id, tool_call, reload)
|
def note_tool_call(socket, conversation_id, tool_call, reload)
|
||||||
when is_binary(conversation_id) and is_map(tool_call) do
|
when is_binary(conversation_id) and is_map(tool_call) do
|
||||||
update_request(socket, conversation_id, fn request ->
|
update_request(
|
||||||
update_in(request.tool_events, &(&1 ++ [%{type: :call, name: tool_call_name(tool_call), arguments: tool_call_arguments(tool_call)}]))
|
socket,
|
||||||
end, reload)
|
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
|
end
|
||||||
|
|
||||||
def note_tool_result(socket, conversation_id, name, reload)
|
def note_tool_result(socket, conversation_id, name, reload)
|
||||||
when is_binary(conversation_id) and is_binary(name) do
|
when is_binary(conversation_id) and is_binary(name) do
|
||||||
update_request(socket, conversation_id, fn request ->
|
update_request(
|
||||||
update_in(request.tool_events, &(&1 ++ [%{type: :result, name: name}]))
|
socket,
|
||||||
end, reload)
|
conversation_id,
|
||||||
|
fn request ->
|
||||||
|
update_in(request.tool_events, &(&1 ++ [%{type: :result, name: name}]))
|
||||||
|
end,
|
||||||
|
reload
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def note_streaming_content(socket, conversation_id, content, reload)
|
def note_streaming_content(socket, conversation_id, content, reload)
|
||||||
when is_binary(conversation_id) and is_binary(content) do
|
when is_binary(conversation_id) and is_binary(content) do
|
||||||
update_request(socket, conversation_id, fn request ->
|
update_request(
|
||||||
%{request | content: content}
|
socket,
|
||||||
end, reload)
|
conversation_id,
|
||||||
|
fn request -> %{request | content: content} end,
|
||||||
|
reload
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def finish_request(socket, ref, result, reload, append_output) when is_reference(ref) do
|
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 =
|
||||||
socket
|
socket
|
||||||
|> assign(:chat_editor_request_refs, remaining_refs)
|
|> 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
|
case result do
|
||||||
{:ok, _reply} ->
|
{:ok, _reply} ->
|
||||||
@@ -195,47 +243,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do
|
# ── HEEx-callable helpers ─────────────────────────────────────────────────
|
||||||
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
|
|
||||||
|
|
||||||
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")
|
||||||
|
|
||||||
def tool_call_name(tool_call) when is_map(tool_call) do
|
defdelegate tool_call_name(tool_call), to: ToolTracking
|
||||||
Map.get(tool_call, "name") || Map.get(tool_call, :name) || "tool"
|
defdelegate tool_call_arguments(tool_call), to: ToolTracking
|
||||||
end
|
|
||||||
|
|
||||||
def tool_call_arguments(tool_call) when is_map(tool_call) do
|
|
||||||
Map.get(tool_call, "arguments") || Map.get(tool_call, :arguments) || Map.get(tool_call, "args") || Map.get(tool_call, :args) || %{}
|
|
||||||
end
|
|
||||||
|
|
||||||
def tool_surface_type(surface), do: Map.get(surface, :type, "json")
|
def 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: ""
|
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(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)
|
||||||
|
|
||||||
@@ -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) when value in [true, "true", 1, "1", "on"], do: true
|
||||||
def truthy?(_value), do: false
|
def truthy?(_value), do: false
|
||||||
|
|
||||||
|
# ── HEEx components ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
attr :markers, :list, required: true
|
attr :markers, :list, required: true
|
||||||
|
|
||||||
def chat_tool_markers(assigns) do
|
def chat_tool_markers(assigns) do
|
||||||
@@ -511,381 +509,19 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_entries(messages, assigns) do
|
# ── Private helpers ───────────────────────────────────────────────────────
|
||||||
{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
|
|
||||||
|
|
||||||
defp update_request(socket, conversation_id, updater, reload) do
|
defp update_request(socket, conversation_id, updater, reload) do
|
||||||
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do
|
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do
|
||||||
nil -> socket
|
nil ->
|
||||||
|
socket
|
||||||
|
|
||||||
request ->
|
request ->
|
||||||
socket
|
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)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -904,23 +540,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
:ok
|
:ok
|
||||||
end
|
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
|
defp rewrite_external_images(html) do
|
||||||
html =
|
html =
|
||||||
Regex.replace(~r/<img\b(?=[^>]*\bsrc="(https?:\/\/[^\"]+)")(?=[^>]*\balt="([^\"]*)")[^>]*\/?>/i, html, fn _match, src, alt ->
|
Regex.replace(
|
||||||
external_image_link(src, alt)
|
~r/<img\b(?=[^>]*\bsrc="(https?:\/\/[^\"]+)")(?=[^>]*\balt="([^\"]*)")[^>]*\/?>/i,
|
||||||
end)
|
html,
|
||||||
|
fn _match, src, alt -> external_image_link(src, alt) end
|
||||||
|
)
|
||||||
|
|
||||||
Regex.replace(~r/<img\b(?=[^>]*\bsrc="(https?:\/\/[^\"]+)")[^>]*\/?>/i, html, fn _match, src ->
|
Regex.replace(~r/<img\b(?=[^>]*\bsrc="(https?:\/\/[^\"]+)")[^>]*\/?>/i, html, fn _match, src ->
|
||||||
external_image_link(src, 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) when is_binary(value), do: String.trim(value) != ""
|
||||||
defp present?(value), do: not is_nil(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 format_error(%{kind: :endpoint_not_configured}),
|
||||||
defp stringify_list(value), do: List.wrap(value) |> Enum.map(&to_string/1)
|
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)
|
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
|
end
|
||||||
|
|||||||
118
lib/bds/desktop/shell_live/chat_editor/message_build.ex
Normal file
118
lib/bds/desktop/shell_live/chat_editor/message_build.ex
Normal file
@@ -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
|
||||||
80
lib/bds/desktop/shell_live/chat_editor/model_selection.ex
Normal file
80
lib/bds/desktop/shell_live/chat_editor/model_selection.ex
Normal file
@@ -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
|
||||||
274
lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex
Normal file
274
lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex
Normal file
@@ -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
|
||||||
101
lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex
Normal file
101
lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex
Normal file
@@ -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
|
||||||
@@ -555,9 +555,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
this.handleNativeMenuAction = (event) => {
|
this.handleNativeMenuAction = (event) => {
|
||||||
const action = event.detail?.action;
|
const action = event.detail?.action;
|
||||||
|
const ackId = event.detail?.ackId;
|
||||||
|
|
||||||
if (action) {
|
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 } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -74,9 +74,20 @@ for await (const line of rl) {
|
|||||||
|
|
||||||
if (message.command === "native_menu_action") {
|
if (message.command === "native_menu_action") {
|
||||||
await page.evaluate((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);
|
}, message.action);
|
||||||
await page.waitForTimeout(50);
|
|
||||||
console.log(JSON.stringify({ ref, status: "ok", result: "ok" }));
|
console.log(JSON.stringify({ ref, status: "ok", result: "ok" }));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user