Compare commits

...

2 Commits

10 changed files with 383 additions and 27 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,13 +384,20 @@ 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 ->
Logger.debug("Chat title generation skipped reason=:no_user_messages")
{:ok, reply} {:ok, reply}
not generated_chat_title?(conversation.title, conversation.model) -> not generated_chat_title?(conversation.title, conversation.model) ->
Logger.debug(
"Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}"
)
{:ok, reply} {:ok, reply}
true -> true ->
Logger.debug("Chat title generation requested conversation_id=#{conversation_id}")
case generate_chat_title(user_content, opts) do case generate_chat_title(user_content, opts) do
{:ok, title} when is_binary(title) and title != "" -> {:ok, title} when is_binary(title) and title != "" ->
now = Persistence.now_ms() now = Persistence.now_ms()
@@ -418,7 +426,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 +457,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

@@ -1,6 +1,8 @@
defmodule BDS.AI.OpenAICompatibleRuntime do defmodule BDS.AI.OpenAICompatibleRuntime do
@moduledoc false @moduledoc false
require Logger
alias BDS.AI.HttpClient alias BDS.AI.HttpClient
def list_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do def list_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
@@ -36,8 +38,13 @@ 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, []))
Logger.debug(
"AI OpenAI-compatible request operation=#{inspect(Map.get(request, :operation))} model=#{inspect(request.model)} url=#{url} tools=#{payload |> Map.get("tools", []) |> length()}"
)
with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)), with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)),
200 <- response.status do 200 <- response.status do
normalize_response(response.body) normalize_response(response.body)
@@ -136,6 +143,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

@@ -66,13 +66,33 @@ defmodule BDS.Desktop.Shutdown do
defp start_shutdown_task do defp start_shutdown_task do
Task.start(fn -> Task.start(fn ->
MainWindow.persist_now() MainWindow.persist_now()
maybe_hide_window()
Process.sleep(50)
quit_module().quit() quit_module().quit()
end) end)
:ok :ok
end end
defp maybe_hide_window do
module = window_module()
if function_exported?(module, :hide, 1) do
module.hide(MainWindow.window_id())
end
:ok
rescue
_error -> :ok
catch
:exit, _reason -> :ok
end
defp quit_module do defp quit_module do
Application.get_env(:bds, :desktop_window_quit_module, Window) Application.get_env(:bds, :desktop_window_quit_module, Window)
end end
defp window_module do
Application.get_env(:bds, :desktop_window_module, Window)
end
end end

View File

@@ -1,7 +1,9 @@
defmodule BDS.AITest do defmodule BDS.AITest do
use ExUnit.Case, async: false use ExUnit.Case, async: false
import ExUnit.CaptureLog
import Ecto.Query import Ecto.Query
require Logger
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.Persistence alias BDS.Persistence
@@ -342,6 +344,11 @@ defmodule BDS.AITest do
{:ok, {_address, port}} = ThousandIsland.listener_info(server) {:ok, {_address, port}} = ThousandIsland.listener_info(server)
previous_level = Logger.level()
Logger.configure(level: :debug)
log =
capture_log(fn ->
assert {:ok, %{content: "Short Title"}} = assert {:ok, %{content: "Short Title"}} =
BDS.AI.OpenAICompatibleRuntime.generate( BDS.AI.OpenAICompatibleRuntime.generate(
%{url: "http://127.0.0.1:#{port}/v1", api_key: nil}, %{url: "http://127.0.0.1:#{port}/v1", api_key: nil},
@@ -353,6 +360,12 @@ defmodule BDS.AITest do
}, },
[] []
) )
end)
Logger.configure(level: previous_level)
assert log =~ "AI OpenAI-compatible request operation=:chat_title"
assert log =~ ~s(model="qwen3.5-122b")
assert_received {:completion_payload, payload} assert_received {:completion_payload, payload}
assert payload["model"] == "qwen3.5-122b" assert payload["model"] == "qwen3.5-122b"
@@ -361,6 +374,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 +544,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 +568,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 +742,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

View File

@@ -23,6 +23,18 @@ defmodule BDS.DesktopTest do
end end
end end
defmodule FakeWindowLifecycle do
def hide(window_id) do
send(Application.fetch_env!(:bds, :desktop_shutdown_test_pid), {:window_hide_requested, window_id})
:ok
end
def quit do
send(Application.fetch_env!(:bds, :desktop_shutdown_test_pid), :window_quit_requested)
:ok
end
end
test "desktop configuration no longer uses a pending adapter" do test "desktop configuration no longer uses a pending adapter" do
assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop assert Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop
end end
@@ -191,6 +203,30 @@ defmodule BDS.DesktopTest do
assert_receive :window_quit_requested assert_receive :window_quit_requested
end end
test "app-owned shutdown hides the window before hard quit" do
previous_module = Application.get_env(:bds, :desktop_shutdown_module)
previous_quit_module = Application.get_env(:bds, :desktop_window_quit_module)
previous_window_module = Application.get_env(:bds, :desktop_window_module)
previous_pid = Application.get_env(:bds, :desktop_shutdown_test_pid)
Application.put_env(:bds, :desktop_shutdown_module, BDS.Desktop.Shutdown)
Application.put_env(:bds, :desktop_window_quit_module, FakeWindowLifecycle)
Application.put_env(:bds, :desktop_window_module, FakeWindowLifecycle)
Application.put_env(:bds, :desktop_shutdown_test_pid, self())
expected_window_id = BDS.Desktop.MainWindow.window_id()
on_exit(fn ->
restore_env(:desktop_shutdown_module, previous_module)
restore_env(:desktop_window_quit_module, previous_quit_module)
restore_env(:desktop_window_module, previous_window_module)
restore_env(:desktop_shutdown_test_pid, previous_pid)
end)
assert :ok = BDS.Desktop.Shutdown.request_quit()
assert_receive {:window_hide_requested, ^expected_window_id}
assert_receive :window_quit_requested
end
test "desktop root html is a LiveView shell and references only the live bootstrap assets" do test "desktop root html is a LiveView shell and references only the live bootstrap assets" do
conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}") conn = conn(:get, "/?k=#{Desktop.Auth.login_key()}")
conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([])) conn = BDS.Desktop.Endpoint.call(conn, BDS.Desktop.Endpoint.init([]))