fix: more work on chat and chat titles

This commit is contained in:
2026-05-01 23:34:37 +02:00
parent c495a2ed0a
commit 7db8f6d36b
8 changed files with 296 additions and 22 deletions

View File

@@ -111,7 +111,8 @@ defmodule BDS.AI.Catalog do
def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do
capabilities = %{ capabilities = %{
supports_attachment: truthy?(BDS.MapUtils.attr(attrs, :supports_attachment)), supports_attachment: truthy?(BDS.MapUtils.attr(attrs, :supports_attachment)),
supports_tool_calls: truthy?(BDS.MapUtils.attr(attrs, :supports_tool_calls)) supports_tool_calls: truthy?(BDS.MapUtils.attr(attrs, :supports_tool_calls)),
disables_reasoning: truthy?(BDS.MapUtils.attr(attrs, :disables_reasoning))
} }
put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities)) put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities))
@@ -163,7 +164,8 @@ defmodule BDS.AI.Catalog do
@spec model_capabilities(String.t()) :: %{ @spec model_capabilities(String.t()) :: %{
supports_attachment: boolean(), supports_attachment: boolean(),
supports_tool_calls: boolean() supports_tool_calls: boolean(),
disables_reasoning: boolean()
} }
def model_capabilities(model_id) do def model_capabilities(model_id) do
overrides = decode_model_capabilities_override(model_id) overrides = decode_model_capabilities_override(model_id)
@@ -173,7 +175,8 @@ defmodule BDS.AI.Catalog do
{:ok, model} -> {:ok, model} ->
%{ %{
supports_attachment: model.supports_attachment or "image" in model.input_modalities, supports_attachment: model.supports_attachment or "image" in model.input_modalities,
supports_tool_calls: model.supports_tool_calls supports_tool_calls: model.supports_tool_calls,
disables_reasoning: false
} }
_other -> _other ->
@@ -196,7 +199,8 @@ defmodule BDS.AI.Catalog do
String.contains?(normalized, "llava"), String.contains?(normalized, "llava"),
supports_tool_calls: supports_tool_calls:
String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or
String.contains?(normalized, "tool") String.contains?(normalized, "tool"),
disables_reasoning: false
} }
end end

View File

@@ -2,6 +2,7 @@ defmodule BDS.AI.Chat do
@moduledoc false @moduledoc false
import Ecto.Query import Ecto.Query
require Logger
alias BDS.AI alias BDS.AI
alias BDS.AI.Catalog alias BDS.AI.Catalog
@@ -23,7 +24,7 @@ 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 @title_max_output_tokens 256
@chat_title_max_length 30 @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
@@ -383,7 +384,7 @@ defmodule BDS.AI.Chat do
conversation = Repo.get!(ChatConversation, conversation_id) conversation = Repo.get!(ChatConversation, conversation_id)
cond do cond do
chat_user_message_count(conversation_id) != 1 -> chat_user_message_count(conversation_id) < 1 ->
{:ok, reply} {:ok, reply}
not generated_chat_title?(conversation.title, conversation.model) -> not generated_chat_title?(conversation.title, conversation.model) ->
@@ -418,7 +419,25 @@ defmodule BDS.AI.Chat do
:ok <- Runtime.validate_target(:chat_title, model, mode), :ok <- Runtime.validate_target(:chat_title, model, mode),
request <- build_chat_title_request(user_content, model), request <- build_chat_title_request(user_content, model),
{:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do {:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
{:ok, sanitize_chat_title(Map.get(response, :content))} title = sanitize_chat_title(Map.get(response, :content))
if title == "" do
Logger.warning("Chat title generation returned an empty title",
model: model,
content: inspect(Map.get(response, :content)),
usage: inspect(Map.get(response, :usage))
)
end
{:ok, title}
else
{:error, reason} = error ->
Logger.warning("Chat title generation failed", reason: inspect(reason))
error
other ->
Logger.warning("Chat title generation failed", reason: inspect(other))
other
end end
end end
@@ -431,7 +450,7 @@ defmodule BDS.AI.Chat do
%{ %{
"role" => "system", "role" => "system",
"content" => "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." "Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text."
}, },
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"} %{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
] ]

