diff --git a/lib/bds/ai/catalog.ex b/lib/bds/ai/catalog.ex index 6f7051e..7614c3d 100644 --- a/lib/bds/ai/catalog.ex +++ b/lib/bds/ai/catalog.ex @@ -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 capabilities = %{ 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)) @@ -163,7 +164,8 @@ defmodule BDS.AI.Catalog do @spec model_capabilities(String.t()) :: %{ supports_attachment: boolean(), - supports_tool_calls: boolean() + supports_tool_calls: boolean(), + disables_reasoning: boolean() } def model_capabilities(model_id) do overrides = decode_model_capabilities_override(model_id) @@ -173,7 +175,8 @@ defmodule BDS.AI.Catalog do {:ok, model} -> %{ 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 -> @@ -196,7 +199,8 @@ defmodule BDS.AI.Catalog do String.contains?(normalized, "llava"), supports_tool_calls: String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or - String.contains?(normalized, "tool") + String.contains?(normalized, "tool"), + disables_reasoning: false } end diff --git a/lib/bds/ai/chat.ex b/lib/bds/ai/chat.ex index ea05e13..531b510 100644 --- a/lib/bds/ai/chat.ex +++ b/lib/bds/ai/chat.ex @@ -2,6 +2,7 @@ defmodule BDS.AI.Chat do @moduledoc false import Ecto.Query + require Logger alias BDS.AI 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_max_output_tokens 16_384 - @title_max_output_tokens 20 + @title_max_output_tokens 256 @chat_title_max_length 30 @chat_max_tool_rounds 10 @default_context_window 128_000 @@ -383,7 +384,7 @@ defmodule BDS.AI.Chat do conversation = Repo.get!(ChatConversation, conversation_id) cond do - chat_user_message_count(conversation_id) != 1 -> + chat_user_message_count(conversation_id) < 1 -> {:ok, reply} 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), 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))} + 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 @@ -431,7 +450,7 @@ defmodule BDS.AI.Chat do %{ "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." + "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)}"} ] diff --git a/lib/bds/ai/openai_compatible_runtime.ex b/lib/bds/ai/openai_compatible_runtime.ex index 5d76ef6..d4ac24c 100644 --- a/lib/bds/ai/openai_compatible_runtime.ex +++ b/lib/bds/ai/openai_compatible_runtime.ex @@ -36,6 +36,7 @@ defmodule BDS.AI.OpenAICompatibleRuntime do "messages" => request.messages, "max_tokens" => request.max_output_tokens } + |> maybe_disable_thinking(request.model) |> maybe_put_tools(Map.get(request, :tools, [])) with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)), @@ -136,6 +137,18 @@ defmodule BDS.AI.OpenAICompatibleRuntime do |> Map.put("tool_choice", "auto") 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 Enum.map(tool_calls, fn tool_call -> %{ diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index 795ffc4..2585f1e 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do import Phoenix.HTML, only: [raw: 1] alias BDS.AI + alias BDS.MapUtils alias BDS.Desktop.ShellData alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking} @@ -249,8 +250,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do ) case result do - {:ok, _reply} -> - reload.(socket, socket.assigns.workbench) + {:ok, reply} -> + socket + |> update_tab_meta_from_reply(conversation_id, reply) + |> reload.(socket.assigns.workbench) {:error, :cancelled} -> reload.(socket, socket.assigns.workbench) @@ -266,6 +269,23 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do 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 ───────────────────────────────────────────────── @spec message_role_label(term()) :: term() diff --git a/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex b/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex index b141427..f829966 100644 --- a/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex +++ b/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex @@ -21,6 +21,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do model_supports_tool_calls?( 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_image_analysis_model" => get_model_preference(:image_analysis), "offline_url" => Map.get(airplane_endpoint || %{}, :url, ""), @@ -33,6 +37,10 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do model_supports_tool_calls?( 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_image_analysis_model" => get_model_preference(:airplane_image_analysis), "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 <- maybe_put_model_preference(:chat, attrs.online_chat_model), :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(:image_analysis, attrs.online_image_analysis_model), :ok <- maybe_put_model_preference(:airplane_chat, attrs.offline_chat_model), :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( @@ -147,6 +163,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do 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_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_image_analysis_model: blank_to_nil(Map.get(draft, "online_image_analysis_model")), 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_chat_model: blank_to_nil(Map.get(draft, "offline_chat_model")), 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_image_analysis_model: blank_to_nil(Map.get(draft, "offline_image_analysis_model")), 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_chat_model" => Map.get(params, "online_chat_model", ""), "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_image_analysis_model" => Map.get(params, "online_image_analysis_model", ""), "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_chat_model" => Map.get(params, "offline_chat_model", ""), "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_image_analysis_model" => Map.get(params, "offline_image_analysis_model", ""), "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, 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("", _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, _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) AI.put_model_capabilities(model, %{ supports_attachment: existing.supports_attachment, - supports_tool_calls: supports_tool_calls + supports_tool_calls: supports_tool_calls, + disables_reasoning: disables_reasoning }) end @@ -208,6 +231,12 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do defp model_supports_tool_calls?(model), 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 if Enum.all?([url, api_key, primary_model], &(blank_to_nil(&1) == nil)) do AI.delete_endpoint(kind) diff --git a/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex b/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex index 0892a0f..a3baf5e 100644 --- a/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex +++ b/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex @@ -233,6 +233,10 @@
+