chore: refactored chat_editor
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user