fix: added delete buttons on chats and have chat titeling (maybe)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
×
|
||||
|
||||
@@ -121,6 +121,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...",
|
||||
|
||||
@@ -121,6 +121,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...",
|
||||
|
||||
@@ -121,6 +121,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...",
|
||||
|
||||
@@ -121,6 +121,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...",
|
||||
|
||||
@@ -121,6 +121,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...",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user