From d3aa7f2438c3422f28dcbe0f501b5f8f1668e9a1 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Fri, 1 May 2026 22:29:06 +0200 Subject: [PATCH] fix: added delete buttons on chats and have chat titeling (maybe) --- lib/bds/ai.ex | 3 + lib/bds/ai/chat.ex | 117 +++++++++++++++++- lib/bds/desktop/shell_live.ex | 24 ++++ .../desktop/shell_live/sidebar_components.ex | 8 +- priv/i18n/locales/de.json | 1 + priv/i18n/locales/en.json | 1 + priv/i18n/locales/es.json | 1 + priv/i18n/locales/fr.json | 1 + priv/i18n/locales/it.json | 1 + test/bds/ai_test.exs | 43 +++++++ test/bds/desktop/shell_live_test.exs | 11 ++ 11 files changed, 205 insertions(+), 6 deletions(-) diff --git a/lib/bds/ai.ex b/lib/bds/ai.ex index 2eb9a3c..98ed299 100644 --- a/lib/bds/ai.ex +++ b/lib/bds/ai.ex @@ -163,6 +163,9 @@ 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 diff --git a/lib/bds/ai/chat.ex b/lib/bds/ai/chat.ex index d7e602a..cb023e6 100644 --- a/lib/bds/ai/chat.ex +++ b/lib/bds/ai/chat.ex @@ -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() @@ -303,7 +321,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 +345,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, diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 43f21f4..582e936 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -458,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 diff --git a/lib/bds/desktop/shell_live/sidebar_components.ex b/lib/bds/desktop/shell_live/sidebar_components.ex index 5ac02f5..7287b28 100644 --- a/lib/bds/desktop/shell_live/sidebar_components.ex +++ b/lib/bds/desktop/shell_live/sidebar_components.ex @@ -353,7 +353,7 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do <%= if Enum.any?(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 %>