View File

@@ -36,6 +36,7 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
"messages" => request.messages, "messages" => request.messages,
"max_tokens" => request.max_output_tokens "max_tokens" => request.max_output_tokens
} }
|> maybe_disable_thinking(request.model)
|> maybe_put_tools(Map.get(request, :tools, [])) |> maybe_put_tools(Map.get(request, :tools, []))
with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)), with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)),
@@ -136,6 +137,18 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|> Map.put("tool_choice", "auto") |> Map.put("tool_choice", "auto")
end end
defp maybe_disable_thinking(payload, model) when is_binary(model) do
if BDS.AI.Catalog.model_capabilities(model).disables_reasoning do
Map.update(payload, "chat_template_kwargs", %{"enable_thinking" => false}, fn kwargs ->
Map.put(kwargs || %{}, "enable_thinking", false)
end)
else
payload
end
end
defp maybe_disable_thinking(payload, _model), do: payload
defp normalize_tool_calls(tool_calls) do defp normalize_tool_calls(tool_calls) do
Enum.map(tool_calls, fn tool_call -> Enum.map(tool_calls, fn tool_call ->
%{ %{

View File

@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
import Phoenix.HTML, only: [raw: 1] import Phoenix.HTML, only: [raw: 1]
alias BDS.AI alias BDS.AI
alias BDS.MapUtils
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking} alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
@@ -249,8 +250,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
) )
case result do case result do
{:ok, _reply} -> {:ok, reply} ->
reload.(socket, socket.assigns.workbench) socket
|> update_tab_meta_from_reply(conversation_id, reply)
|> reload.(socket.assigns.workbench)
{:error, :cancelled} -> {:error, :cancelled} ->
reload.(socket, socket.assigns.workbench) reload.(socket, socket.assigns.workbench)
@@ -266,6 +269,23 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end end
end end
defp update_tab_meta_from_reply(socket, conversation_id, reply) do
title =
reply
|> MapUtils.attr(:conversation, %{})
|> MapUtils.attr(:title)
if is_binary(title) and String.trim(title) != "" do
key = {:chat, conversation_id}
assign(socket, :tab_meta, Map.update(socket.assigns.tab_meta, key, %{title: title}, fn meta ->
Map.put(meta, :title, title)
end))
else
socket
end
end
# ── HEEx-callable helpers ───────────────────────────────────────────────── # ── HEEx-callable helpers ─────────────────────────────────────────────────
@spec message_role_label(term()) :: term() @spec message_role_label(term()) :: term()

View File

@@ -21,6 +21,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
model_supports_tool_calls?( model_supports_tool_calls?(
get_model_preference(:chat) || Map.get(online_endpoint || %{}, :model, "") get_model_preference(:chat) || Map.get(online_endpoint || %{}, :model, "")
), ),
"online_chat_disable_reasoning" =>
model_disables_reasoning?(
get_model_preference(:chat) || Map.get(online_endpoint || %{}, :model, "")
),
"online_title_model" => get_model_preference(:title), "online_title_model" => get_model_preference(:title),
"online_image_analysis_model" => get_model_preference(:image_analysis), "online_image_analysis_model" => get_model_preference(:image_analysis),
"offline_url" => Map.get(airplane_endpoint || %{}, :url, ""), "offline_url" => Map.get(airplane_endpoint || %{}, :url, ""),
@@ -33,6 +37,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
model_supports_tool_calls?( model_supports_tool_calls?(
get_model_preference(:airplane_chat) || Map.get(airplane_endpoint || %{}, :model, "") get_model_preference(:airplane_chat) || Map.get(airplane_endpoint || %{}, :model, "")
), ),
"offline_chat_disable_reasoning" =>
model_disables_reasoning?(
get_model_preference(:airplane_chat) || Map.get(airplane_endpoint || %{}, :model, "")
),
"offline_title_model" => get_model_preference(:airplane_title), "offline_title_model" => get_model_preference(:airplane_title),
"offline_image_analysis_model" => get_model_preference(:airplane_image_analysis), "offline_image_analysis_model" => get_model_preference(:airplane_image_analysis),
"system_prompt" => EditorSettings.get_global_setting("ai.system_prompt") || "" "system_prompt" => EditorSettings.get_global_setting("ai.system_prompt") || ""
@@ -99,12 +107,20 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
:ok <- AI.set_airplane_mode(attrs.offline_mode), :ok <- AI.set_airplane_mode(attrs.offline_mode),
:ok <- maybe_put_model_preference(:chat, attrs.online_chat_model), :ok <- maybe_put_model_preference(:chat, attrs.online_chat_model),
:ok <- :ok <-
maybe_put_chat_model_capabilities(attrs.online_chat_model, attrs.online_chat_tools), maybe_put_chat_model_capabilities(
attrs.online_chat_model,
attrs.online_chat_tools,
attrs.online_chat_disable_reasoning
),
:ok <- maybe_put_model_preference(:title, attrs.online_title_model), :ok <- maybe_put_model_preference(:title, attrs.online_title_model),
:ok <- maybe_put_model_preference(:image_analysis, attrs.online_image_analysis_model), :ok <- maybe_put_model_preference(:image_analysis, attrs.online_image_analysis_model),
:ok <- maybe_put_model_preference(:airplane_chat, attrs.offline_chat_model), :ok <- maybe_put_model_preference(:airplane_chat, attrs.offline_chat_model),
:ok <- :ok <-
maybe_put_chat_model_capabilities(attrs.offline_chat_model, attrs.offline_chat_tools), maybe_put_chat_model_capabilities(
attrs.offline_chat_model,
attrs.offline_chat_tools,
attrs.offline_chat_disable_reasoning
),
:ok <- maybe_put_model_preference(:airplane_title, attrs.offline_title_model), :ok <- maybe_put_model_preference(:airplane_title, attrs.offline_title_model),
:ok <- :ok <-
maybe_put_model_preference( maybe_put_model_preference(
@@ -147,6 +163,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
online_api_key: blank_to_nil(Map.get(draft, "online_api_key")), online_api_key: blank_to_nil(Map.get(draft, "online_api_key")),
online_chat_model: blank_to_nil(Map.get(draft, "online_chat_model")), online_chat_model: blank_to_nil(Map.get(draft, "online_chat_model")),
online_chat_tools: truthy?(Map.get(draft, "online_chat_tools")), online_chat_tools: truthy?(Map.get(draft, "online_chat_tools")),
online_chat_disable_reasoning: truthy?(Map.get(draft, "online_chat_disable_reasoning")),
online_title_model: blank_to_nil(Map.get(draft, "online_title_model")), online_title_model: blank_to_nil(Map.get(draft, "online_title_model")),
online_image_analysis_model: blank_to_nil(Map.get(draft, "online_image_analysis_model")), online_image_analysis_model: blank_to_nil(Map.get(draft, "online_image_analysis_model")),
offline_url: blank_to_nil(Map.get(draft, "offline_url")), offline_url: blank_to_nil(Map.get(draft, "offline_url")),
@@ -154,6 +171,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
offline_mode: truthy?(Map.get(draft, "offline_mode")), offline_mode: truthy?(Map.get(draft, "offline_mode")),
offline_chat_model: blank_to_nil(Map.get(draft, "offline_chat_model")), offline_chat_model: blank_to_nil(Map.get(draft, "offline_chat_model")),
offline_chat_tools: truthy?(Map.get(draft, "offline_chat_tools")), offline_chat_tools: truthy?(Map.get(draft, "offline_chat_tools")),
offline_chat_disable_reasoning: truthy?(Map.get(draft, "offline_chat_disable_reasoning")),
offline_title_model: blank_to_nil(Map.get(draft, "offline_title_model")), offline_title_model: blank_to_nil(Map.get(draft, "offline_title_model")),
offline_image_analysis_model: blank_to_nil(Map.get(draft, "offline_image_analysis_model")), offline_image_analysis_model: blank_to_nil(Map.get(draft, "offline_image_analysis_model")),
system_prompt: Map.get(draft, "system_prompt", "") system_prompt: Map.get(draft, "system_prompt", "")
@@ -166,6 +184,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
"online_api_key" => Map.get(params, "online_api_key", ""), "online_api_key" => Map.get(params, "online_api_key", ""),
"online_chat_model" => Map.get(params, "online_chat_model", ""), "online_chat_model" => Map.get(params, "online_chat_model", ""),
"online_chat_tools" => truthy?(Map.get(params, "online_chat_tools")), "online_chat_tools" => truthy?(Map.get(params, "online_chat_tools")),
"online_chat_disable_reasoning" =>
truthy?(Map.get(params, "online_chat_disable_reasoning")),
"online_title_model" => Map.get(params, "online_title_model", ""), "online_title_model" => Map.get(params, "online_title_model", ""),
"online_image_analysis_model" => Map.get(params, "online_image_analysis_model", ""), "online_image_analysis_model" => Map.get(params, "online_image_analysis_model", ""),
"offline_url" => Map.get(params, "offline_url", ""), "offline_url" => Map.get(params, "offline_url", ""),
@@ -173,6 +193,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
"offline_mode" => truthy?(Map.get(params, "offline_mode")), "offline_mode" => truthy?(Map.get(params, "offline_mode")),
"offline_chat_model" => Map.get(params, "offline_chat_model", ""), "offline_chat_model" => Map.get(params, "offline_chat_model", ""),
"offline_chat_tools" => truthy?(Map.get(params, "offline_chat_tools")), "offline_chat_tools" => truthy?(Map.get(params, "offline_chat_tools")),
"offline_chat_disable_reasoning" =>
truthy?(Map.get(params, "offline_chat_disable_reasoning")),
"offline_title_model" => Map.get(params, "offline_title_model", ""), "offline_title_model" => Map.get(params, "offline_title_model", ""),
"offline_image_analysis_model" => Map.get(params, "offline_image_analysis_model", ""), "offline_image_analysis_model" => Map.get(params, "offline_image_analysis_model", ""),
"system_prompt" => Map.get(params, "system_prompt", "") "system_prompt" => Map.get(params, "system_prompt", "")
@@ -190,15 +212,16 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
defp maybe_put_model_preference(_key, ""), do: :ok defp maybe_put_model_preference(_key, ""), do: :ok
defp maybe_put_model_preference(key, value), do: AI.put_model_preference(key, value) defp maybe_put_model_preference(key, value), do: AI.put_model_preference(key, value)
defp maybe_put_chat_model_capabilities(nil, _supports_tool_calls), do: :ok defp maybe_put_chat_model_capabilities(nil, _supports_tool_calls, _disables_reasoning), do: :ok
defp maybe_put_chat_model_capabilities("", _supports_tool_calls), do: :ok defp maybe_put_chat_model_capabilities("", _supports_tool_calls, _disables_reasoning), do: :ok
defp maybe_put_chat_model_capabilities(model, supports_tool_calls) do defp maybe_put_chat_model_capabilities(model, supports_tool_calls, disables_reasoning) do
existing = BDS.AI.Catalog.model_capabilities(model) existing = BDS.AI.Catalog.model_capabilities(model)
AI.put_model_capabilities(model, %{ AI.put_model_capabilities(model, %{
supports_attachment: existing.supports_attachment, supports_attachment: existing.supports_attachment,
supports_tool_calls: supports_tool_calls supports_tool_calls: supports_tool_calls,
disables_reasoning: disables_reasoning
}) })
end end
@@ -208,6 +231,12 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
defp model_supports_tool_calls?(model), defp model_supports_tool_calls?(model),
do: BDS.AI.Catalog.model_capabilities(model).supports_tool_calls do: BDS.AI.Catalog.model_capabilities(model).supports_tool_calls
defp model_disables_reasoning?(nil), do: false
defp model_disables_reasoning?(""), do: false
defp model_disables_reasoning?(model),
do: BDS.AI.Catalog.model_capabilities(model).disables_reasoning
defp put_endpoint_preferences(kind, url, api_key, primary_model) do defp put_endpoint_preferences(kind, url, api_key, primary_model) do
if Enum.all?([url, api_key, primary_model], &(blank_to_nil(&1) == nil)) do if Enum.all?([url, api_key, primary_model], &(blank_to_nil(&1) == nil)) do
AI.delete_endpoint(kind) AI.delete_endpoint(kind)

View File

@@ -233,6 +233,10 @@
<div class="setting-info"><label class="setting-label"><%= translated("Online Chat Tools") %></label></div> <div class="setting-info"><label class="setting-label"><%= translated("Online Chat Tools") %></label></div>
<div class="setting-control"><label><input type="checkbox" name="settings_ai[online_chat_tools]" checked={@settings_editor.ai["online_chat_tools"]} /> <%= translated("Enable tool calls for the online chat model") %></label></div> <div class="setting-control"><label><input type="checkbox" name="settings_ai[online_chat_tools]" checked={@settings_editor.ai["online_chat_tools"]} /> <%= translated("Enable tool calls for the online chat model") %></label></div>
</div> </div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Online Chat Reasoning") %></label></div>
<div class="setting-control"><label><input type="checkbox" name="settings_ai[online_chat_disable_reasoning]" checked={@settings_editor.ai["online_chat_disable_reasoning"]} /> <%= translated("Disable reasoning output for the online chat model") %></label></div>
</div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Online Title Model") %></label></div> <div class="setting-info"><label class="setting-label"><%= translated("Online Title Model") %></label></div>
<div class="setting-control"><input type="text" list="settings-ai-online-models" name="settings_ai[online_title_model]" value={@settings_editor.ai["online_title_model"]} /></div> <div class="setting-control"><input type="text" list="settings-ai-online-models" name="settings_ai[online_title_model]" value={@settings_editor.ai["online_title_model"]} /></div>
@@ -266,6 +270,10 @@
<div class="setting-info"><label class="setting-label"><%= translated("Offline Chat Tools") %></label></div> <div class="setting-info"><label class="setting-label"><%= translated("Offline Chat Tools") %></label></div>
<div class="setting-control"><label><input type="checkbox" name="settings_ai[offline_chat_tools]" checked={@settings_editor.ai["offline_chat_tools"]} /> <%= translated("Enable tool calls for the offline chat model") %></label></div> <div class="setting-control"><label><input type="checkbox" name="settings_ai[offline_chat_tools]" checked={@settings_editor.ai["offline_chat_tools"]} /> <%= translated("Enable tool calls for the offline chat model") %></label></div>
</div> </div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Offline Chat Reasoning") %></label></div>
<div class="setting-control"><label><input type="checkbox" name="settings_ai[offline_chat_disable_reasoning]" checked={@settings_editor.ai["offline_chat_disable_reasoning"]} /> <%= translated("Disable reasoning output for the offline chat model") %></label></div>
</div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Offline Title Model") %></label></div> <div class="setting-info"><label class="setting-label"><%= translated("Offline Title Model") %></label></div>
<div class="setting-control"><input type="text" list="settings-ai-offline-models" name="settings_ai[offline_title_model]" value={@settings_editor.ai["offline_title_model"]} /></div> <div class="setting-control"><input type="text" list="settings-ai-offline-models" name="settings_ai[offline_title_model]" value={@settings_editor.ai["offline_title_model"]} /></div>

View File

@@ -361,6 +361,31 @@ defmodule BDS.AITest do
refute Map.has_key?(payload, "tool_choice") refute Map.has_key?(payload, "tool_choice")
end end
test "openai-compatible generation disables thinking for configured models" do
Application.put_env(:bds, :test_pid, self())
server =
start_supervised!({Bandit, plug: RecordingCompletionServer, port: 0, startup_log: false})
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
assert :ok = BDS.AI.put_model_capabilities("qwen3.5-122b", %{disables_reasoning: true})
assert {:ok, %{content: "Short Title"}} =
BDS.AI.OpenAICompatibleRuntime.generate(
%{url: "http://127.0.0.1:#{port}/v1", api_key: nil},
%{
operation: :chat_title,
model: "qwen3.5-122b",
messages: [%{"role" => "user", "content" => "Topic: posts per month"}],
max_output_tokens: 256
},
[]
)
assert_received {:completion_payload, payload}
assert payload["chat_template_kwargs"] == %{"enable_thinking" => false}
end
test "airplane mode routes title tasks to airplane endpoint and offline title model" do test "airplane mode routes title tasks to airplane endpoint and offline title model" do
assert {:ok, _endpoint} = assert {:ok, _endpoint} =
BDS.AI.put_endpoint( BDS.AI.put_endpoint(
@@ -506,7 +531,8 @@ defmodule BDS.AITest do
assert :ok = assert :ok =
BDS.AI.put_model_capabilities("llama3.2", %{ BDS.AI.put_model_capabilities("llama3.2", %{
supports_attachment: true, supports_attachment: true,
supports_tool_calls: false supports_tool_calls: false,
disables_reasoning: true
}) })
assert {:ok, analysis} = assert {:ok, analysis} =
@@ -529,6 +555,7 @@ defmodule BDS.AITest do
assert endpoint.kind == :airplane assert endpoint.kind == :airplane
assert request.operation == :analyze_image assert request.operation == :analyze_image
assert request.model == "llama3.2" assert request.model == "llama3.2"
assert BDS.AI.Catalog.model_capabilities("llama3.2").disables_reasoning
end end
test "chat persists user, tool, and assistant messages with usage and blog stats prompt augmentation" do test "chat persists user, tool, and assistant messages with usage and blog stats prompt augmentation" do
@@ -702,7 +729,52 @@ defmodule BDS.AITest do
assert_received {:runtime_request, _endpoint, title_request} assert_received {:runtime_request, _endpoint, title_request}
assert title_request.operation == :chat_title assert title_request.operation == :chat_title
assert title_request.model == "title-model" assert title_request.model == "title-model"
assert title_request.max_output_tokens == 256
assert Enum.any?(title_request.messages, &(&1["content"] =~ "2-3 words")) assert Enum.any?(title_request.messages, &(&1["content"] =~ "2-3 words"))
assert Enum.any?(title_request.messages, &(&1["content"] =~ "Do not include reasoning"))
assert Enum.any?(title_request.messages, &(&1["content"] =~ "How many items"))
end
test "chat retries title generation on later turns while the title is still generated" do
{:ok, project} = create_project_fixture("Retry 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(%{title: "New Chat", model: "gpt-4o-mini"})
Repo.insert!(%BDS.AI.ChatMessage{
conversation_id: conversation.id,
role: :user,
content: "Earlier turn whose title attempt failed",
created_at: Persistence.now_ms()
})
assert {:ok, reply} =
BDS.AI.send_chat_message(conversation.id, "How many items are in the blog now?",
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, %{operation: :chat_title} = title_request}
assert Enum.any?(title_request.messages, &(&1["content"] =~ "How many items")) assert Enum.any?(title_request.messages, &(&1["content"] =~ "How many items"))
end end

View File

@@ -93,6 +93,39 @@ defmodule BDS.Desktop.ShellLiveTest do
end end
end end
defmodule TitleChatServer do
use Plug.Router
import Phoenix.ConnTest, except: [post: 2]
plug(:match)
plug(:dispatch)
post "/v1/chat/completions" do
{:ok, request_body, conn} = Plug.Conn.read_body(conn)
request = Jason.decode!(request_body)
send(Application.fetch_env!(:bds, :test_pid), {:title_chat_request, request})
content =
if Enum.any?(request["messages"] || [], fn message ->
String.contains?(message["content"] || "", "Generate an ultra-short title")
end) do
"Posts 2026"
else
"Ich habe die Posts pro Monat ermittelt."
end
body =
Jason.encode!(%{
"choices" => [%{"message" => %{"content" => content}}],
"usage" => %{"prompt_tokens" => 8, "completion_tokens" => 5}
})
conn
|> Plug.Conn.put_resp_content_type("application/json")
|> send_resp(200, body)
end
end
@endpoint BDS.Desktop.Endpoint @endpoint BDS.Desktop.Endpoint
setup do setup do
@@ -772,6 +805,8 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "Offline Endpoint URL" assert html =~ "Offline Endpoint URL"
assert html =~ "Online API Key" assert html =~ "Online API Key"
assert html =~ "Offline API Key" assert html =~ "Offline API Key"
assert html =~ "Online Chat Reasoning"
assert html =~ "Offline Chat Reasoning"
refute html =~ "Mistral API Key" refute html =~ "Mistral API Key"
refute html =~ "Anthropic / Online API Key" refute html =~ "Anthropic / Online API Key"
@@ -782,12 +817,14 @@ defmodule BDS.Desktop.ShellLiveTest do
"online_api_key" => "online-secret", "online_api_key" => "online-secret",
"online_chat_model" => "gpt-4.1", "online_chat_model" => "gpt-4.1",
"online_chat_tools" => "true", "online_chat_tools" => "true",
"online_chat_disable_reasoning" => "true",
"online_title_model" => "gpt-4.1-mini", "online_title_model" => "gpt-4.1-mini",
"online_image_analysis_model" => "gpt-4.1-vision", "online_image_analysis_model" => "gpt-4.1-vision",
"offline_url" => "http://localhost:11434/v1", "offline_url" => "http://localhost:11434/v1",
"offline_api_key" => "", "offline_api_key" => "",
"offline_chat_model" => "llama3.3", "offline_chat_model" => "llama3.3",
"offline_chat_tools" => "true", "offline_chat_tools" => "true",
"offline_chat_disable_reasoning" => "true",
"offline_title_model" => "llama3.2", "offline_title_model" => "llama3.2",
"offline_image_analysis_model" => "llava:latest", "offline_image_analysis_model" => "llava:latest",
"offline_mode" => "true", "offline_mode" => "true",
@@ -816,8 +853,11 @@ defmodule BDS.Desktop.ShellLiveTest do
assert {:ok, "llama3.2"} = AI.get_model_preference(:airplane_title) assert {:ok, "llama3.2"} = AI.get_model_preference(:airplane_title)
assert {:ok, "llava:latest"} = AI.get_model_preference(:airplane_image_analysis) assert {:ok, "llava:latest"} = AI.get_model_preference(:airplane_image_analysis)
assert %{supports_tool_calls: true} = BDS.AI.Catalog.model_capabilities("gpt-4.1") assert %{supports_tool_calls: true, disables_reasoning: true} =
assert %{supports_tool_calls: true} = BDS.AI.Catalog.model_capabilities("llama3.3") BDS.AI.Catalog.model_capabilities("gpt-4.1")
assert %{supports_tool_calls: true, disables_reasoning: true} =
BDS.AI.Catalog.model_capabilities("llama3.3")
end end
test "ai settings refresh models from the configured endpoints" do test "ai settings refresh models from the configured endpoints" do
@@ -2195,7 +2235,9 @@ defmodule BDS.Desktop.ShellLiveTest do
css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__)) css = File.read!(Path.expand("../../../priv/ui/app.css", __DIR__))
assert css =~ ".chat-panel-title {" assert css =~ ".chat-panel-title {"
assert css =~ "overflow: visible;" 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;"
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"}) render_click(view, "select_chat_model", %{"model" => "llama-next"})
@@ -2203,6 +2245,70 @@ defmodule BDS.Desktop.ShellLiveTest do
assert render(view) =~ "llama-next" assert render(view) =~ "llama-next"
end end
test "chat editor updates the visible new-chat title after the first turn" do
Application.put_env(:bds, :test_pid, self())
assert :ok = AI.set_airplane_mode(false)
server =
start_supervised!({Bandit, plug: TitleChatServer, port: 0, startup_log: false})
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
assert {:ok, _endpoint} =
AI.put_endpoint(:online, %{
url: "http://127.0.0.1:#{port}/v1",
api_key: "online-secret",
model: "gpt-4.1"
})
assert {:ok, conversation} = AI.start_chat(%{title: "New Chat", model: "gpt-4.1"})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html = render_click(view, "select_view", %{"view" => "chat"})
assert html =~ ~s(<span class="chat-item-title">New Chat</span>)
html =
render_click(view, "pin_sidebar_item", %{
"route" => "chat",
"id" => conversation.id,
"title" => conversation.title,
"subtitle" => conversation.model || "chat"
})
assert html =~ ~s(<span class="tab-title">New Chat</span>)
_html =
render_change(view, "change_chat_editor_input", %{"message" => "Posts pro Monat 2026"})
_html =
view
|> element("[data-testid='chat-send-button']")
|> render_click()
Process.sleep(350)
html = render(view)
assert_received {:title_chat_request, chat_request}
refute Enum.any?(chat_request["messages"] || [], fn message ->
String.contains?(message["content"] || "", "Generate an ultra-short title")
end)
assert_received {:title_chat_request, title_request}
assert Enum.any?(title_request["messages"] || [], fn message ->
String.contains?(message["content"] || "", "Generate an ultra-short title")
end)
assert AI.get_chat_conversation(conversation.id).title == "Posts 2026"
assert html =~ ~s(<span class="tab-title">Posts 2026</span>)
assert html =~ ~r/<span class="chat-panel-title-main">\s*Posts 2026\s*<\/span>/
assert html =~ ~s(<span class="chat-item-title">Posts 2026</span>)
refute html =~ ~s(<span class="tab-title">New Chat</span>)
refute html =~ ~s(<span class="chat-item-title">New Chat</span>)
end
test "chat editor renders legacy model controls, collapsed tool pills, and dismissible A2UI surfaces" do 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"})
@@ -2433,7 +2539,9 @@ defmodule BDS.Desktop.ShellLiveTest do
test "chat editor hook reopens server-expanded A2UI surfaces after patches" do test "chat editor hook reopens server-expanded A2UI surfaces after patches" do
live_js = File.read!(Path.expand("../../../priv/ui/live.js", __DIR__)) 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__))
chat_editor =
File.read!(Path.expand("../../../lib/bds/desktop/shell_live/chat_editor.ex", __DIR__))
assert chat_editor =~ "data-expanded={surface_expanded_attr(@surface)}" assert chat_editor =~ "data-expanded={surface_expanded_attr(@surface)}"
assert live_js =~ "this.syncExpandedSurfaces = () =>" assert live_js =~ "this.syncExpandedSurfaces = () =>"
@@ -2508,7 +2616,8 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "Here is the chart." assert html =~ "Here is the chart."
assert html =~ ~s(<span class="chat-message-role">Assistant</span>) assert html =~ ~s(<span class="chat-message-role">Assistant</span>)
assert length(:binary.matches(html, ~s(<span class="chat-message-role">Assistant</span>))) == 1 assert length(:binary.matches(html, ~s(<span class="chat-message-role">Assistant</span>))) ==
1
end end
test "chat editor marks user message text as compact" do test "chat editor marks user message text as compact" do