Compare commits
4 Commits
391a7f216f
...
11df11dbdb
| Author | SHA1 | Date | |
|---|---|---|---|
| 11df11dbdb | |||
| a5193240ad | |||
| d3aa7f2438 | |||
| a17c549817 |
@@ -163,9 +163,15 @@ defmodule BDS.AI do
|
|||||||
@spec get_chat_conversation(String.t()) :: BDS.AI.ChatConversation.t() | nil
|
@spec get_chat_conversation(String.t()) :: BDS.AI.ChatConversation.t() | nil
|
||||||
defdelegate get_chat_conversation(conversation_id), to: Chat
|
defdelegate get_chat_conversation(conversation_id), to: Chat
|
||||||
|
|
||||||
|
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
|
||||||
|
defdelegate delete_chat_conversation(conversation_id), to: Chat
|
||||||
|
|
||||||
@spec available_chat_models(String.t() | nil) :: [map()]
|
@spec available_chat_models(String.t() | nil) :: [map()]
|
||||||
defdelegate available_chat_models(current_model \\ nil), to: Chat
|
defdelegate available_chat_models(current_model \\ nil), to: Chat
|
||||||
|
|
||||||
|
@spec effective_chat_model(BDS.AI.ChatConversation.t() | map() | nil) :: String.t() | nil
|
||||||
|
defdelegate effective_chat_model(conversation), to: Chat
|
||||||
|
|
||||||
@spec set_conversation_model(String.t(), String.t()) ::
|
@spec set_conversation_model(String.t(), String.t()) ::
|
||||||
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
|
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
defdelegate set_conversation_model(conversation_id, model_id), to: Chat
|
defdelegate set_conversation_model(conversation_id, model_id), to: Chat
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ defmodule BDS.AI.Chat do
|
|||||||
|
|
||||||
@default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts."
|
@default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts."
|
||||||
@default_max_output_tokens 16_384
|
@default_max_output_tokens 16_384
|
||||||
|
@title_max_output_tokens 20
|
||||||
|
@chat_title_max_length 30
|
||||||
@chat_max_tool_rounds 10
|
@chat_max_tool_rounds 10
|
||||||
@default_context_window 128_000
|
@default_context_window 128_000
|
||||||
|
|
||||||
@@ -59,6 +61,22 @@ defmodule BDS.AI.Chat do
|
|||||||
Repo.get(ChatConversation, conversation_id)
|
Repo.get(ChatConversation, conversation_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
|
||||||
|
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
|
||||||
|
case Repo.get(ChatConversation, conversation_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
%ChatConversation{} = conversation ->
|
||||||
|
Repo.delete_all(from message in ChatMessage, where: message.conversation_id == ^conversation_id)
|
||||||
|
|
||||||
|
case Repo.delete(conversation) do
|
||||||
|
{:ok, _conversation} -> {:ok, :deleted}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@spec available_chat_models(String.t() | nil) :: [map()]
|
@spec available_chat_models(String.t() | nil) :: [map()]
|
||||||
def available_chat_models(current_model \\ nil) do
|
def available_chat_models(current_model \\ nil) do
|
||||||
endpoint_models = configured_chat_models()
|
endpoint_models = configured_chat_models()
|
||||||
@@ -87,6 +105,15 @@ defmodule BDS.AI.Chat do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec effective_chat_model(ChatConversation.t() | map() | nil) :: String.t() | nil
|
||||||
|
def effective_chat_model(%ChatConversation{} = conversation) do
|
||||||
|
resolve_effective_chat_model(conversation.model)
|
||||||
|
end
|
||||||
|
|
||||||
|
def effective_chat_model(%{model: model}), do: resolve_effective_chat_model(model)
|
||||||
|
def effective_chat_model(%{"model" => model}), do: resolve_effective_chat_model(model)
|
||||||
|
def effective_chat_model(_conversation), do: resolve_effective_chat_model(nil)
|
||||||
|
|
||||||
@spec set_conversation_model(String.t(), String.t()) ::
|
@spec set_conversation_model(String.t(), String.t()) ::
|
||||||
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
|
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
def set_conversation_model(conversation_id, model_id)
|
def set_conversation_model(conversation_id, model_id)
|
||||||
@@ -264,6 +291,25 @@ defmodule BDS.AI.Chat do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp resolve_effective_chat_model(model) when is_binary(model) and model != "", do: model
|
||||||
|
|
||||||
|
defp resolve_effective_chat_model(_model) do
|
||||||
|
mode = if AI.airplane_mode?(), do: :airplane, else: :online
|
||||||
|
|
||||||
|
preference_key = if mode == :airplane, do: :airplane_chat, else: :chat
|
||||||
|
|
||||||
|
case Runtime.model_preference_value(preference_key) do
|
||||||
|
model when is_binary(model) and model != "" ->
|
||||||
|
model
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
case AI.get_endpoint(mode) do
|
||||||
|
{:ok, %{model: model}} when is_binary(model) and model != "" -> model
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp catalog_provider_name_map do
|
defp catalog_provider_name_map do
|
||||||
Repo.all(from provider in CatalogProvider, select: {provider.id, provider.name})
|
Repo.all(from provider in CatalogProvider, select: {provider.id, provider.name})
|
||||||
|> Map.new()
|
|> Map.new()
|
||||||
@@ -303,7 +349,7 @@ defmodule BDS.AI.Chat do
|
|||||||
|
|
||||||
defp fallback_provider_name(_provider), do: "Other"
|
defp fallback_provider_name(_provider), do: "Other"
|
||||||
|
|
||||||
defp do_send_chat_message(conversation, _user_message, opts) do
|
defp do_send_chat_message(conversation, user_message, opts) do
|
||||||
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
||||||
project_id = Keyword.get(opts, :project_id, active_project_id())
|
project_id = Keyword.get(opts, :project_id, active_project_id())
|
||||||
|
|
||||||
@@ -327,11 +373,106 @@ defmodule BDS.AI.Chat do
|
|||||||
runtime,
|
runtime,
|
||||||
opts,
|
opts,
|
||||||
@chat_max_tool_rounds
|
@chat_max_tool_rounds
|
||||||
) do
|
),
|
||||||
|
{:ok, reply} <- maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do
|
||||||
{:ok, reply}
|
{:ok, reply}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_generate_chat_title(conversation_id, user_content, reply, opts) do
|
||||||
|
conversation = Repo.get!(ChatConversation, conversation_id)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
chat_user_message_count(conversation_id) != 1 ->
|
||||||
|
{:ok, reply}
|
||||||
|
|
||||||
|
not generated_chat_title?(conversation.title, conversation.model) ->
|
||||||
|
{:ok, reply}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
case generate_chat_title(user_content, opts) do
|
||||||
|
{:ok, title} when is_binary(title) and title != "" ->
|
||||||
|
now = Persistence.now_ms()
|
||||||
|
|
||||||
|
conversation
|
||||||
|
|> ChatConversation.changeset(%{title: title, updated_at: now})
|
||||||
|
|> Repo.update()
|
||||||
|
|> case do
|
||||||
|
{:ok, updated_conversation} ->
|
||||||
|
{:ok, %{reply | conversation: format_conversation(updated_conversation)}}
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
{:ok, reply}
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
{:ok, reply}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp generate_chat_title(user_content, opts) when is_binary(user_content) do
|
||||||
|
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
||||||
|
|
||||||
|
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
|
||||||
|
:ok <- Runtime.validate_target(:chat_title, model, mode),
|
||||||
|
request <- build_chat_title_request(user_content, model),
|
||||||
|
{:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
|
||||||
|
{:ok, sanitize_chat_title(Map.get(response, :content))}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_chat_title_request(user_content, model) do
|
||||||
|
%{
|
||||||
|
operation: :chat_title,
|
||||||
|
model: model,
|
||||||
|
max_output_tokens: @title_max_output_tokens,
|
||||||
|
messages: [
|
||||||
|
%{
|
||||||
|
"role" => "system",
|
||||||
|
"content" =>
|
||||||
|
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Output ONLY the title text."
|
||||||
|
},
|
||||||
|
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_chat_title(title) when is_binary(title) do
|
||||||
|
title =
|
||||||
|
title
|
||||||
|
|> String.trim()
|
||||||
|
|> String.trim_leading("\"")
|
||||||
|
|> String.trim_leading("'")
|
||||||
|
|> String.trim_trailing("\"")
|
||||||
|
|> String.trim_trailing("'")
|
||||||
|
|> String.trim_trailing(".")
|
||||||
|
|> String.trim_trailing("!")
|
||||||
|
|> String.trim_trailing("?")
|
||||||
|
|
||||||
|
if String.length(title) > @chat_title_max_length do
|
||||||
|
String.slice(title, 0, @chat_title_max_length - 3) <> "..."
|
||||||
|
else
|
||||||
|
title
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_chat_title(_title), do: ""
|
||||||
|
|
||||||
|
defp chat_user_message_count(conversation_id) do
|
||||||
|
Repo.aggregate(
|
||||||
|
from(message in ChatMessage,
|
||||||
|
where: message.conversation_id == ^conversation_id and message.role == :user
|
||||||
|
),
|
||||||
|
:count,
|
||||||
|
:id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp generated_chat_title?(title, model) do
|
||||||
|
title in [generated_chat_title(nil), generated_chat_title(model)]
|
||||||
|
end
|
||||||
|
|
||||||
defp chat_round(
|
defp chat_round(
|
||||||
_conversation,
|
_conversation,
|
||||||
_messages,
|
_messages,
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
|> assign(:chat_editor_request_refs, %{})
|
|> assign(:chat_editor_request_refs, %{})
|
||||||
|> assign(:chat_editor_surface_data, %{})
|
|> assign(:chat_editor_surface_data, %{})
|
||||||
|> assign(:chat_editor_surface_tabs, %{})
|
|> assign(:chat_editor_surface_tabs, %{})
|
||||||
|
|> assign(:chat_editor_dismissed_surfaces, MapSet.new())
|
||||||
|> assign(:chat_editor_action_errors, %{})
|
|> assign(:chat_editor_action_errors, %{})
|
||||||
|> assign(:import_editor_analysis_states, %{})
|
|> assign(:import_editor_analysis_states, %{})
|
||||||
|> assign(:import_editor_analysis_task_refs, %{})
|
|> assign(:import_editor_analysis_task_refs, %{})
|
||||||
@@ -457,6 +458,30 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("delete_sidebar_chat", %{"id" => conversation_id}, socket) do
|
||||||
|
case AI.delete_chat_conversation(conversation_id) do
|
||||||
|
{:ok, :deleted} ->
|
||||||
|
workbench = Workbench.close_tab(socket.assigns.workbench, :chat, conversation_id)
|
||||||
|
tab_meta = Map.delete(socket.assigns.tab_meta, {:chat, conversation_id})
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:tab_meta, tab_meta)
|
||||||
|
|> reload_shell(workbench)}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> append_output_entry(
|
||||||
|
translated("sidebar.chat.deleteConversation"),
|
||||||
|
inspect(reason),
|
||||||
|
nil,
|
||||||
|
"error"
|
||||||
|
)
|
||||||
|
|> reload_shell(socket.assigns.workbench)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("toggle_offline_mode", _params, socket) do
|
def handle_event("toggle_offline_mode", _params, socket) do
|
||||||
next_mode = not socket.assigns.offline_mode
|
next_mode = not socket.assigns.offline_mode
|
||||||
|
|
||||||
@@ -948,6 +973,10 @@ defmodule BDS.Desktop.ShellLive do
|
|||||||
ChatEditor.select_surface_tab(socket, surface_id, parse_integer(index), &reload_shell/2)}
|
ChatEditor.select_surface_tab(socket, surface_id, parse_integer(index), &reload_shell/2)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_event("dismiss_chat_surface", %{"surface-id" => surface_id}, socket) do
|
||||||
|
{:noreply, ChatEditor.dismiss_surface(socket, surface_id, &reload_shell/2)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_event("chat_surface_action", params, socket) do
|
def handle_event("chat_surface_action", params, socket) do
|
||||||
{:noreply,
|
{:noreply,
|
||||||
ChatSurface.handle_action(socket, params, %{
|
ChatSurface.handle_action(socket, params, %{
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
|> reload.(socket.assigns.workbench)
|
|> reload.(socket.assigns.workbench)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec dismiss_surface(term(), term(), term()) :: term()
|
||||||
|
def dismiss_surface(socket, surface_id, reload) when is_binary(surface_id) do
|
||||||
|
socket
|
||||||
|
|> assign(
|
||||||
|
:chat_editor_dismissed_surfaces,
|
||||||
|
MapSet.put(socket.assigns.chat_editor_dismissed_surfaces, surface_id)
|
||||||
|
)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
@spec current_surface_data(term(), term()) :: term()
|
@spec current_surface_data(term(), term()) :: term()
|
||||||
def current_surface_data(socket, surface_id) when is_binary(surface_id) do
|
def current_surface_data(socket, surface_id) when is_binary(surface_id) do
|
||||||
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{})
|
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{})
|
||||||
@@ -314,13 +324,23 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
<%= if @markers != [] do %>
|
<%= if @markers != [] do %>
|
||||||
<div class="chat-tool-markers">
|
<div class="chat-tool-markers">
|
||||||
<%= for marker <- @markers do %>
|
<%= for marker <- @markers do %>
|
||||||
<div class={["chat-tool-marker", if(marker.complete?, do: "completed", else: "pending")]} data-testid="chat-tool-marker">
|
<details 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>
|
<summary>
|
||||||
<span class="chat-tool-marker-name"><%= marker.name %></span>
|
<span class="chat-tool-marker-icon"><%= if marker.complete?, do: "✓", else: "●" %></span>
|
||||||
<%= if marker.args_preview not in [nil, ""] do %>
|
<span class="chat-tool-marker-name"><%= marker.name %></span>
|
||||||
<span class="chat-tool-marker-args">(<%= marker.args_preview %>)</span>
|
<%= if marker.args_preview not in [nil, ""] do %>
|
||||||
<% end %>
|
<span class="chat-tool-marker-args">(<%= marker.args_preview %>)</span>
|
||||||
</div>
|
<% end %>
|
||||||
|
</summary>
|
||||||
|
<div class="chat-tool-marker-details" data-testid="chat-tool-marker-details">
|
||||||
|
<div class="chat-tool-marker-detail-label"><%= translated("chat.toolArguments") %></div>
|
||||||
|
<pre><%= Jason.encode!(marker.arguments || %{}, pretty: true) %></pre>
|
||||||
|
<%= if marker.result not in [nil, ""] do %>
|
||||||
|
<div class="chat-tool-marker-detail-label"><%= translated("chat.toolResult") %></div>
|
||||||
|
<pre><%= marker.result %></pre>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -332,7 +352,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
@spec chat_surface(term()) :: term()
|
@spec chat_surface(term()) :: term()
|
||||||
def chat_surface(assigns) do
|
def chat_surface(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<article class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface">
|
<details id={@surface.id} class={["chat-inline-surface", "chat-inline-surface-#{@surface.type}"]} data-testid="chat-inline-surface" data-expanded={Map.get(@surface, :expanded?, false)} open={Map.get(@surface, :expanded?, false)}>
|
||||||
|
<summary class="chat-inline-surface-header">
|
||||||
|
<span class="chat-inline-surface-icon"><%= surface_icon(@surface.type) %></span>
|
||||||
|
<span class="chat-inline-surface-title"><%= surface_title(@surface) %></span>
|
||||||
|
<button class="chat-inline-surface-dismiss" type="button" phx-click="dismiss_chat_surface" phx-value-surface-id={@surface.id} aria-label={translated("chat.dismissSurface")} data-testid="chat-inline-surface-dismiss">×</button>
|
||||||
|
</summary>
|
||||||
|
<div class="chat-inline-surface-body">
|
||||||
<%= case @surface.type do %>
|
<%= case @surface.type do %>
|
||||||
<% "card" -> %>
|
<% "card" -> %>
|
||||||
<div class="chat-surface-card">
|
<div class="chat-surface-card">
|
||||||
@@ -526,10 +552,28 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
|||||||
<% _other -> %>
|
<% _other -> %>
|
||||||
<pre class="chat-tool-surface-json"><%= Jason.encode!(@surface.raw || %{}, pretty: true) %></pre>
|
<pre class="chat-tool-surface-json"><%= Jason.encode!(@surface.raw || %{}, pretty: true) %></pre>
|
||||||
<% end %>
|
<% end %>
|
||||||
</article>
|
</div>
|
||||||
|
</details>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp surface_icon("chart"), do: "▥"
|
||||||
|
defp surface_icon("table"), do: "▦"
|
||||||
|
defp surface_icon("form"), do: "▤"
|
||||||
|
defp surface_icon("card"), do: "▣"
|
||||||
|
defp surface_icon("metric"), do: "#"
|
||||||
|
defp surface_icon("list"), do: "☰"
|
||||||
|
defp surface_icon("tabs"), do: "▧"
|
||||||
|
defp surface_icon(_type), do: "■"
|
||||||
|
|
||||||
|
defp surface_title(surface) do
|
||||||
|
cond do
|
||||||
|
present?(Map.get(surface, :title)) -> Map.get(surface, :title)
|
||||||
|
present?(Map.get(surface, :label)) -> Map.get(surface, :label)
|
||||||
|
true -> surface.type |> to_string() |> String.capitalize()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# ── Private helpers ───────────────────────────────────────────────────────
|
# ── Private helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
defp update_request(socket, conversation_id, updater, reload) do
|
defp update_request(socket, conversation_id, updater, reload) do
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
%ChatConversation{} = conversation ->
|
%ChatConversation{} = conversation ->
|
||||||
messages = AI.list_chat_messages(conversation.id)
|
messages = AI.list_chat_messages(conversation.id)
|
||||||
request = Map.get(assigns.chat_editor_requests, conversation.id)
|
request = Map.get(assigns.chat_editor_requests, conversation.id)
|
||||||
available_models = AI.available_chat_models(conversation.model)
|
effective_model = AI.effective_chat_model(conversation)
|
||||||
|
available_models = AI.available_chat_models(effective_model)
|
||||||
|
|
||||||
%{
|
%{
|
||||||
id: conversation.id,
|
id: conversation.id,
|
||||||
title: conversation.title || translated("chat.newChat"),
|
title: conversation.title || translated("chat.newChat"),
|
||||||
model: conversation.model,
|
model: conversation.model,
|
||||||
|
effective_model: effective_model,
|
||||||
available_models: available_models,
|
available_models: available_models,
|
||||||
available_model_groups: ModelSelection.group_available_models(available_models),
|
available_model_groups: ModelSelection.group_available_models(available_models),
|
||||||
model_selector_open?:
|
model_selector_open?:
|
||||||
@@ -31,6 +33,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
is_streaming: not is_nil(request),
|
is_streaming: not is_nil(request),
|
||||||
streaming_content: streaming_content(request),
|
streaming_content: streaming_content(request),
|
||||||
streaming_tool_markers: ToolTracking.tool_markers_from_events(request),
|
streaming_tool_markers: ToolTracking.tool_markers_from_events(request),
|
||||||
|
streaming_inline_surfaces: streaming_inline_surfaces(conversation.id, request, assigns),
|
||||||
offline?: Map.get(assigns, :offline_mode, true),
|
offline?: Map.get(assigns, :offline_mode, true),
|
||||||
needs_api_key?: ModelSelection.needs_api_key?(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),
|
action_error: Map.get(assigns.chat_editor_action_errors, conversation.id),
|
||||||
@@ -49,7 +52,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
case message.role do
|
case message.role do
|
||||||
:tool ->
|
:tool ->
|
||||||
if current_entry && current_entry.role == :assistant do
|
if current_entry && current_entry.role == :assistant do
|
||||||
{entries, append_tool_surface(current_entry, message), turn_index}
|
{entries, append_tool_result(current_entry, message), turn_index}
|
||||||
else
|
else
|
||||||
{entries, current_entry, turn_index}
|
{entries, current_entry, turn_index}
|
||||||
end
|
end
|
||||||
@@ -95,18 +98,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
content: message.content || "",
|
content: message.content || "",
|
||||||
turn_index: turn_index,
|
turn_index: turn_index,
|
||||||
tool_markers: tool_markers,
|
tool_markers: tool_markers,
|
||||||
inline_surfaces: ToolSurfaces.build_render_surfaces(tool_markers, message.id, assigns),
|
inline_surfaces:
|
||||||
|
ToolSurfaces.build_render_surfaces(tool_markers, message.id, assigns)
|
||||||
|
|> mark_surfaces_expanded(assigns),
|
||||||
tool_surfaces: []
|
tool_surfaces: []
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp append_tool_surface(entry, message) do
|
defp append_tool_result(entry, message) do
|
||||||
entry = ToolTracking.mark_tool_call_completed(entry, message.tool_call_id)
|
ToolTracking.mark_tool_call_completed(entry, message.tool_call_id, message.content)
|
||||||
|
|
||||||
case ToolSurfaces.normalize_tool_surface(message.content) do
|
|
||||||
nil -> entry
|
|
||||||
surface -> update_in(entry.tool_surfaces, &(&1 ++ [surface]))
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp tool_only_assistant_entry?(%{role: :assistant, content: content} = entry) do
|
defp tool_only_assistant_entry?(%{role: :assistant, content: content} = entry) do
|
||||||
@@ -125,6 +125,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp mark_surfaces_expanded([], _assigns), do: []
|
||||||
|
|
||||||
|
defp mark_surfaces_expanded(surfaces, assigns) do
|
||||||
|
dismissed = Map.get(assigns, :chat_editor_dismissed_surfaces, MapSet.new())
|
||||||
|
|
||||||
|
surfaces
|
||||||
|
|> Enum.reject(&MapSet.member?(dismissed, &1.id))
|
||||||
|
|> Enum.map(&Map.put(&1, :expanded?, true))
|
||||||
|
end
|
||||||
|
|
||||||
defp pending_user_message(_messages, nil), do: nil
|
defp pending_user_message(_messages, nil), do: nil
|
||||||
|
|
||||||
defp pending_user_message(messages, %{message: message}) when is_binary(message) do
|
defp pending_user_message(messages, %{message: message}) when is_binary(message) do
|
||||||
@@ -140,6 +150,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
|||||||
defp streaming_content(%{content: content}) when is_binary(content), do: content
|
defp streaming_content(%{content: content}) when is_binary(content), do: content
|
||||||
defp streaming_content(_request), do: ""
|
defp streaming_content(_request), do: ""
|
||||||
|
|
||||||
|
defp streaming_inline_surfaces(_conversation_id, nil, _assigns), do: []
|
||||||
|
|
||||||
|
defp streaming_inline_surfaces(conversation_id, request, assigns) do
|
||||||
|
request
|
||||||
|
|> ToolTracking.tool_markers_from_events()
|
||||||
|
|> ToolSurfaces.build_render_surfaces("streaming-#{conversation_id}", assigns)
|
||||||
|
|> mark_surfaces_expanded(assigns)
|
||||||
|
end
|
||||||
|
|
||||||
defp translated(text, bindings \\ %{}),
|
defp translated(text, bindings \\ %{}),
|
||||||
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
|
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
|
|||||||
name: tool_call_name(tool_call),
|
name: tool_call_name(tool_call),
|
||||||
arguments: arguments,
|
arguments: arguments,
|
||||||
args_preview: tool_arguments_preview(arguments),
|
args_preview: tool_arguments_preview(arguments),
|
||||||
|
result: nil,
|
||||||
complete?: false
|
complete?: false
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
@@ -39,11 +40,19 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
|
|||||||
@spec tool_arguments_preview(term()) :: term()
|
@spec tool_arguments_preview(term()) :: term()
|
||||||
def tool_arguments_preview(_arguments), do: ""
|
def tool_arguments_preview(_arguments), do: ""
|
||||||
|
|
||||||
|
@spec mark_tool_call_completed(term(), term()) :: term()
|
||||||
def mark_tool_call_completed(entry, tool_call_id) when is_binary(tool_call_id) do
|
def mark_tool_call_completed(entry, tool_call_id) when is_binary(tool_call_id) do
|
||||||
|
mark_tool_call_completed(entry, tool_call_id, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_tool_call_completed(entry, _tool_call_id), do: entry
|
||||||
|
|
||||||
|
@spec mark_tool_call_completed(term(), term(), term()) :: term()
|
||||||
|
def mark_tool_call_completed(entry, tool_call_id, result) when is_binary(tool_call_id) do
|
||||||
update_in(entry.tool_markers, fn markers ->
|
update_in(entry.tool_markers, fn markers ->
|
||||||
Enum.map(markers, fn marker ->
|
Enum.map(markers, fn marker ->
|
||||||
if marker.id == tool_call_id do
|
if marker.id == tool_call_id do
|
||||||
%{marker | complete?: true}
|
%{marker | complete?: true, result: result}
|
||||||
else
|
else
|
||||||
marker
|
marker
|
||||||
end
|
end
|
||||||
@@ -51,8 +60,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec mark_tool_call_completed(term(), term()) :: term()
|
def mark_tool_call_completed(entry, _tool_call_id, _result), do: entry
|
||||||
def mark_tool_call_completed(entry, _tool_call_id), do: entry
|
|
||||||
|
|
||||||
@spec tool_markers_from_events(term()) :: term()
|
@spec tool_markers_from_events(term()) :: term()
|
||||||
def tool_markers_from_events(nil), do: []
|
def tool_markers_from_events(nil), do: []
|
||||||
@@ -68,6 +76,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
|
|||||||
name: event.name,
|
name: event.name,
|
||||||
arguments: event.arguments,
|
arguments: event.arguments,
|
||||||
args_preview: tool_arguments_preview(event.arguments || %{}),
|
args_preview: tool_arguments_preview(event.arguments || %{}),
|
||||||
|
result: nil,
|
||||||
complete?: false
|
complete?: false
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
phx-click="toggle_chat_model_selector"
|
phx-click="toggle_chat_model_selector"
|
||||||
data-testid="chat-model-selector-button"
|
data-testid="chat-model-selector-button"
|
||||||
>
|
>
|
||||||
<span><%= @chat_editor.model || translated("chat.newChat") %></span>
|
<span><%= @chat_editor.effective_model || translated("chat.modelUnavailable") %></span>
|
||||||
<span class="chat-model-selector-caret">▾</span>
|
<span class="chat-model-selector-caret">▾</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<button
|
<button
|
||||||
class={[
|
class={[
|
||||||
"chat-model-selector-option",
|
"chat-model-selector-option",
|
||||||
if(model.id == @chat_editor.model, do: "active")
|
if(model.id == @chat_editor.effective_model, do: "active")
|
||||||
]}
|
]}
|
||||||
type="button"
|
type="button"
|
||||||
phx-click="select_chat_model"
|
phx-click="select_chat_model"
|
||||||
@@ -109,40 +109,6 @@
|
|||||||
<.chat_surface surface={surface} />
|
<.chat_surface surface={surface} />
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= for surface <- message.tool_surfaces do %>
|
|
||||||
<article class="chat-tool-surface" data-testid="chat-tool-surface">
|
|
||||||
<%= if surface.title do %>
|
|
||||||
<h3><%= surface.title %></h3>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= case tool_surface_type(surface) do %>
|
|
||||||
<% "table" -> %>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<% _other -> %>
|
|
||||||
<pre class="chat-tool-surface-json"><%= Jason.encode!(surface.data, pretty: true) %></pre>
|
|
||||||
<% end %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
|
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
|
||||||
@@ -160,6 +126,10 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%= for surface <- @chat_editor.streaming_inline_surfaces do %>
|
||||||
|
<.chat_surface surface={surface} />
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>
|
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
|
|||||||
<%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %>
|
<%= if Enum.any?(Map.get(@sidebar_data, :items, [])) do %>
|
||||||
<div class={if(template_sidebar?(@sidebar_data), do: "chat-list-items", else: "settings-nav-list")}>
|
<div class={if(template_sidebar?(@sidebar_data), do: "chat-list-items", else: "settings-nav-list")}>
|
||||||
<%= for item <- Map.get(@sidebar_data, :items, []) do %>
|
<%= for item <- Map.get(@sidebar_data, :items, []) do %>
|
||||||
<%= if item.route == "templates" do %>
|
<%= if item.route in ["templates", "chat"] do %>
|
||||||
<div
|
<div
|
||||||
class={["chat-list-item", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "active")]}
|
class={["chat-list-item", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "active")]}
|
||||||
data-item-id={item.id}
|
data-item-id={item.id}
|
||||||
@@ -379,11 +379,11 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="chat-item-delete"
|
class="chat-item-delete"
|
||||||
data-testid="sidebar-delete-template"
|
data-testid={if(item.route == "chat", do: "sidebar-delete-chat", else: "sidebar-delete-template")}
|
||||||
data-item-id={item.id}
|
data-item-id={item.id}
|
||||||
type="button"
|
type="button"
|
||||||
title={translated("Delete") <> " " <> translated("Template")}
|
title={if(item.route == "chat", do: translated("sidebar.chat.deleteConversation"), else: translated("Delete") <> " " <> translated("Template"))}
|
||||||
phx-click="delete_sidebar_template"
|
phx-click={if(item.route == "chat", do: "delete_sidebar_chat", else: "delete_sidebar_template")}
|
||||||
phx-value-id={item.id}
|
phx-value-id={item.id}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
|
|||||||
@@ -107,8 +107,12 @@
|
|||||||
"chat.welcomeTipTabs": "Beitragsstatistiken pro Jahr in Tabs mit Diagrammen",
|
"chat.welcomeTipTabs": "Beitragsstatistiken pro Jahr in Tabs mit Diagrammen",
|
||||||
"chat.role.you": "Du",
|
"chat.role.you": "Du",
|
||||||
"chat.role.assistant": "Assistent",
|
"chat.role.assistant": "Assistent",
|
||||||
|
"chat.modelUnavailable": "Kein Modell",
|
||||||
"chat.inputPlaceholder": "Nachricht eingeben...",
|
"chat.inputPlaceholder": "Nachricht eingeben...",
|
||||||
"chat.stop": "Stopp",
|
"chat.stop": "Stopp",
|
||||||
|
"chat.toolArguments": "Argumente",
|
||||||
|
"chat.toolResult": "Ergebnis",
|
||||||
|
"chat.dismissSurface": "Ansicht schließen",
|
||||||
"chat.cancelledSuffix": "(abgebrochen)",
|
"chat.cancelledSuffix": "(abgebrochen)",
|
||||||
"gitDiff.changedFiles": "Geänderte Dateien",
|
"gitDiff.changedFiles": "Geänderte Dateien",
|
||||||
"sidebar.tags": "Schlagwörter",
|
"sidebar.tags": "Schlagwörter",
|
||||||
@@ -118,6 +122,7 @@
|
|||||||
"sidebar.noPostsYet": "Noch keine Beiträge",
|
"sidebar.noPostsYet": "Noch keine Beiträge",
|
||||||
"sidebar.noPagesYet": "Noch keine Seiten",
|
"sidebar.noPagesYet": "Noch keine Seiten",
|
||||||
"sidebar.noMediaYet": "Noch keine Medien",
|
"sidebar.noMediaYet": "Noch keine Medien",
|
||||||
|
"sidebar.chat.deleteConversation": "Unterhaltung löschen",
|
||||||
"sidebar.search": "Suchen",
|
"sidebar.search": "Suchen",
|
||||||
"sidebar.searchPostsPlaceholder": "Beiträge durchsuchen...",
|
"sidebar.searchPostsPlaceholder": "Beiträge durchsuchen...",
|
||||||
"sidebar.searchPagesPlaceholder": "Seiten durchsuchen...",
|
"sidebar.searchPagesPlaceholder": "Seiten durchsuchen...",
|
||||||
|
|||||||
@@ -107,8 +107,12 @@
|
|||||||
"chat.welcomeTipTabs": "Show post statistics by year in tabs with charts",
|
"chat.welcomeTipTabs": "Show post statistics by year in tabs with charts",
|
||||||
"chat.role.you": "You",
|
"chat.role.you": "You",
|
||||||
"chat.role.assistant": "Assistant",
|
"chat.role.assistant": "Assistant",
|
||||||
|
"chat.modelUnavailable": "No model",
|
||||||
"chat.inputPlaceholder": "Type a message...",
|
"chat.inputPlaceholder": "Type a message...",
|
||||||
"chat.stop": "Stop",
|
"chat.stop": "Stop",
|
||||||
|
"chat.toolArguments": "Arguments",
|
||||||
|
"chat.toolResult": "Result",
|
||||||
|
"chat.dismissSurface": "Dismiss surface",
|
||||||
"chat.cancelledSuffix": "(cancelled)",
|
"chat.cancelledSuffix": "(cancelled)",
|
||||||
"gitDiff.changedFiles": "Changed files",
|
"gitDiff.changedFiles": "Changed files",
|
||||||
"sidebar.tags": "Tags",
|
"sidebar.tags": "Tags",
|
||||||
@@ -118,6 +122,7 @@
|
|||||||
"sidebar.noPostsYet": "No posts yet",
|
"sidebar.noPostsYet": "No posts yet",
|
||||||
"sidebar.noPagesYet": "No pages yet",
|
"sidebar.noPagesYet": "No pages yet",
|
||||||
"sidebar.noMediaYet": "No media yet",
|
"sidebar.noMediaYet": "No media yet",
|
||||||
|
"sidebar.chat.deleteConversation": "Delete conversation",
|
||||||
"sidebar.search": "Search",
|
"sidebar.search": "Search",
|
||||||
"sidebar.searchPostsPlaceholder": "Search posts...",
|
"sidebar.searchPostsPlaceholder": "Search posts...",
|
||||||
"sidebar.searchPagesPlaceholder": "Search pages...",
|
"sidebar.searchPagesPlaceholder": "Search pages...",
|
||||||
|
|||||||
@@ -107,8 +107,12 @@
|
|||||||
"chat.welcomeTipTabs": "Muestre estadísticas por año en pestañas con gráficos",
|
"chat.welcomeTipTabs": "Muestre estadísticas por año en pestañas con gráficos",
|
||||||
"chat.role.you": "Tú",
|
"chat.role.you": "Tú",
|
||||||
"chat.role.assistant": "Asistente",
|
"chat.role.assistant": "Asistente",
|
||||||
|
"chat.modelUnavailable": "Sin modelo",
|
||||||
"chat.inputPlaceholder": "Escribe un mensaje...",
|
"chat.inputPlaceholder": "Escribe un mensaje...",
|
||||||
"chat.stop": "Detener",
|
"chat.stop": "Detener",
|
||||||
|
"chat.toolArguments": "Argumentos",
|
||||||
|
"chat.toolResult": "Resultado",
|
||||||
|
"chat.dismissSurface": "Cerrar superficie",
|
||||||
"chat.cancelledSuffix": "(cancelado)",
|
"chat.cancelledSuffix": "(cancelado)",
|
||||||
"gitDiff.changedFiles": "Archivos modificados",
|
"gitDiff.changedFiles": "Archivos modificados",
|
||||||
"sidebar.tags": "Etiquetas",
|
"sidebar.tags": "Etiquetas",
|
||||||
@@ -118,6 +122,7 @@
|
|||||||
"sidebar.noPostsYet": "Aún no hay entradas",
|
"sidebar.noPostsYet": "Aún no hay entradas",
|
||||||
"sidebar.noPagesYet": "Aún no hay páginas",
|
"sidebar.noPagesYet": "Aún no hay páginas",
|
||||||
"sidebar.noMediaYet": "Aún no hay medios",
|
"sidebar.noMediaYet": "Aún no hay medios",
|
||||||
|
"sidebar.chat.deleteConversation": "Eliminar conversación",
|
||||||
"sidebar.search": "Buscar",
|
"sidebar.search": "Buscar",
|
||||||
"sidebar.searchPostsPlaceholder": "Buscar entradas...",
|
"sidebar.searchPostsPlaceholder": "Buscar entradas...",
|
||||||
"sidebar.searchPagesPlaceholder": "Buscar páginas...",
|
"sidebar.searchPagesPlaceholder": "Buscar páginas...",
|
||||||
|
|||||||
@@ -107,8 +107,12 @@
|
|||||||
"chat.welcomeTipTabs": "Afficher les statistiques par année dans des onglets avec graphiques",
|
"chat.welcomeTipTabs": "Afficher les statistiques par année dans des onglets avec graphiques",
|
||||||
"chat.role.you": "Vous",
|
"chat.role.you": "Vous",
|
||||||
"chat.role.assistant": "Assistant IA",
|
"chat.role.assistant": "Assistant IA",
|
||||||
|
"chat.modelUnavailable": "Aucun modèle",
|
||||||
"chat.inputPlaceholder": "Saisissez un message...",
|
"chat.inputPlaceholder": "Saisissez un message...",
|
||||||
"chat.stop": "Arrêter",
|
"chat.stop": "Arrêter",
|
||||||
|
"chat.toolArguments": "Arguments",
|
||||||
|
"chat.toolResult": "Résultat",
|
||||||
|
"chat.dismissSurface": "Fermer la surface",
|
||||||
"chat.cancelledSuffix": "(annulé)",
|
"chat.cancelledSuffix": "(annulé)",
|
||||||
"gitDiff.changedFiles": "Fichiers modifiés",
|
"gitDiff.changedFiles": "Fichiers modifiés",
|
||||||
"sidebar.tags": "Étiquettes",
|
"sidebar.tags": "Étiquettes",
|
||||||
@@ -118,6 +122,7 @@
|
|||||||
"sidebar.noPostsYet": "Aucun article pour le moment",
|
"sidebar.noPostsYet": "Aucun article pour le moment",
|
||||||
"sidebar.noPagesYet": "Aucune page pour le moment",
|
"sidebar.noPagesYet": "Aucune page pour le moment",
|
||||||
"sidebar.noMediaYet": "Aucun média pour le moment",
|
"sidebar.noMediaYet": "Aucun média pour le moment",
|
||||||
|
"sidebar.chat.deleteConversation": "Supprimer la conversation",
|
||||||
"sidebar.search": "Rechercher",
|
"sidebar.search": "Rechercher",
|
||||||
"sidebar.searchPostsPlaceholder": "Rechercher des articles...",
|
"sidebar.searchPostsPlaceholder": "Rechercher des articles...",
|
||||||
"sidebar.searchPagesPlaceholder": "Rechercher des pages...",
|
"sidebar.searchPagesPlaceholder": "Rechercher des pages...",
|
||||||
|
|||||||
@@ -107,8 +107,12 @@
|
|||||||
"chat.welcomeTipTabs": "Mostrare statistiche per anno in schede con grafici",
|
"chat.welcomeTipTabs": "Mostrare statistiche per anno in schede con grafici",
|
||||||
"chat.role.you": "Tu",
|
"chat.role.you": "Tu",
|
||||||
"chat.role.assistant": "Assistente",
|
"chat.role.assistant": "Assistente",
|
||||||
|
"chat.modelUnavailable": "Nessun modello",
|
||||||
"chat.inputPlaceholder": "Scrivi un messaggio...",
|
"chat.inputPlaceholder": "Scrivi un messaggio...",
|
||||||
"chat.stop": "Ferma",
|
"chat.stop": "Ferma",
|
||||||
|
"chat.toolArguments": "Argomenti",
|
||||||
|
"chat.toolResult": "Risultato",
|
||||||
|
"chat.dismissSurface": "Chiudi superficie",
|
||||||
"chat.cancelledSuffix": "(annullato)",
|
"chat.cancelledSuffix": "(annullato)",
|
||||||
"gitDiff.changedFiles": "File modificati",
|
"gitDiff.changedFiles": "File modificati",
|
||||||
"sidebar.tags": "Tag",
|
"sidebar.tags": "Tag",
|
||||||
@@ -118,6 +122,7 @@
|
|||||||
"sidebar.noPostsYet": "Nessun post",
|
"sidebar.noPostsYet": "Nessun post",
|
||||||
"sidebar.noPagesYet": "Nessuna pagina",
|
"sidebar.noPagesYet": "Nessuna pagina",
|
||||||
"sidebar.noMediaYet": "Nessun media",
|
"sidebar.noMediaYet": "Nessun media",
|
||||||
|
"sidebar.chat.deleteConversation": "Elimina conversazione",
|
||||||
"sidebar.search": "Cerca",
|
"sidebar.search": "Cerca",
|
||||||
"sidebar.searchPostsPlaceholder": "Cerca post...",
|
"sidebar.searchPostsPlaceholder": "Cerca post...",
|
||||||
"sidebar.searchPagesPlaceholder": "Cerca pagine...",
|
"sidebar.searchPagesPlaceholder": "Cerca pagine...",
|
||||||
|
|||||||
@@ -3565,7 +3565,7 @@ button svg * {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--vscode-foreground, inherit);
|
color: var(--vscode-foreground, inherit);
|
||||||
@@ -5145,7 +5145,7 @@ button svg * {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--vscode-foreground, inherit);
|
color: var(--vscode-foreground, inherit);
|
||||||
@@ -5558,13 +5558,45 @@ button svg * {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chat-tool-marker {
|
.chat-tool-marker {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-marker summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 12px;
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-marker summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-marker-details {
|
||||||
|
margin: 6px 0 2px 20px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c));
|
||||||
|
border-radius: 6px;
|
||||||
|
background-color: var(--vscode-editor-background, rgba(0, 0, 0, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-marker-detail-label {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
color: var(--vscode-descriptionForeground, inherit);
|
color: var(--vscode-descriptionForeground, inherit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-tool-marker-details pre {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
font: 11px/1.45 "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-tool-marker.completed .chat-tool-marker-icon {
|
.chat-tool-marker.completed .chat-tool-marker-icon {
|
||||||
color: var(--vscode-testing-iconPassed, #89d185);
|
color: var(--vscode-testing-iconPassed, #89d185);
|
||||||
}
|
}
|
||||||
@@ -5581,13 +5613,60 @@ button svg * {
|
|||||||
.chat-tool-surface {
|
.chat-tool-surface {
|
||||||
width: min(720px, calc(100% - 44px));
|
width: min(720px, calc(100% - 44px));
|
||||||
margin-left: 44px;
|
margin-left: 44px;
|
||||||
padding: 14px;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c));
|
border: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c));
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background-color: var(--vscode-sideBar-background, var(--panel-2, #252526));
|
background-color: var(--vscode-sideBar-background, var(--panel-2, #252526));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
list-style: none;
|
||||||
|
background-color: var(--vscode-textBlockQuote-background, rgba(127, 127, 127, 0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-header::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-title {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-dismiss {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-descriptionForeground, inherit);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-dismiss:hover {
|
||||||
|
background-color: var(--vscode-toolbar-hoverBackground, rgba(255, 255, 255, 0.08));
|
||||||
|
color: var(--vscode-foreground, inherit);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-body {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.chat-inline-surface h3,
|
.chat-inline-surface h3,
|
||||||
.chat-tool-surface h3 {
|
.chat-tool-surface h3 {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
|
|||||||
@@ -780,6 +780,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.syncExpandedSurfaces = () => {
|
||||||
|
this.el
|
||||||
|
.querySelectorAll(".chat-inline-surface[data-expanded='true']")
|
||||||
|
.forEach((surface) => {
|
||||||
|
surface.open = true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
this.handleScroll = () => {
|
this.handleScroll = () => {
|
||||||
if (!this.scrollContainer) {
|
if (!this.scrollContainer) {
|
||||||
this.stickToBottom = true;
|
this.stickToBottom = true;
|
||||||
@@ -823,12 +831,14 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
this.el.addEventListener("keydown", this.handleKeyDown);
|
this.el.addEventListener("keydown", this.handleKeyDown);
|
||||||
|
|
||||||
this.syncScrollContainer();
|
this.syncScrollContainer();
|
||||||
|
this.syncExpandedSurfaces();
|
||||||
this.autoResize();
|
this.autoResize();
|
||||||
window.requestAnimationFrame(() => this.scrollToBottom(true));
|
window.requestAnimationFrame(() => this.scrollToBottom(true));
|
||||||
},
|
},
|
||||||
|
|
||||||
updated() {
|
updated() {
|
||||||
this.syncScrollContainer();
|
this.syncScrollContainer();
|
||||||
|
this.syncExpandedSurfaces();
|
||||||
this.autoResize();
|
this.autoResize();
|
||||||
window.requestAnimationFrame(() => this.scrollToBottom());
|
window.requestAnimationFrame(() => this.scrollToBottom());
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -168,6 +168,9 @@ defmodule BDS.AITest do
|
|||||||
usage: usage(31, 8, 0, 0)
|
usage: usage(31, 8, 0, 0)
|
||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
:chat_title ->
|
||||||
|
{:ok, %{content: "Blog Stats", usage: usage(12, 3, 0, 0)}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -615,6 +618,46 @@ defmodule BDS.AITest do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "chat generates a short title after the first user turn using the title model" do
|
||||||
|
{:ok, project} = create_project_fixture("Title Chat")
|
||||||
|
_fixtures = seed_project_content(project.id)
|
||||||
|
|
||||||
|
assert {:ok, _endpoint} =
|
||||||
|
BDS.AI.put_endpoint(
|
||||||
|
:online,
|
||||||
|
%{
|
||||||
|
url: "https://api.example.test/v1",
|
||||||
|
api_key: "online-secret",
|
||||||
|
model: "gpt-4o-mini"
|
||||||
|
},
|
||||||
|
secret_backend: FakeSecretBackend
|
||||||
|
)
|
||||||
|
|
||||||
|
assert :ok = BDS.AI.set_airplane_mode(false)
|
||||||
|
assert :ok = BDS.AI.put_model_preference(:title, "title-model")
|
||||||
|
assert {:ok, conversation} = BDS.AI.start_chat(%{model: "gpt-4o-mini"})
|
||||||
|
|
||||||
|
assert {:ok, reply} =
|
||||||
|
BDS.AI.send_chat_message(conversation.id, "How many items are in the blog?",
|
||||||
|
runtime: FakeRuntime,
|
||||||
|
test_pid: self(),
|
||||||
|
project_id: project.id,
|
||||||
|
secret_backend: FakeSecretBackend
|
||||||
|
)
|
||||||
|
|
||||||
|
assert reply.conversation.title == "Blog Stats"
|
||||||
|
assert BDS.AI.get_chat_conversation(conversation.id).title == "Blog Stats"
|
||||||
|
|
||||||
|
assert_received {:runtime_request, _endpoint, %{operation: :chat}}
|
||||||
|
assert_received {:runtime_request, _endpoint, %{operation: :chat}}
|
||||||
|
|
||||||
|
assert_received {:runtime_request, _endpoint, title_request}
|
||||||
|
assert title_request.operation == :chat_title
|
||||||
|
assert title_request.model == "title-model"
|
||||||
|
assert Enum.any?(title_request.messages, &(&1["content"] =~ "2-3 words"))
|
||||||
|
assert Enum.any?(title_request.messages, &(&1["content"] =~ "How many items"))
|
||||||
|
end
|
||||||
|
|
||||||
test "chat does not prompt models to emit textual tool calls when tools are unavailable" do
|
test "chat does not prompt models to emit textual tool calls when tools are unavailable" do
|
||||||
{:ok, project} = create_project_fixture("No Tool Chat")
|
{:ok, project} = create_project_fixture("No Tool Chat")
|
||||||
_fixtures = seed_project_content(project.id)
|
_fixtures = seed_project_content(project.id)
|
||||||
|
|||||||
@@ -248,6 +248,17 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert html =~ ~s(data-tab-type="chat")
|
assert html =~ ~s(data-tab-type="chat")
|
||||||
assert html =~ ~s(data-tab-id="#{created_chat.id}")
|
assert html =~ ~s(data-tab-id="#{created_chat.id}")
|
||||||
|
|
||||||
|
html = render_click(view, "select_view", %{"view" => "chat"})
|
||||||
|
assert html =~ ~s(data-testid="sidebar-delete-chat")
|
||||||
|
|
||||||
|
html =
|
||||||
|
view
|
||||||
|
|> element("[data-testid='sidebar-delete-chat'][data-item-id='#{created_chat.id}']")
|
||||||
|
|> render_click()
|
||||||
|
|
||||||
|
refute Repo.get(BDS.AI.ChatConversation, created_chat.id)
|
||||||
|
refute html =~ ~s(data-tab-id="#{created_chat.id}")
|
||||||
|
|
||||||
_html = render_click(view, "select_view", %{"view" => "import"})
|
_html = render_click(view, "select_view", %{"view" => "import"})
|
||||||
|
|
||||||
html =
|
html =
|
||||||
@@ -2149,7 +2160,50 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert css =~ "position: static;"
|
assert css =~ "position: static;"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "chat editor renders legacy model controls, tool markers, and structured tool surfaces" do
|
test "chat editor model selector uses effective model for new chats and persists selection" do
|
||||||
|
assert :ok = AI.set_airplane_mode(true)
|
||||||
|
|
||||||
|
assert {:ok, _endpoint} =
|
||||||
|
AI.put_endpoint(:airplane, %{
|
||||||
|
url: "http://localhost:11434/v1",
|
||||||
|
api_key: nil,
|
||||||
|
model: "llama-default"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert :ok = AI.put_model_preference(:airplane_chat, "llama-current")
|
||||||
|
assert {:ok, conversation} = AI.start_chat(%{title: "New Chat"})
|
||||||
|
|
||||||
|
{: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" => "chat"
|
||||||
|
})
|
||||||
|
|
||||||
|
assert html =~ ~s(data-testid="chat-model-selector-button")
|
||||||
|
assert html =~ "llama-current"
|
||||||
|
refute html =~ ~s(<span>New Chat</span><span class="chat-model-selector-caret">▾</span>)
|
||||||
|
|
||||||
|
selector_html = render_click(view, "toggle_chat_model_selector", %{})
|
||||||
|
assert selector_html =~ ~s(class="chat-model-selector-menu")
|
||||||
|
assert selector_html =~ ~s(data-testid="chat-model-selector-option")
|
||||||
|
assert selector_html =~ "llama-current"
|
||||||
|
|
||||||
|
css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__))
|
||||||
|
assert css =~ ".chat-panel-title {"
|
||||||
|
assert css =~ "overflow: visible;"
|
||||||
|
refute css =~ ".chat-panel-title {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n gap: 10px;\n overflow: hidden;"
|
||||||
|
|
||||||
|
render_click(view, "select_chat_model", %{"model" => "llama-next"})
|
||||||
|
|
||||||
|
assert AI.get_chat_conversation(conversation.id).model == "llama-next"
|
||||||
|
assert render(view) =~ "llama-next"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "chat editor renders legacy model controls, collapsed tool pills, and dismissible A2UI surfaces" do
|
||||||
assert {:ok, conversation} = AI.start_chat(%{title: "Editor Chat", model: "gpt-4.1"})
|
assert {:ok, conversation} = AI.start_chat(%{title: "Editor Chat", model: "gpt-4.1"})
|
||||||
|
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
@@ -2209,11 +2263,92 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert html =~ ~s(data-testid="chat-model-selector-button")
|
assert html =~ ~s(data-testid="chat-model-selector-button")
|
||||||
assert html =~ "gpt-4.1"
|
assert html =~ "gpt-4.1"
|
||||||
assert html =~ ~s(data-testid="chat-tool-marker")
|
assert html =~ ~s(data-testid="chat-tool-marker")
|
||||||
|
assert html =~ ~s(data-testid="chat-tool-marker-details")
|
||||||
assert html =~ "render_table"
|
assert html =~ "render_table"
|
||||||
assert html =~ ~s(data-testid="chat-tool-surface")
|
refute html =~ ~s(data-testid="chat-tool-surface")
|
||||||
|
assert html =~ ~s(data-testid="chat-inline-surface")
|
||||||
|
assert html =~ ~s(data-testid="chat-inline-surface-dismiss")
|
||||||
assert html =~ "Blog Stats"
|
assert html =~ "Blog Stats"
|
||||||
assert html =~ "Metric"
|
assert html =~ "Metric"
|
||||||
assert html =~ "Posts"
|
assert html =~ "Posts"
|
||||||
|
|
||||||
|
dismissed_html =
|
||||||
|
render_click(view, "dismiss_chat_surface", %{
|
||||||
|
"surface-id" => Regex.run(~r/id="([^"]+-surface-0)"/, html) |> Enum.at(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
refute dismissed_html =~ ~s(data-testid="chat-inline-surface")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "chat editor keeps every non-dismissed A2UI surface expanded" do
|
||||||
|
assert {:ok, conversation} = AI.start_chat(%{title: "Surface 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: "Show two updates",
|
||||||
|
created_at: now
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
Repo.insert!(
|
||||||
|
BDS.AI.ChatMessage.changeset(%BDS.AI.ChatMessage{}, %{
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
role: :assistant,
|
||||||
|
content: "Here are the updates.",
|
||||||
|
tool_calls:
|
||||||
|
Jason.encode!([
|
||||||
|
%{
|
||||||
|
"id" => "call-card-old",
|
||||||
|
"name" => "render_card",
|
||||||
|
"arguments" => %{
|
||||||
|
"title" => "Earlier Missing Data",
|
||||||
|
"body" => "The first data request needs review."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"id" => "call-card-new",
|
||||||
|
"name" => "render_card",
|
||||||
|
"arguments" => %{
|
||||||
|
"title" => "Latest Missing Data",
|
||||||
|
"body" => "The second data request needs review."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]),
|
||||||
|
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 length(:binary.matches(html, ~s(data-testid="chat-inline-surface"))) == 2
|
||||||
|
assert length(:binary.matches(html, "data-expanded")) == 2
|
||||||
|
assert html =~ "Earlier Missing Data"
|
||||||
|
assert html =~ "The first data request needs review."
|
||||||
|
assert html =~ "Latest Missing Data"
|
||||||
|
assert html =~ "The second data request needs review."
|
||||||
|
end
|
||||||
|
|
||||||
|
test "chat editor hook reopens server-expanded A2UI surfaces after patches" do
|
||||||
|
live_js = File.read!(Path.expand("../../../priv/ui/live.js", __DIR__))
|
||||||
|
chat_editor = File.read!(Path.expand("../../../lib/bds/desktop/shell_live/chat_editor.ex", __DIR__))
|
||||||
|
|
||||||
|
assert chat_editor =~ "data-expanded={Map.get(@surface, :expanded?, false)}"
|
||||||
|
assert live_js =~ "this.syncExpandedSurfaces = () =>"
|
||||||
|
assert live_js =~ "querySelectorAll(\".chat-inline-surface[data-expanded='true']\")"
|
||||||
|
assert live_js =~ "surface.open = true;"
|
||||||
|
assert live_js =~ "this.syncExpandedSurfaces();"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "chat editor folds tool-only assistant steps into the final assistant answer" do
|
test "chat editor folds tool-only assistant steps into the final assistant answer" do
|
||||||
@@ -2647,6 +2782,25 @@ defmodule BDS.Desktop.ShellLiveTest do
|
|||||||
assert assistant_index < user_index
|
assert assistant_index < user_index
|
||||||
assert html =~ ~r/<textarea[^>]*class="chat-input chat-surface-input"[^>]*disabled/
|
assert html =~ ~r/<textarea[^>]*class="chat-input chat-surface-input"[^>]*disabled/
|
||||||
|
|
||||||
|
send(view.pid, {
|
||||||
|
:chat_tool_call,
|
||||||
|
conversation.id,
|
||||||
|
%{
|
||||||
|
id: "call-streaming-chart",
|
||||||
|
name: "render_chart",
|
||||||
|
arguments: %{
|
||||||
|
"title" => "Streaming Chart",
|
||||||
|
"chartType" => "bar",
|
||||||
|
"series" => [%{"label" => "Posts", "value" => 3}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
html = render(view)
|
||||||
|
assert html =~ ~s(data-testid="chat-streaming-message")
|
||||||
|
assert html =~ ~s(data-testid="chat-inline-surface")
|
||||||
|
assert html =~ "Streaming Chart"
|
||||||
|
|
||||||
html =
|
html =
|
||||||
view
|
view
|
||||||
|> element("[data-testid='chat-abort-button']")
|
|> element("[data-testid='chat-abort-button']")
|
||||||
|
|||||||
Reference in New Issue
Block a user