Compare commits

..

4 Commits

17 changed files with 599 additions and 70 deletions

View File

@@ -163,9 +163,15 @@ defmodule BDS.AI do
@spec get_chat_conversation(String.t()) :: BDS.AI.ChatConversation.t() | nil
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()]
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()) ::
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
defdelegate set_conversation_model(conversation_id, model_id), to: Chat

View File

@@ -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_max_output_tokens 16_384
@title_max_output_tokens 20
@chat_title_max_length 30
@chat_max_tool_rounds 10
@default_context_window 128_000
@@ -59,6 +61,22 @@ defmodule BDS.AI.Chat do
Repo.get(ChatConversation, conversation_id)
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()]
def available_chat_models(current_model \\ nil) do
endpoint_models = configured_chat_models()
@@ -87,6 +105,15 @@ defmodule BDS.AI.Chat do
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()) ::
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
def set_conversation_model(conversation_id, model_id)
@@ -264,6 +291,25 @@ defmodule BDS.AI.Chat do
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
Repo.all(from provider in CatalogProvider, select: {provider.id, provider.name})
|> Map.new()
@@ -303,7 +349,7 @@ defmodule BDS.AI.Chat do
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)
project_id = Keyword.get(opts, :project_id, active_project_id())
@@ -327,11 +373,106 @@ defmodule BDS.AI.Chat do
runtime,
opts,
@chat_max_tool_rounds
) do
),
{:ok, reply} <- maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do
{:ok, reply}
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(
_conversation,
_messages,

View File

@@ -135,6 +135,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:chat_editor_request_refs, %{})
|> assign(:chat_editor_surface_data, %{})
|> assign(:chat_editor_surface_tabs, %{})
|> assign(:chat_editor_dismissed_surfaces, MapSet.new())
|> assign(:chat_editor_action_errors, %{})
|> assign(:import_editor_analysis_states, %{})
|> assign(:import_editor_analysis_task_refs, %{})
@@ -457,6 +458,30 @@ defmodule BDS.Desktop.ShellLive do
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
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)}
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
{:noreply,
ChatSurface.handle_action(socket, params, %{

View File

@@ -59,6 +59,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> reload.(socket.assigns.workbench)
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()
def current_surface_data(socket, surface_id) when is_binary(surface_id) do
Map.get(socket.assigns.chat_editor_surface_data, surface_id, %{})
@@ -314,13 +324,23 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<%= 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">
<details class={["chat-tool-marker", if(marker.complete?, do: "completed", else: "pending")]} data-testid="chat-tool-marker">
<summary>
<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 %>
</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 %>
</div>
<% end %>
@@ -332,7 +352,13 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
@spec chat_surface(term()) :: term()
def chat_surface(assigns) do
~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 %>
<% "card" -> %>
<div class="chat-surface-card">
@@ -526,10 +552,28 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<% _other -> %>
<pre class="chat-tool-surface-json"><%= Jason.encode!(@surface.raw || %{}, pretty: true) %></pre>
<% end %>
</article>
</div>
</details>
"""
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 ───────────────────────────────────────────────────────
defp update_request(socket, conversation_id, updater, reload) do

View File

@@ -15,12 +15,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
%ChatConversation{} = conversation ->
messages = AI.list_chat_messages(conversation.id)
request = Map.get(assigns.chat_editor_requests, conversation.id)
available_models = AI.available_chat_models(conversation.model)
effective_model = AI.effective_chat_model(conversation)
available_models = AI.available_chat_models(effective_model)
%{
id: conversation.id,
title: conversation.title || translated("chat.newChat"),
model: conversation.model,
effective_model: effective_model,
available_models: available_models,
available_model_groups: ModelSelection.group_available_models(available_models),
model_selector_open?:
@@ -31,6 +33,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
is_streaming: not is_nil(request),
streaming_content: streaming_content(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),
needs_api_key?: ModelSelection.needs_api_key?(Map.get(assigns, :offline_mode, true)),
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
:tool ->
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
{entries, current_entry, turn_index}
end
@@ -95,18 +98,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
content: message.content || "",
turn_index: turn_index,
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: []
}
end
defp append_tool_surface(entry, message) do
entry = ToolTracking.mark_tool_call_completed(entry, message.tool_call_id)
case ToolSurfaces.normalize_tool_surface(message.content) do
nil -> entry
surface -> update_in(entry.tool_surfaces, &(&1 ++ [surface]))
end
defp append_tool_result(entry, message) do
ToolTracking.mark_tool_call_completed(entry, message.tool_call_id, message.content)
end
defp tool_only_assistant_entry?(%{role: :assistant, content: content} = entry) do
@@ -125,6 +125,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
}
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, %{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(_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 \\ %{}),
do: ShellData.translate(text, bindings, BDS.Desktop.UILocale.current())
end

View File

@@ -22,6 +22,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
name: tool_call_name(tool_call),
arguments: arguments,
args_preview: tool_arguments_preview(arguments),
result: nil,
complete?: false
}
end)
@@ -39,11 +40,19 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
@spec tool_arguments_preview(term()) :: term()
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
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 ->
Enum.map(markers, fn marker ->
if marker.id == tool_call_id do
%{marker | complete?: true}
%{marker | complete?: true, result: result}
else
marker
end
@@ -51,8 +60,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
end)
end
@spec mark_tool_call_completed(term(), term()) :: term()
def mark_tool_call_completed(entry, _tool_call_id), do: entry
def mark_tool_call_completed(entry, _tool_call_id, _result), do: entry
@spec tool_markers_from_events(term()) :: term()
def tool_markers_from_events(nil), do: []
@@ -68,6 +76,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
name: event.name,
arguments: event.arguments,
args_preview: tool_arguments_preview(event.arguments || %{}),
result: nil,
complete?: false
}
]

View File

@@ -17,7 +17,7 @@
phx-click="toggle_chat_model_selector"
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>
</button>
@@ -33,7 +33,7 @@
<button
class={[
"chat-model-selector-option",
if(model.id == @chat_editor.model, do: "active")
if(model.id == @chat_editor.effective_model, do: "active")
]}
type="button"
phx-click="select_chat_model"
@@ -109,40 +109,6 @@
<.chat_surface surface={surface} />
<% 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 %>
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
@@ -160,6 +126,10 @@
<% end %>
</div>
</div>
<%= for surface <- @chat_editor.streaming_inline_surfaces do %>
<.chat_surface surface={surface} />
<% end %>
<% end %>
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>

View File

@@ -353,7 +353,7 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents 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")}>
<%= for item <- Map.get(@sidebar_data, :items, []) do %>
<%= if item.route == "templates" do %>
<%= if item.route in ["templates", "chat"] do %>
<div
class={["chat-list-item", if(sidebar_item_selected?(@workbench, item.route, item.id), do: "active")]}
data-item-id={item.id}
@@ -379,11 +379,11 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
</button>
<button
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}
type="button"
title={translated("Delete") <> " " <> translated("Template")}
phx-click="delete_sidebar_template"
title={if(item.route == "chat", do: translated("sidebar.chat.deleteConversation"), else: translated("Delete") <> " " <> translated("Template"))}
phx-click={if(item.route == "chat", do: "delete_sidebar_chat", else: "delete_sidebar_template")}
phx-value-id={item.id}
>
×

View File

@@ -107,8 +107,12 @@
"chat.welcomeTipTabs": "Beitragsstatistiken pro Jahr in Tabs mit Diagrammen",
"chat.role.you": "Du",
"chat.role.assistant": "Assistent",
"chat.modelUnavailable": "Kein Modell",
"chat.inputPlaceholder": "Nachricht eingeben...",
"chat.stop": "Stopp",
"chat.toolArguments": "Argumente",
"chat.toolResult": "Ergebnis",
"chat.dismissSurface": "Ansicht schließen",
"chat.cancelledSuffix": "(abgebrochen)",
"gitDiff.changedFiles": "Geänderte Dateien",
"sidebar.tags": "Schlagwörter",
@@ -118,6 +122,7 @@
"sidebar.noPostsYet": "Noch keine Beiträge",
"sidebar.noPagesYet": "Noch keine Seiten",
"sidebar.noMediaYet": "Noch keine Medien",
"sidebar.chat.deleteConversation": "Unterhaltung löschen",
"sidebar.search": "Suchen",
"sidebar.searchPostsPlaceholder": "Beiträge durchsuchen...",
"sidebar.searchPagesPlaceholder": "Seiten durchsuchen...",

View File

@@ -107,8 +107,12 @@
"chat.welcomeTipTabs": "Show post statistics by year in tabs with charts",
"chat.role.you": "You",
"chat.role.assistant": "Assistant",
"chat.modelUnavailable": "No model",
"chat.inputPlaceholder": "Type a message...",
"chat.stop": "Stop",
"chat.toolArguments": "Arguments",
"chat.toolResult": "Result",
"chat.dismissSurface": "Dismiss surface",
"chat.cancelledSuffix": "(cancelled)",
"gitDiff.changedFiles": "Changed files",
"sidebar.tags": "Tags",
@@ -118,6 +122,7 @@
"sidebar.noPostsYet": "No posts yet",
"sidebar.noPagesYet": "No pages yet",
"sidebar.noMediaYet": "No media yet",
"sidebar.chat.deleteConversation": "Delete conversation",
"sidebar.search": "Search",
"sidebar.searchPostsPlaceholder": "Search posts...",
"sidebar.searchPagesPlaceholder": "Search pages...",

View File

@@ -107,8 +107,12 @@
"chat.welcomeTipTabs": "Muestre estadísticas por año en pestañas con gráficos",
"chat.role.you": "Tú",
"chat.role.assistant": "Asistente",
"chat.modelUnavailable": "Sin modelo",
"chat.inputPlaceholder": "Escribe un mensaje...",
"chat.stop": "Detener",
"chat.toolArguments": "Argumentos",
"chat.toolResult": "Resultado",
"chat.dismissSurface": "Cerrar superficie",
"chat.cancelledSuffix": "(cancelado)",
"gitDiff.changedFiles": "Archivos modificados",
"sidebar.tags": "Etiquetas",
@@ -118,6 +122,7 @@
"sidebar.noPostsYet": "Aún no hay entradas",
"sidebar.noPagesYet": "Aún no hay páginas",
"sidebar.noMediaYet": "Aún no hay medios",
"sidebar.chat.deleteConversation": "Eliminar conversación",
"sidebar.search": "Buscar",
"sidebar.searchPostsPlaceholder": "Buscar entradas...",
"sidebar.searchPagesPlaceholder": "Buscar páginas...",

View File

@@ -107,8 +107,12 @@
"chat.welcomeTipTabs": "Afficher les statistiques par année dans des onglets avec graphiques",
"chat.role.you": "Vous",
"chat.role.assistant": "Assistant IA",
"chat.modelUnavailable": "Aucun modèle",
"chat.inputPlaceholder": "Saisissez un message...",
"chat.stop": "Arrêter",
"chat.toolArguments": "Arguments",
"chat.toolResult": "Résultat",
"chat.dismissSurface": "Fermer la surface",
"chat.cancelledSuffix": "(annulé)",
"gitDiff.changedFiles": "Fichiers modifiés",
"sidebar.tags": "Étiquettes",
@@ -118,6 +122,7 @@
"sidebar.noPostsYet": "Aucun article pour le moment",
"sidebar.noPagesYet": "Aucune page pour le moment",
"sidebar.noMediaYet": "Aucun média pour le moment",
"sidebar.chat.deleteConversation": "Supprimer la conversation",
"sidebar.search": "Rechercher",
"sidebar.searchPostsPlaceholder": "Rechercher des articles...",
"sidebar.searchPagesPlaceholder": "Rechercher des pages...",

View File

@@ -107,8 +107,12 @@
"chat.welcomeTipTabs": "Mostrare statistiche per anno in schede con grafici",
"chat.role.you": "Tu",
"chat.role.assistant": "Assistente",
"chat.modelUnavailable": "Nessun modello",
"chat.inputPlaceholder": "Scrivi un messaggio...",
"chat.stop": "Ferma",
"chat.toolArguments": "Argomenti",
"chat.toolResult": "Risultato",
"chat.dismissSurface": "Chiudi superficie",
"chat.cancelledSuffix": "(annullato)",
"gitDiff.changedFiles": "File modificati",
"sidebar.tags": "Tag",
@@ -118,6 +122,7 @@
"sidebar.noPostsYet": "Nessun post",
"sidebar.noPagesYet": "Nessuna pagina",
"sidebar.noMediaYet": "Nessun media",
"sidebar.chat.deleteConversation": "Elimina conversazione",
"sidebar.search": "Cerca",
"sidebar.searchPostsPlaceholder": "Cerca post...",
"sidebar.searchPagesPlaceholder": "Cerca pagine...",

View File

@@ -3565,7 +3565,7 @@ button svg * {
display: flex;
align-items: center;
gap: 10px;
overflow: hidden;
overflow: visible;
font-size: 14px;
font-weight: 500;
color: var(--vscode-foreground, inherit);
@@ -5145,7 +5145,7 @@ button svg * {
display: flex;
align-items: center;
gap: 10px;
overflow: hidden;
overflow: visible;
font-size: 14px;
font-weight: 500;
color: var(--vscode-foreground, inherit);
@@ -5558,13 +5558,45 @@ button svg * {
}
.chat-tool-marker {
font-size: 12px;
color: var(--vscode-descriptionForeground, inherit);
}
.chat-tool-marker summary {
display: flex;
align-items: center;
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);
}
.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 {
color: var(--vscode-testing-iconPassed, #89d185);
}
@@ -5581,13 +5613,60 @@ button svg * {
.chat-tool-surface {
width: min(720px, calc(100% - 44px));
margin-left: 44px;
padding: 14px;
box-sizing: border-box;
border: 1px solid var(--vscode-editorGroup-border, var(--line, #3c3c3c));
border-radius: 12px;
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-tool-surface h3 {
margin: 0 0 12px;

View File

@@ -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 = () => {
if (!this.scrollContainer) {
this.stickToBottom = true;
@@ -823,12 +831,14 @@ document.addEventListener("DOMContentLoaded", () => {
this.el.addEventListener("keydown", this.handleKeyDown);
this.syncScrollContainer();
this.syncExpandedSurfaces();
this.autoResize();
window.requestAnimationFrame(() => this.scrollToBottom(true));
},
updated() {
this.syncScrollContainer();
this.syncExpandedSurfaces();
this.autoResize();
window.requestAnimationFrame(() => this.scrollToBottom());
},

View File

@@ -168,6 +168,9 @@ defmodule BDS.AITest do
usage: usage(31, 8, 0, 0)
}}
end
:chat_title ->
{:ok, %{content: "Blog Stats", usage: usage(12, 3, 0, 0)}}
end
end
@@ -615,6 +618,46 @@ defmodule BDS.AITest do
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
{:ok, project} = create_project_fixture("No Tool Chat")
_fixtures = seed_project_content(project.id)

View File

@@ -248,6 +248,17 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-tab-type="chat")
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 =
@@ -2149,7 +2160,50 @@ defmodule BDS.Desktop.ShellLiveTest do
assert css =~ "position: static;"
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"})
now = Persistence.now_ms()
@@ -2209,11 +2263,92 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(data-testid="chat-model-selector-button")
assert html =~ "gpt-4.1"
assert html =~ ~s(data-testid="chat-tool-marker")
assert html =~ ~s(data-testid="chat-tool-marker-details")
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 =~ "Metric"
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
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 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 =
view
|> element("[data-testid='chat-abort-button']")