feat: step 6 first round
This commit is contained in:
133
lib/bds/ai.ex
133
lib/bds/ai.ex
@@ -521,11 +521,19 @@ defmodule BDS.AI do
|
||||
with {:ok, response} <- runtime.generate(endpoint_with_model(endpoint, model), request, opts),
|
||||
{:ok, assistant_message} <- persist_assistant_response(conversation.id, response),
|
||||
:ok <- touch_conversation(conversation.id) do
|
||||
if is_binary(Map.get(response, :content)) and String.trim(Map.get(response, :content)) != "" do
|
||||
notify_chat_event(opts, {:chat_streaming_content, conversation.id, Map.get(response, :content)})
|
||||
end
|
||||
|
||||
tool_calls = decode_tool_calls(Map.get(response, :tool_calls))
|
||||
|
||||
Enum.each(tool_calls, fn tool_call ->
|
||||
notify_chat_event(opts, {:chat_tool_call, conversation.id, tool_call})
|
||||
end)
|
||||
|
||||
cond do
|
||||
tool_calls != [] and tools != [] ->
|
||||
with {:ok, tool_messages} <- execute_tool_calls(conversation.id, tool_calls, project_id),
|
||||
with {:ok, tool_messages} <- execute_tool_calls(conversation.id, tool_calls, project_id, opts),
|
||||
updated_messages <- load_chat_messages(conversation.id),
|
||||
{:ok, reply} <-
|
||||
chat_round(
|
||||
@@ -575,7 +583,7 @@ defmodule BDS.AI do
|
||||
})
|
||||
end
|
||||
|
||||
defp execute_tool_calls(conversation_id, tool_calls, project_id) do
|
||||
defp execute_tool_calls(conversation_id, tool_calls, project_id, opts) do
|
||||
tool_messages =
|
||||
Enum.map(tool_calls, fn tool_call ->
|
||||
result = execute_tool(tool_call.name, tool_call.arguments || %{}, project_id)
|
||||
@@ -589,6 +597,8 @@ defmodule BDS.AI do
|
||||
created_at: Persistence.now_ms()
|
||||
})
|
||||
|
||||
notify_chat_event(opts, {:chat_tool_result, conversation_id, tool_call.name})
|
||||
|
||||
format_chat_message(message)
|
||||
end)
|
||||
|
||||
@@ -652,7 +662,51 @@ defmodule BDS.AI do
|
||||
%{
|
||||
type: "form",
|
||||
title: arguments["title"],
|
||||
fields: arguments["fields"] || []
|
||||
fields: arguments["fields"] || [],
|
||||
submit_label: arguments["submit_label"] || arguments["submitLabel"],
|
||||
submit_action: arguments["submit_action"] || arguments["submitAction"]
|
||||
}
|
||||
end
|
||||
|
||||
defp execute_tool("render_card", arguments, _project_id) do
|
||||
%{
|
||||
type: "card",
|
||||
title: arguments["title"],
|
||||
subtitle: arguments["subtitle"],
|
||||
body: arguments["body"],
|
||||
actions: arguments["actions"] || []
|
||||
}
|
||||
end
|
||||
|
||||
defp execute_tool("render_metric", arguments, _project_id) do
|
||||
%{
|
||||
type: "metric",
|
||||
label: arguments["label"],
|
||||
value: arguments["value"]
|
||||
}
|
||||
end
|
||||
|
||||
defp execute_tool("render_list", arguments, _project_id) do
|
||||
%{
|
||||
type: "list",
|
||||
title: arguments["title"],
|
||||
items: arguments["items"] || []
|
||||
}
|
||||
end
|
||||
|
||||
defp execute_tool("render_tabs", arguments, _project_id) do
|
||||
%{
|
||||
type: "tabs",
|
||||
title: arguments["title"],
|
||||
tabs: arguments["tabs"] || []
|
||||
}
|
||||
end
|
||||
|
||||
defp execute_tool("render_mindmap", arguments, _project_id) do
|
||||
%{
|
||||
type: "mindmap",
|
||||
title: arguments["title"],
|
||||
nodes: arguments["nodes"] || []
|
||||
}
|
||||
end
|
||||
|
||||
@@ -736,9 +790,14 @@ defmodule BDS.AI do
|
||||
|
||||
project_tools ++
|
||||
[
|
||||
%{name: "render_card", spec: tool_spec("render_card", "Return a structured card payload", render_card_schema())},
|
||||
%{name: "render_table", spec: tool_spec("render_table", "Return a structured table payload", render_table_schema())},
|
||||
%{name: "render_chart", spec: tool_spec("render_chart", "Return a structured chart payload", render_chart_schema())},
|
||||
%{name: "render_form", spec: tool_spec("render_form", "Return a structured form payload", render_form_schema())}
|
||||
%{name: "render_form", spec: tool_spec("render_form", "Return a structured form payload", render_form_schema())},
|
||||
%{name: "render_metric", spec: tool_spec("render_metric", "Return a structured metric payload", render_metric_schema())},
|
||||
%{name: "render_list", spec: tool_spec("render_list", "Return a structured list payload", render_list_schema())},
|
||||
%{name: "render_tabs", spec: tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema())},
|
||||
%{name: "render_mindmap", spec: tool_spec("render_mindmap", "Return a structured mindmap payload", render_mindmap_schema())}
|
||||
]
|
||||
else
|
||||
[]
|
||||
@@ -1162,7 +1221,61 @@ defmodule BDS.AI do
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"fields" => %{"type" => "array"}
|
||||
"fields" => %{"type" => "array"},
|
||||
"submitLabel" => %{"type" => "string"},
|
||||
"submitAction" => %{"type" => "string"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_card_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"subtitle" => %{"type" => "string"},
|
||||
"body" => %{"type" => "string"},
|
||||
"actions" => %{"type" => "array"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_metric_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string"},
|
||||
"value" => %{"type" => "string"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_list_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"items" => %{"type" => "array"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_tabs_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"tabs" => %{"type" => "array"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_mindmap_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"nodes" => %{"type" => "array"}
|
||||
}
|
||||
}
|
||||
end
|
||||
@@ -1170,6 +1283,16 @@ defmodule BDS.AI do
|
||||
defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
|
||||
defp normalize_limit(_value), do: 10
|
||||
|
||||
defp notify_chat_event(opts, event) do
|
||||
case Keyword.get(opts, :event_target) do
|
||||
pid when is_pid(pid) -> send(pid, event)
|
||||
callback when is_function(callback, 1) -> callback.(event)
|
||||
_other -> :ok
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp truncate_text(nil, _max_length), do: ""
|
||||
|
||||
defp truncate_text(text, max_length) when is_binary(text) do
|
||||
|
||||
@@ -98,6 +98,11 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|> assign(:template_editor_drafts, %{})
|
||||
|> assign(:chat_editor_inputs, %{})
|
||||
|> assign(:chat_model_selectors_open, %{})
|
||||
|> assign(:chat_editor_requests, %{})
|
||||
|> assign(:chat_editor_request_refs, %{})
|
||||
|> assign(:chat_editor_surface_data, %{})
|
||||
|> assign(:chat_editor_surface_tabs, %{})
|
||||
|> assign(:chat_editor_action_errors, %{})
|
||||
|> assign(:misc_editor_selected_pairs, %{})
|
||||
|> assign(:misc_editor_git_selected_files, %{})
|
||||
|> assign(:metadata_diff_active_tabs, %{})
|
||||
@@ -697,6 +702,29 @@ defmodule BDS.Desktop.ShellLive do
|
||||
{:noreply, ChatEditor.send_message(socket, &reload_shell/2, &append_output_entry/5)}
|
||||
end
|
||||
|
||||
def handle_event("abort_chat_editor_message", _params, socket) do
|
||||
{:noreply, ChatEditor.abort_message(socket, &reload_shell/2)}
|
||||
end
|
||||
|
||||
def handle_event("open_chat_settings", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> clear_chat_action_error()
|
||||
|> open_sidebar_item(%{"route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI"}, :pin)}
|
||||
end
|
||||
|
||||
def handle_event("change_chat_surface_form", %{"surface" => %{"id" => surface_id, "fields" => fields}}, socket) do
|
||||
{:noreply, ChatEditor.update_surface_form(socket, surface_id, fields, &reload_shell/2)}
|
||||
end
|
||||
|
||||
def handle_event("select_chat_surface_tab", %{"surface-id" => surface_id, "index" => index}, socket) do
|
||||
{:noreply, ChatEditor.select_surface_tab(socket, surface_id, parse_integer(index), &reload_shell/2)}
|
||||
end
|
||||
|
||||
def handle_event("chat_surface_action", params, socket) do
|
||||
{:noreply, handle_chat_surface_action(socket, params)}
|
||||
end
|
||||
|
||||
def handle_event("rerun_misc_editor", _params, socket) do
|
||||
case MiscEditor.rerun(socket) do
|
||||
{:command, action} -> {:noreply, apply_shell_command(socket, action)}
|
||||
@@ -1068,6 +1096,33 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({ref, result}, socket) when is_reference(ref) do
|
||||
Process.demonitor(ref, [:flush])
|
||||
{:noreply, ChatEditor.finish_request(socket, ref, result, &reload_shell/2, &append_output_entry/5)}
|
||||
end
|
||||
|
||||
def handle_info({:DOWN, ref, :process, _pid, reason}, socket) when is_reference(ref) do
|
||||
next_socket =
|
||||
case reason do
|
||||
:normal -> socket
|
||||
_other -> ChatEditor.finish_request(socket, ref, {:error, :cancelled}, &reload_shell/2, &append_output_entry/5)
|
||||
end
|
||||
|
||||
{:noreply, next_socket}
|
||||
end
|
||||
|
||||
def handle_info({:chat_tool_call, conversation_id, tool_call}, socket) do
|
||||
{:noreply, ChatEditor.note_tool_call(socket, conversation_id, tool_call, &reload_shell/2)}
|
||||
end
|
||||
|
||||
def handle_info({:chat_tool_result, conversation_id, name}, socket) do
|
||||
{:noreply, ChatEditor.note_tool_result(socket, conversation_id, name, &reload_shell/2)}
|
||||
end
|
||||
|
||||
def handle_info({:chat_streaming_content, conversation_id, content}, socket) do
|
||||
{:noreply, ChatEditor.note_streaming_content(socket, conversation_id, content, &reload_shell/2)}
|
||||
end
|
||||
|
||||
def handle_info(:refresh_task_status, socket) do
|
||||
raw_task_status = BDS.Tasks.status_snapshot()
|
||||
|
||||
@@ -2011,6 +2066,166 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|
||||
defp git_history_target(_tab), do: nil
|
||||
|
||||
defp handle_chat_surface_action(socket, params) do
|
||||
surface_id = Map.get(params, "surface-id", "")
|
||||
|
||||
payload =
|
||||
params
|
||||
|> Map.get("payload")
|
||||
|> decode_chat_surface_payload()
|
||||
|> maybe_put_chat_surface_form_data(socket, surface_id)
|
||||
|
||||
case normalize_chat_action(Map.get(params, "action", "")) do
|
||||
:open_post ->
|
||||
case Map.get(payload, "postId") || Map.get(payload, "post_id") do
|
||||
post_id when is_binary(post_id) and post_id != "" ->
|
||||
socket
|
||||
|> clear_chat_action_error()
|
||||
|> open_sidebar_item(%{"route" => "post", "id" => post_id, "title" => post_title(post_id), "subtitle" => post_subtitle(post_id)}, :pin)
|
||||
|
||||
_other ->
|
||||
ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Invalid payload for openPost action", &reload_shell/2)
|
||||
end
|
||||
|
||||
:open_media ->
|
||||
case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do
|
||||
media_id when is_binary(media_id) and media_id != "" ->
|
||||
socket
|
||||
|> clear_chat_action_error()
|
||||
|> open_sidebar_item(%{"route" => "media", "id" => media_id, "title" => media_title(media_id), "subtitle" => media_subtitle(media_id)}, :pin)
|
||||
|
||||
_other ->
|
||||
ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Invalid payload for openMedia action", &reload_shell/2)
|
||||
end
|
||||
|
||||
:open_settings ->
|
||||
socket
|
||||
|> clear_chat_action_error()
|
||||
|> open_sidebar_item(%{"route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI"}, :pin)
|
||||
|
||||
:open_chat ->
|
||||
chat_id = Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") || socket.assigns.current_tab.id
|
||||
|
||||
socket
|
||||
|> clear_chat_action_error()
|
||||
|> open_sidebar_item(%{"route" => "chat", "id" => chat_id, "title" => Map.get(payload, "title", "Chat"), "subtitle" => Map.get(payload, "subtitle", "")}, :pin)
|
||||
|
||||
:switch_view ->
|
||||
case safe_existing_atom(Map.get(payload, "view")) do
|
||||
nil -> ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Invalid payload for switchView action", &reload_shell/2)
|
||||
view ->
|
||||
socket
|
||||
|> clear_chat_action_error()
|
||||
|> reload_shell(Workbench.click_activity(socket.assigns.workbench, view))
|
||||
end
|
||||
|
||||
:toggle_sidebar ->
|
||||
socket
|
||||
|> clear_chat_action_error()
|
||||
|> reload_shell(Workbench.toggle_sidebar(socket.assigns.workbench))
|
||||
|
||||
:toggle_panel ->
|
||||
socket
|
||||
|> clear_chat_action_error()
|
||||
|> reload_shell(Workbench.toggle_panel(socket.assigns.workbench))
|
||||
|
||||
:toggle_assistant_sidebar ->
|
||||
socket
|
||||
|> clear_chat_action_error()
|
||||
|> reload_shell(Workbench.toggle_assistant_sidebar(socket.assigns.workbench))
|
||||
|
||||
:unknown ->
|
||||
ChatEditor.set_action_error(socket, socket.assigns.current_tab.id, "Unsupported assistant action", &reload_shell/2)
|
||||
end
|
||||
end
|
||||
|
||||
defp clear_chat_action_error(%{assigns: %{current_tab: %{type: :chat, id: conversation_id}}} = socket) do
|
||||
assign(socket, :chat_editor_action_errors, Map.delete(socket.assigns.chat_editor_action_errors, conversation_id))
|
||||
end
|
||||
|
||||
defp clear_chat_action_error(socket), do: socket
|
||||
|
||||
defp decode_chat_surface_payload(nil), do: %{}
|
||||
defp decode_chat_surface_payload(""), do: %{}
|
||||
|
||||
defp decode_chat_surface_payload(payload) when is_binary(payload) do
|
||||
case Jason.decode(payload) do
|
||||
{:ok, decoded} when is_map(decoded) -> decoded
|
||||
_other -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_chat_surface_payload(_payload), do: %{}
|
||||
|
||||
defp maybe_put_chat_surface_form_data(payload, socket, surface_id) when is_binary(surface_id) and surface_id != "" do
|
||||
form_data = ChatEditor.current_surface_data(socket, surface_id)
|
||||
|
||||
if form_data == %{} do
|
||||
payload
|
||||
else
|
||||
Map.put(payload, "formData", form_data)
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_put_chat_surface_form_data(payload, _socket, _surface_id), do: payload
|
||||
|
||||
defp normalize_chat_action(action) do
|
||||
action
|
||||
|> to_string()
|
||||
|> String.replace("_", "")
|
||||
|> String.downcase()
|
||||
|> case do
|
||||
"openpost" -> :open_post
|
||||
"openmedia" -> :open_media
|
||||
"opensettings" -> :open_settings
|
||||
"openchat" -> :open_chat
|
||||
"switchview" -> :switch_view
|
||||
"setactiveview" -> :switch_view
|
||||
"togglesidebar" -> :toggle_sidebar
|
||||
"togglepanel" -> :toggle_panel
|
||||
"openpanel" -> :toggle_panel
|
||||
"toggleassistantsidebar" -> :toggle_assistant_sidebar
|
||||
_other -> :unknown
|
||||
end
|
||||
end
|
||||
|
||||
defp post_title(post_id) do
|
||||
case Repo.get(Post, post_id) do
|
||||
%Post{} = post -> post.title || post.slug || post.id
|
||||
_other -> "Post"
|
||||
end
|
||||
end
|
||||
|
||||
defp post_subtitle(post_id) do
|
||||
case Repo.get(Post, post_id) do
|
||||
%Post{} = post -> post.slug || "draft"
|
||||
_other -> "draft"
|
||||
end
|
||||
end
|
||||
|
||||
defp media_title(media_id) do
|
||||
case Repo.get(Media, media_id) do
|
||||
%Media{} = media -> media.title || media.filename || media.id
|
||||
_other -> "Media"
|
||||
end
|
||||
end
|
||||
|
||||
defp media_subtitle(media_id) do
|
||||
case Repo.get(Media, media_id) do
|
||||
%Media{} = media -> media.filename || media.mime_type || "media"
|
||||
_other -> "media"
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_integer(value) when is_integer(value), do: value
|
||||
|
||||
defp parse_integer(value) do
|
||||
case Integer.parse(to_string(value || "0")) do
|
||||
{parsed, _rest} -> parsed
|
||||
:error -> 0
|
||||
end
|
||||
end
|
||||
|
||||
defp short_commit_hash(hash) when is_binary(hash), do: String.slice(hash, 0, 7)
|
||||
defp short_commit_hash(_hash), do: "-------"
|
||||
|
||||
|
||||
@@ -2,11 +2,24 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
@moduledoc false
|
||||
|
||||
use Phoenix.Component
|
||||
import Phoenix.HTML, only: [raw: 1]
|
||||
|
||||
alias BDS.{AI, Repo}
|
||||
alias BDS.AI.ChatConversation
|
||||
alias BDS.Desktop.ShellData
|
||||
|
||||
@render_tool_names MapSet.new([
|
||||
"render_card",
|
||||
"render_chart",
|
||||
"render_form",
|
||||
"render_list",
|
||||
"render_metric",
|
||||
"render_mindmap",
|
||||
"render_table",
|
||||
"render_tabs"
|
||||
])
|
||||
@tool_args_max_length 30
|
||||
|
||||
embed_templates "chat_editor_html/*"
|
||||
|
||||
def assign_socket(socket) do
|
||||
@@ -46,27 +59,137 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def update_surface_form(socket, surface_id, fields, reload)
|
||||
when is_binary(surface_id) and is_map(fields) do
|
||||
next_data = Map.put(socket.assigns.chat_editor_surface_data, surface_id, fields)
|
||||
|
||||
socket
|
||||
|> assign(:chat_editor_surface_data, next_data)
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def select_surface_tab(socket, surface_id, index, reload)
|
||||
when is_binary(surface_id) and is_integer(index) and index >= 0 do
|
||||
socket
|
||||
|> assign(:chat_editor_surface_tabs, Map.put(socket.assigns.chat_editor_surface_tabs, surface_id, index))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def current_surface_data(socket, surface_id) when is_binary(surface_id) do
|
||||
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{})
|
||||
end
|
||||
|
||||
def set_action_error(socket, conversation_id, message, reload)
|
||||
when is_binary(conversation_id) and is_binary(message) do
|
||||
socket
|
||||
|> assign(:chat_editor_action_errors, Map.put(socket.assigns.chat_editor_action_errors, conversation_id, message))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def clear_action_error(socket, conversation_id, reload) when is_binary(conversation_id) do
|
||||
socket
|
||||
|> assign(:chat_editor_action_errors, Map.delete(socket.assigns.chat_editor_action_errors, conversation_id))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def send_message(socket, reload, append_output) do
|
||||
%{id: conversation_id} = socket.assigns.current_tab
|
||||
message = socket.assigns.chat_editor_inputs |> Map.get(conversation_id, "") |> String.trim()
|
||||
|
||||
cond do
|
||||
message == "" -> reload.(socket, socket.assigns.workbench)
|
||||
Map.has_key?(socket.assigns.chat_editor_requests, conversation_id) -> reload.(socket, socket.assigns.workbench)
|
||||
socket.assigns.offline_mode ->
|
||||
socket
|
||||
|> append_output.(translated("Chat"), translated("Automatic AI actions stay gated by airplane mode."), nil, "info")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
|
||||
needs_api_key?(false) ->
|
||||
reload.(socket, socket.assigns.workbench)
|
||||
|
||||
true ->
|
||||
case AI.send_chat_message(conversation_id, message, project_id: socket.assigns.projects.active_project_id) do
|
||||
{:ok, _result} ->
|
||||
live_view_pid = self()
|
||||
|
||||
task =
|
||||
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
||||
AI.send_chat_message(conversation_id, message,
|
||||
project_id: socket.assigns.projects.active_project_id,
|
||||
event_target: live_view_pid
|
||||
)
|
||||
end)
|
||||
|
||||
:ok = allow_repo_sandbox(task.pid)
|
||||
|
||||
socket
|
||||
|> assign(:chat_editor_inputs, Map.put(socket.assigns.chat_editor_inputs, conversation_id, ""))
|
||||
|> assign(:chat_editor_requests, Map.put(socket.assigns.chat_editor_requests, conversation_id, %{ref: task.ref, pid: task.pid, message: message, content: "", tool_events: []}))
|
||||
|> assign(:chat_editor_request_refs, Map.put(socket.assigns.chat_editor_request_refs, task.ref, conversation_id))
|
||||
|> assign(:chat_editor_action_errors, Map.delete(socket.assigns.chat_editor_action_errors, conversation_id))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def abort_message(socket, reload) do
|
||||
%{id: conversation_id} = socket.assigns.current_tab
|
||||
|
||||
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do
|
||||
nil -> reload.(socket, socket.assigns.workbench)
|
||||
|
||||
%{ref: ref} = _request ->
|
||||
:ok = AI.cancel_chat(conversation_id)
|
||||
|
||||
socket
|
||||
|> assign(:chat_editor_requests, Map.delete(socket.assigns.chat_editor_requests, conversation_id))
|
||||
|> assign(:chat_editor_request_refs, Map.delete(socket.assigns.chat_editor_request_refs, ref))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def note_tool_call(socket, conversation_id, tool_call, reload)
|
||||
when is_binary(conversation_id) and is_map(tool_call) do
|
||||
update_request(socket, conversation_id, fn request ->
|
||||
update_in(request.tool_events, &(&1 ++ [%{type: :call, name: tool_call_name(tool_call), arguments: tool_call_arguments(tool_call)}]))
|
||||
end, reload)
|
||||
end
|
||||
|
||||
def note_tool_result(socket, conversation_id, name, reload)
|
||||
when is_binary(conversation_id) and is_binary(name) do
|
||||
update_request(socket, conversation_id, fn request ->
|
||||
update_in(request.tool_events, &(&1 ++ [%{type: :result, name: name}]))
|
||||
end, reload)
|
||||
end
|
||||
|
||||
def note_streaming_content(socket, conversation_id, content, reload)
|
||||
when is_binary(conversation_id) and is_binary(content) do
|
||||
update_request(socket, conversation_id, fn request ->
|
||||
%{request | content: content}
|
||||
end, reload)
|
||||
end
|
||||
|
||||
def finish_request(socket, ref, result, reload, append_output) when is_reference(ref) do
|
||||
case Map.pop(socket.assigns.chat_editor_request_refs, ref) do
|
||||
{nil, _remaining_refs} ->
|
||||
socket
|
||||
|
||||
{conversation_id, remaining_refs} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:chat_editor_request_refs, remaining_refs)
|
||||
|> assign(:chat_editor_requests, Map.delete(socket.assigns.chat_editor_requests, conversation_id))
|
||||
|
||||
case result do
|
||||
{:ok, _reply} ->
|
||||
reload.(socket, socket.assigns.workbench)
|
||||
|
||||
{:error, :cancelled} ->
|
||||
reload.(socket, socket.assigns.workbench)
|
||||
|
||||
{:error, %{kind: :endpoint_not_configured}} ->
|
||||
reload.(socket, socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output.(translated("Chat"), inspect(reason), nil, "error")
|
||||
|> append_output.(translated("Chat"), format_error(reason), nil, "error")
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
@@ -76,6 +199,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
case Repo.get(ChatConversation, conversation_id) do
|
||||
nil -> nil
|
||||
%ChatConversation{} = conversation ->
|
||||
messages = AI.list_chat_messages(conversation.id)
|
||||
request = Map.get(assigns.chat_editor_requests, conversation.id)
|
||||
|
||||
%{
|
||||
id: conversation.id,
|
||||
title: conversation.title || translated("chat.newChat"),
|
||||
@@ -83,8 +209,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
available_models: AI.available_chat_models(conversation.model),
|
||||
model_selector_open?: Map.get(assigns.chat_model_selectors_open, conversation.id, false),
|
||||
input: Map.get(assigns.chat_editor_inputs, conversation.id, ""),
|
||||
messages: build_entries(AI.list_chat_messages(conversation.id)),
|
||||
offline?: Map.get(assigns, :offline_mode, true)
|
||||
messages: build_entries(messages, assigns),
|
||||
pending_user_message: pending_user_message(messages, request),
|
||||
is_streaming: not is_nil(request),
|
||||
streaming_content: streaming_content(request),
|
||||
streaming_tool_markers: tool_markers_from_events(request),
|
||||
offline?: Map.get(assigns, :offline_mode, true),
|
||||
needs_api_key?: needs_api_key?(Map.get(assigns, :offline_mode, true)),
|
||||
action_error: Map.get(assigns.chat_editor_action_errors, conversation.id),
|
||||
send_disabled?: String.trim(Map.get(assigns.chat_editor_inputs, conversation.id, "")) == "" or not is_nil(request)
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -98,25 +231,288 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
Map.get(tool_call, "name") || Map.get(tool_call, :name) || "tool"
|
||||
end
|
||||
|
||||
def tool_call_arguments(tool_call) when is_map(tool_call) do
|
||||
Map.get(tool_call, "arguments") || Map.get(tool_call, :arguments) || Map.get(tool_call, "args") || Map.get(tool_call, :args) || %{}
|
||||
end
|
||||
|
||||
def tool_surface_type(surface), do: Map.get(surface, :type, "json")
|
||||
|
||||
defp build_entries(messages) do
|
||||
{entries, current_entry} =
|
||||
Enum.reduce(messages, {[], nil}, fn message, {entries, current_entry} ->
|
||||
def markdown_html(content) when is_binary(content) do
|
||||
html =
|
||||
case Earmark.as_html(content, escape: true) do
|
||||
{:ok, rendered, _messages} -> rendered
|
||||
{:error, rendered, _messages} -> rendered
|
||||
end
|
||||
|> rewrite_external_images()
|
||||
|
||||
raw(html)
|
||||
end
|
||||
|
||||
def markdown_html(_content), do: ""
|
||||
|
||||
def payload_json(nil), do: "{}"
|
||||
def payload_json(payload) when is_map(payload), do: Jason.encode!(payload)
|
||||
|
||||
def chart_width(_max_value, value) when not is_number(value), do: 0
|
||||
|
||||
def chart_width(max_value, value) when is_number(max_value) and max_value > 0 do
|
||||
value
|
||||
|> Kernel./(max_value)
|
||||
|> Kernel.*(100)
|
||||
|> min(100)
|
||||
|> max(0)
|
||||
|> Float.round(2)
|
||||
end
|
||||
|
||||
def chart_width(_max_value, _value), do: 0
|
||||
|
||||
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true
|
||||
def truthy?(_value), do: false
|
||||
|
||||
attr :markers, :list, required: true
|
||||
|
||||
def chat_tool_markers(assigns) do
|
||||
~H"""
|
||||
<%= if @markers != [] do %>
|
||||
<div class="chat-tool-markers">
|
||||
<%= for marker <- @markers do %>
|
||||
<div class={["chat-tool-marker", if(marker.complete?, do: "completed", else: "pending")]} data-testid="chat-tool-marker">
|
||||
<span class="chat-tool-marker-icon"><%= if marker.complete?, do: "✓", else: "●" %></span>
|
||||
<span class="chat-tool-marker-name"><%= marker.name %></span>
|
||||
<%= if marker.args_preview not in [nil, ""] do %>
|
||||
<span class="chat-tool-marker-args">(<%= marker.args_preview %>)</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :surface, :map, required: true
|
||||
|
||||
def chat_surface(assigns) do
|
||||
~H"""
|
||||
<article class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface">
|
||||
<%= case @surface.type do %>
|
||||
<% "card" -> %>
|
||||
<div class="chat-surface-card">
|
||||
<%= if present?(@surface.title) do %>
|
||||
<h3><%= @surface.title %></h3>
|
||||
<% end %>
|
||||
<%= if present?(@surface.subtitle) do %>
|
||||
<p class="chat-surface-subtitle"><%= @surface.subtitle %></p>
|
||||
<% end %>
|
||||
<p class="chat-surface-body"><%= @surface.body %></p>
|
||||
<%= if @surface.actions != [] do %>
|
||||
<div class="chat-surface-actions">
|
||||
<%= for action <- @surface.actions do %>
|
||||
<button
|
||||
class="chat-surface-action-button"
|
||||
type="button"
|
||||
phx-click="chat_surface_action"
|
||||
phx-value-surface-id={@surface.id}
|
||||
phx-value-action={action.action}
|
||||
phx-value-payload={payload_json(action.payload)}
|
||||
data-testid="chat-surface-action"
|
||||
data-action={action.action}
|
||||
>
|
||||
<%= action.label %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% "table" -> %>
|
||||
<%= if present?(@surface.title) do %>
|
||||
<h3><%= @surface.title %></h3>
|
||||
<% end %>
|
||||
<div class="chat-tool-surface-table-wrap">
|
||||
<table class="chat-tool-surface-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<%= for column <- @surface.columns do %>
|
||||
<th><%= column %></th>
|
||||
<% end %>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<%= for row <- @surface.rows do %>
|
||||
<tr>
|
||||
<%= for value <- row do %>
|
||||
<td><%= value %></td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<% "chart" -> %>
|
||||
<%= if present?(@surface.title) do %>
|
||||
<h3><%= @surface.title %></h3>
|
||||
<% end %>
|
||||
<p class="chat-surface-chart-type"><%= @surface.chart_type %></p>
|
||||
<div class="chat-surface-chart-list">
|
||||
<%= for series <- @surface.series do %>
|
||||
<div class="chat-surface-chart-row">
|
||||
<div class="chat-surface-chart-meta">
|
||||
<span><%= series.label %></span>
|
||||
<span><%= series.value %></span>
|
||||
</div>
|
||||
<div class="chat-surface-chart-bar">
|
||||
<span style={"width: #{chart_width(@surface.max_value, series.value)}%"}></span>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% "metric" -> %>
|
||||
<div class="chat-surface-metric">
|
||||
<span class="chat-surface-metric-label"><%= @surface.label %></span>
|
||||
<strong class="chat-surface-metric-value"><%= @surface.value %></strong>
|
||||
</div>
|
||||
|
||||
<% "list" -> %>
|
||||
<%= if present?(@surface.title) do %>
|
||||
<h3><%= @surface.title %></h3>
|
||||
<% end %>
|
||||
<ul class="chat-surface-list">
|
||||
<%= for item <- @surface.items do %>
|
||||
<li><%= item %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<% "mindmap" -> %>
|
||||
<%= if present?(@surface.title) do %>
|
||||
<h3><%= @surface.title %></h3>
|
||||
<% end %>
|
||||
<ul class="chat-surface-mindmap">
|
||||
<%= for node <- @surface.nodes do %>
|
||||
<li>
|
||||
<strong><%= node.label %></strong>
|
||||
<%= if node.children != [] do %>
|
||||
<span class="chat-surface-mindmap-children"><%= Enum.join(node.children, ", ") %></span>
|
||||
<% end %>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
|
||||
<% "tabs" -> %>
|
||||
<%= if present?(@surface.title) do %>
|
||||
<h3><%= @surface.title %></h3>
|
||||
<% end %>
|
||||
<div class="chat-surface-tabs">
|
||||
<div class="chat-surface-tab-list">
|
||||
<%= for {tab, index} <- Enum.with_index(@surface.tabs) do %>
|
||||
<button
|
||||
class={["chat-surface-tab-button", if(index == @surface.selected_index, do: "active")]}
|
||||
type="button"
|
||||
phx-click="select_chat_surface_tab"
|
||||
phx-value-surface-id={@surface.id}
|
||||
phx-value-index={index}
|
||||
>
|
||||
<%= tab.label %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= case Enum.at(@surface.tabs, @surface.selected_index || 0) do %>
|
||||
<% nil -> %>
|
||||
<% tab -> %>
|
||||
<div class="chat-surface-tab-panel">
|
||||
<%= for content <- tab.content do %>
|
||||
<.chat_surface surface={content} />
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% "form" -> %>
|
||||
<%= if present?(@surface.title) do %>
|
||||
<h3><%= @surface.title %></h3>
|
||||
<% end %>
|
||||
<form class="chat-surface-form" phx-change="change_chat_surface_form">
|
||||
<input type="hidden" name="surface[id]" value={@surface.id} />
|
||||
|
||||
<%= for field <- @surface.fields do %>
|
||||
<label class="chat-surface-form-field">
|
||||
<span><%= field.label %></span>
|
||||
|
||||
<%= case field.input_type do %>
|
||||
<% "textarea" -> %>
|
||||
<textarea name={"surface[fields][#{field.key}]"} placeholder={field.placeholder}><%= field.value || "" %></textarea>
|
||||
|
||||
<% "select" -> %>
|
||||
<select name={"surface[fields][#{field.key}]"}>
|
||||
<%= for option <- field.options do %>
|
||||
<option value={option.value} selected={to_string(field.value || "") == to_string(option.value)}><%= option.label %></option>
|
||||
<% end %>
|
||||
</select>
|
||||
|
||||
<% "checkbox" -> %>
|
||||
<span class="chat-surface-form-checkbox">
|
||||
<input type="hidden" name={"surface[fields][#{field.key}]"} value="false" />
|
||||
<input type="checkbox" name={"surface[fields][#{field.key}]"} value="true" checked={truthy?(field.value)} />
|
||||
</span>
|
||||
|
||||
<% _other -> %>
|
||||
<input type={surface_input_type(field.input_type)} name={"surface[fields][#{field.key}]"} value={field.value || ""} placeholder={field.placeholder} />
|
||||
<% end %>
|
||||
</label>
|
||||
<% end %>
|
||||
</form>
|
||||
|
||||
<%= if present?(@surface.submit_label) do %>
|
||||
<div class="chat-surface-actions">
|
||||
<button
|
||||
class="chat-surface-action-button"
|
||||
type="button"
|
||||
phx-click="chat_surface_action"
|
||||
phx-value-surface-id={@surface.id}
|
||||
phx-value-action={@surface.submit_action}
|
||||
phx-value-payload="{}"
|
||||
data-testid="chat-surface-action"
|
||||
data-action={@surface.submit_action}
|
||||
>
|
||||
<%= @surface.submit_label %>
|
||||
</button>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% "text" -> %>
|
||||
<div class="chat-surface-text"><%= @surface.body %></div>
|
||||
|
||||
<% _other -> %>
|
||||
<pre class="chat-tool-surface-json"><%= Jason.encode!(@surface.raw || %{}, pretty: true) %></pre>
|
||||
<% end %>
|
||||
</article>
|
||||
"""
|
||||
end
|
||||
|
||||
defp build_entries(messages, assigns) do
|
||||
{entries, current_entry, _turn_index} =
|
||||
Enum.reduce(messages, {[], nil, -1}, fn message, {entries, current_entry, turn_index} ->
|
||||
case message.role do
|
||||
:tool ->
|
||||
if current_entry && current_entry.role == :assistant do
|
||||
{entries, append_tool_surface(current_entry, message)}
|
||||
{entries, append_tool_surface(current_entry, message), turn_index}
|
||||
else
|
||||
{entries, current_entry}
|
||||
{entries, current_entry, turn_index}
|
||||
end
|
||||
|
||||
:system ->
|
||||
{entries, current_entry}
|
||||
{entries, current_entry, turn_index}
|
||||
|
||||
:user ->
|
||||
entries = finalize_entry(entries, current_entry)
|
||||
next_turn_index = turn_index + 1
|
||||
{entries, start_entry(message, next_turn_index, assigns), next_turn_index}
|
||||
|
||||
_other ->
|
||||
entries = finalize_entry(entries, current_entry)
|
||||
{entries, start_entry(message)}
|
||||
{entries, start_entry(message, turn_index, assigns), turn_index}
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -128,17 +524,23 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
defp finalize_entry(entries, nil), do: entries
|
||||
defp finalize_entry(entries, entry), do: [entry | entries]
|
||||
|
||||
defp start_entry(message) do
|
||||
defp start_entry(message, turn_index, assigns) do
|
||||
tool_markers = normalize_tool_calls(message.tool_calls)
|
||||
|
||||
%{
|
||||
id: message.id,
|
||||
role: message.role,
|
||||
content: message.content || "",
|
||||
tool_markers: normalize_tool_calls(message.tool_calls),
|
||||
turn_index: turn_index,
|
||||
tool_markers: tool_markers,
|
||||
inline_surfaces: build_render_surfaces(tool_markers, message.id, assigns),
|
||||
tool_surfaces: []
|
||||
}
|
||||
end
|
||||
|
||||
defp append_tool_surface(entry, message) do
|
||||
entry = mark_tool_call_completed(entry, message.tool_call_id)
|
||||
|
||||
case normalize_tool_surface(message.content) do
|
||||
nil -> entry
|
||||
surface -> update_in(entry.tool_surfaces, &(&1 ++ [surface]))
|
||||
@@ -147,15 +549,254 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|
||||
defp normalize_tool_calls(tool_calls) when is_list(tool_calls) do
|
||||
Enum.map(tool_calls, fn tool_call ->
|
||||
arguments = tool_call_arguments(tool_call)
|
||||
|
||||
%{
|
||||
id: Map.get(tool_call, "id") || Map.get(tool_call, :id),
|
||||
name: tool_call_name(tool_call),
|
||||
arguments: Map.get(tool_call, "arguments") || Map.get(tool_call, :arguments) || Map.get(tool_call, "args") || Map.get(tool_call, :args) || %{}
|
||||
arguments: arguments,
|
||||
args_preview: tool_arguments_preview(arguments),
|
||||
complete?: false
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_tool_calls(_tool_calls), do: []
|
||||
|
||||
defp build_render_surfaces(tool_calls, message_id, assigns) do
|
||||
tool_calls
|
||||
|> Enum.with_index()
|
||||
|> Enum.flat_map(fn {tool_call, index} ->
|
||||
case build_render_surface(tool_call, "#{message_id}-surface-#{index}", assigns) do
|
||||
nil -> []
|
||||
surface -> [surface]
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp build_render_surface(%{name: name, arguments: arguments}, surface_id, assigns) do
|
||||
if MapSet.member?(@render_tool_names, name) do
|
||||
do_build_render_surface(name, arguments || %{}, surface_id, assigns)
|
||||
end
|
||||
end
|
||||
|
||||
defp do_build_render_surface("render_card", arguments, surface_id, _assigns) do
|
||||
%{
|
||||
id: surface_id,
|
||||
type: "card",
|
||||
title: map_value(arguments, "title"),
|
||||
subtitle: map_value(arguments, "subtitle"),
|
||||
body: map_value(arguments, "body", ""),
|
||||
actions: decode_surface_actions(map_value(arguments, "actions", []))
|
||||
}
|
||||
end
|
||||
|
||||
defp do_build_render_surface("render_table", arguments, surface_id, _assigns) do
|
||||
%{
|
||||
id: surface_id,
|
||||
type: "table",
|
||||
title: map_value(arguments, "title"),
|
||||
columns: stringify_list(map_value(arguments, "columns", [])),
|
||||
rows: Enum.map(List.wrap(map_value(arguments, "rows", [])), &stringify_list/1)
|
||||
}
|
||||
end
|
||||
|
||||
defp do_build_render_surface("render_chart", arguments, surface_id, _assigns) do
|
||||
series =
|
||||
map_value(arguments, "series", [])
|
||||
|> List.wrap()
|
||||
|> Enum.map(fn entry ->
|
||||
%{
|
||||
label: map_value(entry, "label", translated("chat.role.assistant")),
|
||||
value: numeric_value(map_value(entry, "value", 0)),
|
||||
segments: List.wrap(map_value(entry, "segments", []))
|
||||
}
|
||||
end)
|
||||
|
||||
%{
|
||||
id: surface_id,
|
||||
type: "chart",
|
||||
title: map_value(arguments, "title"),
|
||||
chart_type: map_value(arguments, "chart_type", "bar"),
|
||||
series: series,
|
||||
max_value: Enum.max([0 | Enum.map(series, & &1.value)])
|
||||
}
|
||||
end
|
||||
|
||||
defp do_build_render_surface("render_metric", arguments, surface_id, _assigns) do
|
||||
%{
|
||||
id: surface_id,
|
||||
type: "metric",
|
||||
label: map_value(arguments, "label", "Metric"),
|
||||
value: map_value(arguments, "value", "")
|
||||
}
|
||||
end
|
||||
|
||||
defp do_build_render_surface("render_list", arguments, surface_id, _assigns) do
|
||||
%{
|
||||
id: surface_id,
|
||||
type: "list",
|
||||
title: map_value(arguments, "title"),
|
||||
items: stringify_list(map_value(arguments, "items", []))
|
||||
}
|
||||
end
|
||||
|
||||
defp do_build_render_surface("render_mindmap", arguments, surface_id, _assigns) do
|
||||
nodes =
|
||||
arguments
|
||||
|> map_value("nodes", [])
|
||||
|> List.wrap()
|
||||
|> Enum.map(fn node ->
|
||||
%{
|
||||
id: map_value(node, "id"),
|
||||
label: map_value(node, "label", "Node"),
|
||||
children: stringify_list(map_value(node, "children", []))
|
||||
}
|
||||
end)
|
||||
|
||||
%{
|
||||
id: surface_id,
|
||||
type: "mindmap",
|
||||
title: map_value(arguments, "title"),
|
||||
nodes: nodes
|
||||
}
|
||||
end
|
||||
|
||||
defp do_build_render_surface("render_form", arguments, surface_id, assigns) do
|
||||
stored_fields = Map.get(assigns.chat_editor_surface_data, surface_id, %{})
|
||||
|
||||
fields =
|
||||
arguments
|
||||
|> map_value("fields", [])
|
||||
|> List.wrap()
|
||||
|> Enum.map(fn field ->
|
||||
key = map_value(field, "key", "field")
|
||||
|
||||
%{
|
||||
key: key,
|
||||
label: map_value(field, "label", key),
|
||||
input_type: map_value(field, "inputType") || map_value(field, "input_type", "text"),
|
||||
placeholder: map_value(field, "placeholder"),
|
||||
value: Map.get(stored_fields, key, map_value(field, "defaultValue") || map_value(field, "default_value")),
|
||||
options: decode_surface_options(map_value(field, "options", [])),
|
||||
required?: truthy?(map_value(field, "required", false))
|
||||
}
|
||||
end)
|
||||
|
||||
%{
|
||||
id: surface_id,
|
||||
type: "form",
|
||||
title: map_value(arguments, "title"),
|
||||
fields: fields,
|
||||
submit_label: map_value(arguments, "submitLabel") || map_value(arguments, "submit_label", translated("chat.stop")),
|
||||
submit_action: map_value(arguments, "submitAction") || map_value(arguments, "submit_action", "submitForm")
|
||||
}
|
||||
end
|
||||
|
||||
defp do_build_render_surface("render_tabs", arguments, surface_id, assigns) do
|
||||
tabs =
|
||||
arguments
|
||||
|> map_value("tabs", [])
|
||||
|> List.wrap()
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {tab, tab_index} ->
|
||||
%{
|
||||
label: map_value(tab, "label", "Tab #{tab_index + 1}"),
|
||||
content:
|
||||
tab
|
||||
|> map_value("content", [])
|
||||
|> List.wrap()
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {content, content_index} ->
|
||||
build_tab_surface(content, "#{surface_id}-tab-#{tab_index}-#{content_index}", assigns)
|
||||
end)
|
||||
}
|
||||
end)
|
||||
|
||||
%{
|
||||
id: surface_id,
|
||||
type: "tabs",
|
||||
title: map_value(arguments, "title"),
|
||||
tabs: tabs,
|
||||
selected_index: Map.get(assigns.chat_editor_surface_tabs, surface_id, 0)
|
||||
}
|
||||
end
|
||||
|
||||
defp do_build_render_surface(_name, arguments, surface_id, _assigns) do
|
||||
%{id: surface_id, type: "json", raw: arguments}
|
||||
end
|
||||
|
||||
defp build_tab_surface(%{} = content, surface_id, assigns) do
|
||||
type = map_value(content, "type", "text")
|
||||
|
||||
case type do
|
||||
render_type when render_type in ["card", "chart", "form", "list", "metric", "mindmap", "table", "tabs"] ->
|
||||
do_build_render_surface("render_#{render_type}", Map.delete(content, "type"), surface_id, assigns)
|
||||
|
||||
"text" ->
|
||||
%{id: surface_id, type: "text", body: map_value(content, "body") || map_value(content, "text", "")}
|
||||
|
||||
_other ->
|
||||
%{id: surface_id, type: "json", raw: content}
|
||||
end
|
||||
end
|
||||
|
||||
defp build_tab_surface(content, surface_id, _assigns) do
|
||||
%{id: surface_id, type: "text", body: to_string(content || "")}
|
||||
end
|
||||
|
||||
defp mark_tool_call_completed(entry, tool_call_id) when is_binary(tool_call_id) do
|
||||
update_in(entry.tool_markers, fn markers ->
|
||||
Enum.map(markers, fn marker ->
|
||||
if marker.id == tool_call_id do
|
||||
%{marker | complete?: true}
|
||||
else
|
||||
marker
|
||||
end
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
defp mark_tool_call_completed(entry, _tool_call_id), do: entry
|
||||
|
||||
defp decode_surface_actions(actions) when is_list(actions) do
|
||||
Enum.map(actions, fn action ->
|
||||
%{
|
||||
label: map_value(action, "label", translated("chat.openSettings")),
|
||||
action: map_value(action, "action", "openSettings"),
|
||||
payload: map_value(action, "payload", %{})
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp decode_surface_actions(_actions), do: []
|
||||
|
||||
defp decode_surface_options(options) when is_list(options) do
|
||||
Enum.map(options, fn option ->
|
||||
%{
|
||||
label: map_value(option, "label", ""),
|
||||
value: map_value(option, "value", "")
|
||||
}
|
||||
end)
|
||||
end
|
||||
|
||||
defp decode_surface_options(_options), do: []
|
||||
|
||||
defp tool_arguments_preview(arguments) when is_map(arguments) do
|
||||
arguments
|
||||
|> Enum.map(fn {key, value} -> "#{key}: #{preview_value(value)}" end)
|
||||
|> Enum.join(", ")
|
||||
end
|
||||
|
||||
defp tool_arguments_preview(_arguments), do: ""
|
||||
|
||||
defp preview_value(value) when is_binary(value) do
|
||||
quoted = if String.length(value) > @tool_args_max_length, do: String.slice(value, 0, @tool_args_max_length) <> "...", else: value
|
||||
inspect(quoted)
|
||||
end
|
||||
|
||||
defp preview_value(value), do: inspect(value)
|
||||
|
||||
defp normalize_tool_surface(content) when is_binary(content) do
|
||||
case Jason.decode(content) do
|
||||
{:ok, %{"type" => type} = decoded} ->
|
||||
@@ -175,5 +816,138 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|
||||
defp normalize_tool_surface(_content), do: nil
|
||||
|
||||
defp pending_user_message(_messages, nil), do: nil
|
||||
|
||||
defp pending_user_message(messages, %{message: message}) when is_binary(message) do
|
||||
case messages |> Enum.reverse() |> Enum.find(&(&1.role not in [:system, :tool])) do
|
||||
%{role: :user, content: ^message} -> nil
|
||||
_other -> message
|
||||
end
|
||||
end
|
||||
|
||||
defp pending_user_message(_messages, _request), do: nil
|
||||
|
||||
defp streaming_content(nil), do: ""
|
||||
defp streaming_content(%{content: content}) when is_binary(content), do: content
|
||||
defp streaming_content(_request), do: ""
|
||||
|
||||
defp tool_markers_from_events(nil), do: []
|
||||
|
||||
defp tool_markers_from_events(%{tool_events: tool_events}) do
|
||||
Enum.reduce(tool_events || [], [], fn event, markers ->
|
||||
case event.type do
|
||||
:call -> markers ++ [%{id: nil, name: event.name, arguments: event.arguments, args_preview: tool_arguments_preview(event.arguments || %{}), complete?: false}]
|
||||
|
||||
:result ->
|
||||
Enum.reverse(markers)
|
||||
|> mark_last_matching_complete(event.name)
|
||||
|> Enum.reverse()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp mark_last_matching_complete(markers, name) do
|
||||
{updated, found?} =
|
||||
Enum.map_reduce(markers, false, fn marker, found? ->
|
||||
cond do
|
||||
found? -> {marker, true}
|
||||
marker.name == name and not marker.complete? -> {%{marker | complete?: true}, true}
|
||||
true -> {marker, false}
|
||||
end
|
||||
end)
|
||||
|
||||
if found?, do: updated, else: updated
|
||||
end
|
||||
|
||||
defp update_request(socket, conversation_id, updater, reload) do
|
||||
case Map.get(socket.assigns.chat_editor_requests, conversation_id) do
|
||||
nil -> socket
|
||||
|
||||
request ->
|
||||
socket
|
||||
|> assign(:chat_editor_requests, Map.put(socket.assigns.chat_editor_requests, conversation_id, updater.(request)))
|
||||
|> reload.(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
defp allow_repo_sandbox(pid) when is_pid(pid) do
|
||||
if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do
|
||||
try do
|
||||
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
|
||||
rescue
|
||||
_error -> :ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp needs_api_key?(true), do: false
|
||||
|
||||
defp needs_api_key?(false) do
|
||||
case AI.get_endpoint(:online) do
|
||||
{:ok, %{url: url, model: model, api_key: api_key}} -> blank?(url) or blank?(model) or blank?(api_key)
|
||||
_other -> true
|
||||
end
|
||||
end
|
||||
|
||||
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
|
||||
defp blank?(nil), do: true
|
||||
defp blank?(_value), do: false
|
||||
|
||||
defp rewrite_external_images(html) do
|
||||
html =
|
||||
Regex.replace(~r/<img\b(?=[^>]*\bsrc="(https?:\/\/[^\"]+)")(?=[^>]*\balt="([^\"]*)")[^>]*\/?>/i, html, fn _match, src, alt ->
|
||||
external_image_link(src, alt)
|
||||
end)
|
||||
|
||||
Regex.replace(~r/<img\b(?=[^>]*\bsrc="(https?:\/\/[^\"]+)")[^>]*\/?>/i, html, fn _match, src ->
|
||||
external_image_link(src, src)
|
||||
end)
|
||||
end
|
||||
|
||||
defp external_image_link(src, text) do
|
||||
escaped_src = src |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
|
||||
escaped_text = (text || src) |> Phoenix.HTML.html_escape() |> Phoenix.HTML.safe_to_string()
|
||||
~s(<a href="#{escaped_src}" rel="noopener noreferrer" target="_blank">#{escaped_text}</a>)
|
||||
end
|
||||
|
||||
defp surface_input_type("number"), do: "number"
|
||||
defp surface_input_type("date"), do: "date"
|
||||
defp surface_input_type(_type), do: "text"
|
||||
|
||||
defp present?(value) when is_binary(value), do: String.trim(value) != ""
|
||||
defp present?(value), do: not is_nil(value)
|
||||
|
||||
defp stringify_list(values) when is_list(values), do: Enum.map(values, &to_string/1)
|
||||
defp stringify_list(value), do: List.wrap(value) |> Enum.map(&to_string/1)
|
||||
|
||||
defp numeric_value(value) when is_integer(value), do: value
|
||||
defp numeric_value(value) when is_float(value), do: value
|
||||
|
||||
defp numeric_value(value) when is_binary(value) do
|
||||
case Float.parse(value) do
|
||||
{parsed, ""} -> parsed
|
||||
_other -> 0
|
||||
end
|
||||
end
|
||||
|
||||
defp numeric_value(_value), do: 0
|
||||
|
||||
defp map_value(map, key, default \\ nil)
|
||||
|
||||
defp map_value(map, key, default) when is_map(map) and is_binary(key) do
|
||||
Map.get(map, key, Map.get(map, String.to_atom(key), default))
|
||||
rescue
|
||||
ArgumentError -> Map.get(map, key, default)
|
||||
end
|
||||
|
||||
defp map_value(_map, _key, default), do: default
|
||||
|
||||
defp format_error(%{kind: :endpoint_not_configured}), do: translated("chat.apiKeyRequiredDescription")
|
||||
defp format_error(reason), do: inspect(reason)
|
||||
|
||||
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
||||
end
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
<div class="chat-panel" data-testid="chat-editor">
|
||||
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel" data-testid="chat-editor" phx-hook="ChatSurface">
|
||||
<div class="chat-panel-header">
|
||||
<div class="chat-panel-title"><%= @chat_editor.title %></div>
|
||||
<div class="chat-panel-title">
|
||||
<%= if @chat_editor.needs_api_key? do %>
|
||||
<%= translated("chat.setupTitle") %>
|
||||
<% else %>
|
||||
<%= @chat_editor.title %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= unless @chat_editor.needs_api_key? do %>
|
||||
<div class="chat-panel-header-actions">
|
||||
<button
|
||||
class="chat-model-selector-button"
|
||||
@@ -31,10 +38,21 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="chat-messages">
|
||||
<%= if Enum.empty?(@chat_editor.messages) do %>
|
||||
<div class="chat-messages chat-surface-scroll">
|
||||
<%= if @chat_editor.needs_api_key? do %>
|
||||
<div class="chat-welcome chat-api-key-state" data-testid="chat-api-key-required">
|
||||
<div class="chat-welcome-icon">🔑</div>
|
||||
<h2><%= translated("chat.apiKeyRequiredTitle") %></h2>
|
||||
<p><%= translated("chat.apiKeyRequiredDescription") %></p>
|
||||
<div class="api-key-form">
|
||||
<button class="api-key-submit" type="button" phx-click="open_chat_settings"><%= translated("chat.openSettings") %></button>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= if Enum.empty?(@chat_editor.messages) and not @chat_editor.is_streaming do %>
|
||||
<div class="chat-welcome">
|
||||
<div class="chat-welcome-icon">🤖</div>
|
||||
<h2><%= translated("chat.welcomeTitle") %></h2>
|
||||
@@ -48,24 +66,38 @@
|
||||
</ul>
|
||||
</div>
|
||||
<% else %>
|
||||
<%= if @chat_editor.pending_user_message do %>
|
||||
<div class="chat-message user pending" data-testid="chat-pending-user-message">
|
||||
<div class="chat-message-avatar">👤</div>
|
||||
<div class="chat-message-content">
|
||||
<div class="chat-message-header">
|
||||
<span class="chat-message-role"><%= message_role_label(:user) %></span>
|
||||
</div>
|
||||
<div class="chat-message-text"><%= @chat_editor.pending_user_message %></div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= for message <- @chat_editor.messages do %>
|
||||
<div class={["chat-message", to_string(message.role || "assistant")]}>
|
||||
<div class="chat-message-avatar"><%= if message.role == :user, do: "👤", else: "🤖" %></div>
|
||||
<div class="chat-message-content">
|
||||
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
|
||||
<.chat_tool_markers markers={message.tool_markers} />
|
||||
|
||||
<%= if message.tool_markers != [] do %>
|
||||
<div class="chat-tool-markers">
|
||||
<%= for tool_call <- message.tool_markers do %>
|
||||
<div class="chat-tool-marker" data-testid="chat-tool-marker">
|
||||
<span class="chat-tool-marker-name"><%= tool_call_name(tool_call) %></span>
|
||||
</div>
|
||||
<div class="chat-message-text">
|
||||
<%= if message.role == :assistant do %>
|
||||
<%= markdown_html(message.content || "") %>
|
||||
<% else %>
|
||||
<%= message.content || "" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-message-text"><%= message.content || "" %></div>
|
||||
</div>
|
||||
</div>
|
||||
<%= for surface <- message.inline_surfaces do %>
|
||||
<.chat_surface surface={surface} />
|
||||
<% end %>
|
||||
|
||||
<%= for surface <- message.tool_surfaces do %>
|
||||
<article class="chat-tool-surface" data-testid="chat-tool-surface">
|
||||
@@ -102,13 +134,52 @@
|
||||
</article>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
|
||||
<div class="chat-message assistant streaming" data-testid="chat-streaming-message">
|
||||
<div class="chat-message-avatar">🤖</div>
|
||||
<div class="chat-message-content">
|
||||
<div class="chat-message-header">
|
||||
<span class="chat-message-role"><%= message_role_label(:assistant) %></span>
|
||||
<span class="streaming-indicator">●</span>
|
||||
</div>
|
||||
<.chat_tool_markers markers={@chat_editor.streaming_tool_markers} />
|
||||
|
||||
<%= if @chat_editor.streaming_content != "" do %>
|
||||
<div class="chat-message-text"><%= markdown_html(@chat_editor.streaming_content) %></div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>
|
||||
<div class="chat-message assistant thinking" data-testid="chat-streaming-thinking">
|
||||
<div class="chat-message-avatar">🤖</div>
|
||||
<div class="chat-message-content">
|
||||
<div class="chat-thinking-indicator">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="chat-input-container">
|
||||
<form class="chat-input-wrapper" phx-change="change_chat_editor_input">
|
||||
<%= unless @chat_editor.needs_api_key? do %>
|
||||
<div class="chat-input-container" data-testid="chat-input-container">
|
||||
<%= if @chat_editor.is_streaming do %>
|
||||
<button class="chat-abort-button" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message">◼ <%= translated("chat.stop") %></button>
|
||||
<% end %>
|
||||
|
||||
<form class="chat-input-wrapper" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message">
|
||||
<textarea class="chat-input chat-surface-input" name="message" rows="1" placeholder={translated("chat.inputPlaceholder")}><%= @chat_editor.input %></textarea>
|
||||
<button class="chat-send-button" type="button" phx-click="send_chat_editor_message" disabled={String.trim(@chat_editor.input || "") == "" or @chat_editor.offline?}>↑</button>
|
||||
<button class="chat-send-button" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" disabled={@chat_editor.send_disabled?}>↑</button>
|
||||
</form>
|
||||
|
||||
<%= if @chat_editor.action_error do %>
|
||||
<p class="chat-surface-error"><%= @chat_editor.action_error %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -65,6 +65,10 @@
|
||||
"translationValidation.fix": "Probleme beheben",
|
||||
"translationValidation.toast.fixSuccess": "%{dbRows} DB-Zeilen und %{files} Dateien gelöscht, %{flushed} Übersetzungen auf Datenträger geschrieben",
|
||||
"chat.newChat": "Neuer Chat",
|
||||
"chat.setupTitle": "KI-Chat-Einrichtung",
|
||||
"chat.apiKeyRequiredTitle": "API-Schlüssel erforderlich",
|
||||
"chat.apiKeyRequiredDescription": "Konfiguriere einen API-Schlüssel in den Einstellungen, um den KI-Chat zu aktivieren.",
|
||||
"chat.openSettings": "Einstellungen öffnen",
|
||||
"chat.welcomeTitle": "Willkommen beim KI-Assistenten",
|
||||
"chat.welcomeDescription": "Ich kann dir mit interaktiven Visualisierungen bei deinem Blog helfen. Frag mich zum Beispiel nach:",
|
||||
"chat.welcomeTipSearch": "Beiträgen zu einem bestimmten Thema",
|
||||
@@ -75,6 +79,8 @@
|
||||
"chat.role.you": "Du",
|
||||
"chat.role.assistant": "Assistent",
|
||||
"chat.inputPlaceholder": "Nachricht eingeben...",
|
||||
"chat.stop": "Stopp",
|
||||
"chat.cancelledSuffix": "(abgebrochen)",
|
||||
"gitDiff.changedFiles": "Geänderte Dateien",
|
||||
"sidebar.tags": "Schlagwörter",
|
||||
"sidebar.categories": "Kategorien",
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
"translationValidation.fix": "Fix Issues",
|
||||
"translationValidation.toast.fixSuccess": "Deleted %{dbRows} DB rows and %{files} files, flushed %{flushed} translations to disk",
|
||||
"chat.newChat": "New Chat",
|
||||
"chat.setupTitle": "AI Chat Setup",
|
||||
"chat.apiKeyRequiredTitle": "API Key Required",
|
||||
"chat.apiKeyRequiredDescription": "Configure an API key in Settings to enable AI chat.",
|
||||
"chat.openSettings": "Open Settings",
|
||||
"chat.welcomeTitle": "Welcome to the AI Assistant",
|
||||
"chat.welcomeDescription": "I can help you manage your blog with rich visualizations. Try asking me to:",
|
||||
"chat.welcomeTipSearch": "Search for posts about a specific topic",
|
||||
@@ -75,6 +79,8 @@
|
||||
"chat.role.you": "You",
|
||||
"chat.role.assistant": "Assistant",
|
||||
"chat.inputPlaceholder": "Type a message...",
|
||||
"chat.stop": "Stop",
|
||||
"chat.cancelledSuffix": "(cancelled)",
|
||||
"gitDiff.changedFiles": "Changed files",
|
||||
"sidebar.tags": "Tags",
|
||||
"sidebar.categories": "Categories",
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
"translationValidation.fix": "Corregir problemas",
|
||||
"translationValidation.toast.fixSuccess": "%{dbRows} filas de BD y %{files} archivos eliminados, %{flushed} traducciones escritas a disco",
|
||||
"chat.newChat": "Nuevo chat",
|
||||
"chat.setupTitle": "Configuración de chat IA",
|
||||
"chat.apiKeyRequiredTitle": "Clave API requerida",
|
||||
"chat.apiKeyRequiredDescription": "Configura una clave API en Ajustes para habilitar el chat de IA.",
|
||||
"chat.openSettings": "Abrir Ajustes",
|
||||
"chat.welcomeTitle": "Bienvenido al asistente de IA",
|
||||
"chat.welcomeDescription": "Puedo ayudarte a gestionar tu blog con visualizaciones interactivas. Prueba a pedirme que:",
|
||||
"chat.welcomeTipSearch": "Busque entradas sobre un tema específico",
|
||||
@@ -75,6 +79,8 @@
|
||||
"chat.role.you": "Tú",
|
||||
"chat.role.assistant": "Asistente",
|
||||
"chat.inputPlaceholder": "Escribe un mensaje...",
|
||||
"chat.stop": "Detener",
|
||||
"chat.cancelledSuffix": "(cancelado)",
|
||||
"gitDiff.changedFiles": "Archivos modificados",
|
||||
"sidebar.tags": "Etiquetas",
|
||||
"sidebar.categories": "Categorías",
|
||||
|
||||
@@ -67,6 +67,10 @@
|
||||
"chat.newChat": "Nouveau chat",
|
||||
"chat.welcomeTitle": "Bienvenue dans l’assistant IA",
|
||||
"chat.welcomeDescription": "Je peux vous aider à gérer votre blog avec des visualisations riches. Essayez par exemple :",
|
||||
"chat.setupTitle": "Configuration du chat IA",
|
||||
"chat.apiKeyRequiredTitle": "Clé API requise",
|
||||
"chat.apiKeyRequiredDescription": "Configurez une clé API dans les Réglages pour activer le chat IA.",
|
||||
"chat.openSettings": "Ouvrir les Réglages",
|
||||
"chat.welcomeTipSearch": "Rechercher des articles sur un sujet précis",
|
||||
"chat.welcomeTipChart": "Afficher un graphique des articles publiés par mois",
|
||||
"chat.welcomeTipTable": "Comparer mes derniers articles dans un tableau",
|
||||
@@ -75,6 +79,8 @@
|
||||
"chat.role.you": "Vous",
|
||||
"chat.role.assistant": "Assistant IA",
|
||||
"chat.inputPlaceholder": "Saisissez un message...",
|
||||
"chat.stop": "Arrêter",
|
||||
"chat.cancelledSuffix": "(annulé)",
|
||||
"gitDiff.changedFiles": "Fichiers modifiés",
|
||||
"sidebar.tags": "Étiquettes",
|
||||
"sidebar.categories": "Catégories",
|
||||
|
||||
@@ -65,6 +65,10 @@
|
||||
"translationValidation.fix": "Correggi problemi",
|
||||
"translationValidation.toast.fixSuccess": "%{dbRows} righe DB e %{files} file eliminati, %{flushed} traduzioni scritte su disco",
|
||||
"chat.newChat": "Nuova chat",
|
||||
"chat.setupTitle": "Configurazione chat IA",
|
||||
"chat.apiKeyRequiredTitle": "Chiave API richiesta",
|
||||
"chat.apiKeyRequiredDescription": "Configura una chiave API nelle Impostazioni per abilitare la chat IA.",
|
||||
"chat.openSettings": "Apri Impostazioni",
|
||||
"chat.welcomeTitle": "Benvenuto nell’assistente IA",
|
||||
"chat.welcomeDescription": "Posso aiutarti a gestire il tuo blog con visualizzazioni interattive. Prova a chiedermi di:",
|
||||
"chat.welcomeTipSearch": "Cercare post su un argomento specifico",
|
||||
@@ -75,6 +79,8 @@
|
||||
"chat.role.you": "Tu",
|
||||
"chat.role.assistant": "Assistente",
|
||||
"chat.inputPlaceholder": "Scrivi un messaggio...",
|
||||
"chat.stop": "Ferma",
|
||||
"chat.cancelledSuffix": "(annullato)",
|
||||
"gitDiff.changedFiles": "File modificati",
|
||||
"sidebar.tags": "Tag",
|
||||
"sidebar.categories": "Categorie",
|
||||
|
||||
1556
priv/ui/app.css
1556
priv/ui/app.css
File diff suppressed because it is too large
Load Diff
107
priv/ui/live.js
107
priv/ui/live.js
@@ -683,6 +683,113 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
},
|
||||
|
||||
ChatSurface: {
|
||||
mounted() {
|
||||
this.stickToBottom = true;
|
||||
this.scrollContainer = null;
|
||||
|
||||
this.autoResize = () => {
|
||||
const textarea = this.el.querySelector(".chat-input");
|
||||
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
|
||||
textarea.style.height = "auto";
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
|
||||
};
|
||||
|
||||
this.syncScrollContainer = () => {
|
||||
const nextContainer = this.el.querySelector(".chat-messages");
|
||||
|
||||
if (nextContainer === this.scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.scrollContainer) {
|
||||
this.scrollContainer.removeEventListener("scroll", this.handleScroll);
|
||||
}
|
||||
|
||||
this.scrollContainer = nextContainer;
|
||||
|
||||
if (this.scrollContainer) {
|
||||
this.scrollContainer.addEventListener("scroll", this.handleScroll);
|
||||
}
|
||||
};
|
||||
|
||||
this.scrollToBottom = (force = false) => {
|
||||
if (!this.scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (force || this.stickToBottom) {
|
||||
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
this.handleScroll = () => {
|
||||
if (!this.scrollContainer) {
|
||||
this.stickToBottom = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const distanceFromBottom =
|
||||
this.scrollContainer.scrollHeight -
|
||||
this.scrollContainer.scrollTop -
|
||||
this.scrollContainer.clientHeight;
|
||||
|
||||
this.stickToBottom = distanceFromBottom < 48;
|
||||
};
|
||||
|
||||
this.handleInput = (event) => {
|
||||
if (!event.target.closest(".chat-input")) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.stickToBottom = true;
|
||||
this.autoResize();
|
||||
};
|
||||
|
||||
this.handleKeyDown = (event) => {
|
||||
if (!event.target.closest(".chat-input")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
|
||||
const sendButton = this.el.querySelector("[data-testid='chat-send-button']");
|
||||
|
||||
if (sendButton && !sendButton.disabled) {
|
||||
sendButton.click();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.el.addEventListener("input", this.handleInput);
|
||||
this.el.addEventListener("keydown", this.handleKeyDown);
|
||||
|
||||
this.syncScrollContainer();
|
||||
this.autoResize();
|
||||
window.requestAnimationFrame(() => this.scrollToBottom(true));
|
||||
},
|
||||
|
||||
updated() {
|
||||
this.syncScrollContainer();
|
||||
this.autoResize();
|
||||
window.requestAnimationFrame(() => this.scrollToBottom());
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
this.el.removeEventListener("input", this.handleInput);
|
||||
this.el.removeEventListener("keydown", this.handleKeyDown);
|
||||
|
||||
if (this.scrollContainer) {
|
||||
this.scrollContainer.removeEventListener("scroll", this.handleScroll);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
MonacoEditor: {
|
||||
mounted() {
|
||||
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
||||
|
||||
@@ -32,6 +32,32 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
def get(_url, _headers), do: {:error, :not_found}
|
||||
end
|
||||
|
||||
defmodule DelayedChatServer do
|
||||
use Plug.Router
|
||||
import Phoenix.ConnTest, except: [post: 2]
|
||||
|
||||
plug :match
|
||||
plug :dispatch
|
||||
|
||||
post "/v1/chat/completions" do
|
||||
Process.sleep(300)
|
||||
|
||||
body =
|
||||
Jason.encode!(%{
|
||||
"choices" => [%{"message" => %{"content" => "Delayed **response**"}}],
|
||||
"usage" => %{"prompt_tokens" => 8, "completion_tokens" => 5}
|
||||
})
|
||||
|
||||
conn
|
||||
|> Plug.Conn.put_resp_content_type("application/json")
|
||||
|> send_resp(200, body)
|
||||
end
|
||||
|
||||
match _ do
|
||||
send_resp(conn, 404, "not found")
|
||||
end
|
||||
end
|
||||
|
||||
@endpoint BDS.Desktop.Endpoint
|
||||
|
||||
setup do
|
||||
@@ -1803,6 +1829,155 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
assert html =~ "Posts"
|
||||
end
|
||||
|
||||
test "chat editor renders API-key-required state when online chat is not configured" do
|
||||
assert :ok = AI.set_airplane_mode(false)
|
||||
|
||||
assert {:ok, _endpoint} =
|
||||
AI.put_endpoint(:online, %{
|
||||
url: "https://api.example.test/v1",
|
||||
api_key: nil,
|
||||
model: "gpt-4.1"
|
||||
})
|
||||
|
||||
assert {:ok, conversation} = AI.start_chat(%{title: "Needs Setup", model: "gpt-4.1"})
|
||||
|
||||
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||
|
||||
html =
|
||||
render_click(view, "pin_sidebar_item", %{
|
||||
"route" => "chat",
|
||||
"id" => conversation.id,
|
||||
"title" => conversation.title,
|
||||
"subtitle" => conversation.model || "chat"
|
||||
})
|
||||
|
||||
assert html =~ "AI Chat Setup"
|
||||
assert html =~ "API Key Required"
|
||||
assert html =~ "Open Settings"
|
||||
refute html =~ ~s(data-testid="chat-input-container")
|
||||
end
|
||||
|
||||
test "chat editor renders assistant markdown and dispatches assistant navigation actions", %{project: project} do
|
||||
assert {:ok, post} =
|
||||
Posts.create_post(%{
|
||||
project_id: project.id,
|
||||
title: "Action Post",
|
||||
content: "Body",
|
||||
language: "en"
|
||||
})
|
||||
|
||||
assert {:ok, conversation} = AI.start_chat(%{title: "Action Chat", model: "gpt-4.1"})
|
||||
|
||||
now = Persistence.now_ms()
|
||||
|
||||
Repo.insert!(
|
||||
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
|
||||
conversation_id: conversation.id,
|
||||
role: :user,
|
||||
content: "Open the post",
|
||||
created_at: now
|
||||
})
|
||||
)
|
||||
|
||||
Repo.insert!(
|
||||
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
|
||||
conversation_id: conversation.id,
|
||||
role: :assistant,
|
||||
content: "Use **markdown** to jump to the editor.",
|
||||
tool_calls:
|
||||
Jason.encode!([
|
||||
%{
|
||||
"id" => "call-card",
|
||||
"name" => "render_card",
|
||||
"arguments" => %{
|
||||
"title" => "Quick Action",
|
||||
"body" => "Open the related post editor.",
|
||||
"actions" => [
|
||||
%{
|
||||
"label" => "Open Post",
|
||||
"action" => "openPost",
|
||||
"payload" => %{"postId" => post.id}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]),
|
||||
created_at: now + 1
|
||||
})
|
||||
)
|
||||
|
||||
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||
|
||||
html =
|
||||
render_click(view, "pin_sidebar_item", %{
|
||||
"route" => "chat",
|
||||
"id" => conversation.id,
|
||||
"title" => conversation.title,
|
||||
"subtitle" => conversation.model || "chat"
|
||||
})
|
||||
|
||||
assert html =~ "<strong>markdown</strong>"
|
||||
assert html =~ ~s(data-testid="chat-inline-surface")
|
||||
assert html =~ "Quick Action"
|
||||
assert html =~ "Open Post"
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("[data-testid='chat-surface-action'][data-action='openPost']")
|
||||
|> render_click()
|
||||
|
||||
assert html =~ ~s(data-tab-type="post")
|
||||
assert html =~ ~s(data-tab-id="#{post.id}")
|
||||
end
|
||||
|
||||
test "chat editor shows in-flight stop state and can abort a running turn" do
|
||||
assert :ok = AI.set_airplane_mode(false)
|
||||
|
||||
server =
|
||||
start_supervised!({Bandit, plug: DelayedChatServer, port: 0, startup_log: false})
|
||||
|
||||
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
|
||||
|
||||
assert {:ok, _endpoint} =
|
||||
AI.put_endpoint(:online, %{
|
||||
url: "http://127.0.0.1:#{port}/v1",
|
||||
api_key: "online-secret",
|
||||
model: "gpt-4.1"
|
||||
})
|
||||
|
||||
assert {:ok, conversation} = AI.start_chat(%{title: "Slow Chat", model: "gpt-4.1"})
|
||||
|
||||
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
|
||||
|
||||
_html =
|
||||
render_click(view, "pin_sidebar_item", %{
|
||||
"route" => "chat",
|
||||
"id" => conversation.id,
|
||||
"title" => conversation.title,
|
||||
"subtitle" => conversation.model || "chat"
|
||||
})
|
||||
|
||||
_html = render_change(view, "change_chat_editor_input", %{"message" => "Please wait"})
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("[data-testid='chat-send-button']")
|
||||
|> render_click()
|
||||
|
||||
assert html =~ ~s(data-testid="chat-abort-button")
|
||||
assert html =~ ~s(data-testid="chat-streaming-thinking")
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("[data-testid='chat-abort-button']")
|
||||
|> render_click()
|
||||
|
||||
refute html =~ ~s(data-testid="chat-abort-button")
|
||||
|
||||
Process.sleep(350)
|
||||
refute render(view) =~ "Delayed response"
|
||||
end
|
||||
|
||||
test "translation validation route renders dedicated cards and fix controls", %{project: project, temp_dir: temp_dir} do
|
||||
assert {:ok, _metadata} =
|
||||
BDS.Metadata.update_project_metadata(project.id, %{
|
||||
|
||||
Reference in New Issue
Block a user