fix: added delete buttons on chats and have chat titeling (maybe)

This commit is contained in:
2026-05-01 22:29:06 +02:00
parent a17c549817
commit d3aa7f2438
11 changed files with 205 additions and 6 deletions

View File

@@ -163,6 +163,9 @@ 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

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_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()
@@ -303,7 +321,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 +345,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,

View File

@@ -458,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

View File

@@ -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}
> >
× ×

View File

@@ -121,6 +121,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...",

View File

@@ -121,6 +121,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...",

View File

@@ -121,6 +121,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...",

View File

@@ -121,6 +121,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...",

View File

@@ -121,6 +121,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...",

View File

@@ -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)

View File

@@ -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 =