Compare commits
2 Commits
c495a2ed0a
...
631ceb0521
| Author | SHA1 | Date | |
|---|---|---|---|
| 631ceb0521 | |||
| 7db8f6d36b |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)}"}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 ->
|
||||||
%{
|
%{
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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([]))
|
||||||
|
|||||||
Reference in New Issue
Block a user