feat: AI provider setup now more what we want

This commit is contained in:
2026-04-26 22:48:12 +02:00
parent d3c46127e5
commit b51764df24
7 changed files with 379 additions and 129 deletions

View File

@@ -75,6 +75,11 @@ defmodule BDS.AI do
:ok :ok
end end
def list_endpoint_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
http_client = Keyword.get(opts, :http_client, Application.get_env(:bds, :ai_http_client, BDS.AI.HttpClient))
OpenAICompatibleRuntime.list_models(endpoint, http_client: http_client)
end
def refresh_model_catalog(opts \\ []) when is_list(opts) do def refresh_model_catalog(opts \\ []) when is_list(opts) do
http_client = Keyword.get(opts, :http_client, BDS.AI.HttpClient) http_client = Keyword.get(opts, :http_client, BDS.AI.HttpClient)
@@ -147,8 +152,12 @@ defmodule BDS.AI do
put_setting("ai.airplane_mode_enabled", Atom.to_string(enabled)) put_setting("ai.airplane_mode_enabled", Atom.to_string(enabled))
end end
def airplane_mode? do def airplane_mode?(default \\ false) when is_boolean(default) do
get_setting("ai.airplane_mode_enabled") == "true" case get_setting("ai.airplane_mode_enabled") do
nil -> default
"false" -> false
_other -> true
end
end end
def put_model_preference(key, model) when is_atom(key) and is_binary(model) do def put_model_preference(key, model) when is_atom(key) and is_binary(model) do
@@ -709,11 +718,11 @@ defmodule BDS.AI do
end end
defp resolve_model_for_operation(:chat, :online, endpoint, conversation: conversation) do defp resolve_model_for_operation(:chat, :online, endpoint, conversation: conversation) do
{:ok, conversation.model || get_model_preference_value(:chat) || get_model_preference_value(:default) || endpoint.model} {:ok, conversation.model || get_model_preference_value(:chat) || endpoint.model}
end end
defp resolve_model_for_operation(:chat, :online, endpoint, _extra) do defp resolve_model_for_operation(:chat, :online, endpoint, _extra) do
{:ok, get_model_preference_value(:chat) || get_model_preference_value(:default) || endpoint.model} {:ok, get_model_preference_value(:chat) || endpoint.model}
end end
defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, _extra) do defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, _extra) do
@@ -721,7 +730,7 @@ defmodule BDS.AI do
end end
defp resolve_model_for_operation(:analyze_image, :online, endpoint, _extra) do defp resolve_model_for_operation(:analyze_image, :online, endpoint, _extra) do
{:ok, get_model_preference_value(:image_analysis) || get_model_preference_value(:default) || endpoint.model} {:ok, get_model_preference_value(:image_analysis) || endpoint.model}
end end
defp resolve_model_for_operation(_operation, :airplane, endpoint, _extra) do defp resolve_model_for_operation(_operation, :airplane, endpoint, _extra) do
@@ -729,7 +738,7 @@ defmodule BDS.AI do
end end
defp resolve_model_for_operation(_operation, :online, endpoint, _extra) do defp resolve_model_for_operation(_operation, :online, endpoint, _extra) do
{:ok, get_model_preference_value(:title) || get_model_preference_value(:default) || endpoint.model} {:ok, get_model_preference_value(:title) || endpoint.model}
end end
defp validate_runtime_target(:analyze_image, model, _mode) do defp validate_runtime_target(:analyze_image, model, _mode) do

View File

@@ -3,6 +3,23 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
alias BDS.AI.HttpClient alias BDS.AI.HttpClient
def list_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
http_client = Keyword.get(opts, :http_client, HttpClient)
url = models_url(endpoint.url)
headers =
%{"accept" => "application/json"}
|> maybe_put_auth(endpoint.api_key)
with {:ok, response} <- http_client.get(url, headers),
200 <- response.status do
normalize_models_response(response.body)
else
status when is_integer(status) -> {:error, %{kind: :http_error, status: status}}
{:error, reason} -> {:error, %{kind: :http_error, reason: reason}}
end
end
def generate(endpoint, request, _opts) when is_map(endpoint) and is_map(request) do def generate(endpoint, request, _opts) when is_map(endpoint) and is_map(request) do
url = completions_url(endpoint.url) url = completions_url(endpoint.url)
@@ -57,6 +74,36 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
end end
end end
defp models_url(url) do
cond do
String.ends_with?(url, "/chat/completions") -> String.replace_suffix(url, "/chat/completions", "/models")
String.ends_with?(url, "/models") -> url
String.ends_with?(url, "/") -> url <> "models"
true -> url <> "/models"
end
end
defp normalize_models_response(body) do
payload = Jason.decode!(body)
models =
payload
|> Map.get("data", [])
|> Enum.map(fn entry ->
id = entry["id"] || entry[:id]
%{
id: id,
label: id
}
end)
|> Enum.reject(&is_nil(&1.id))
|> Enum.uniq_by(& &1.id)
|> Enum.sort_by(&String.downcase(&1.id))
{:ok, models}
end
defp maybe_put_auth(headers, nil), do: headers defp maybe_put_auth(headers, nil), do: headers
defp maybe_put_auth(headers, ""), do: headers defp maybe_put_auth(headers, ""), do: headers
defp maybe_put_auth(headers, api_key), do: Map.put(headers, "authorization", "Bearer #{api_key}") defp maybe_put_auth(headers, api_key), do: Map.put(headers, "authorization", "Bearer #{api_key}")

View File

@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive do
import Phoenix.HTML import Phoenix.HTML
alias BDS.AI
alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData} alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData}
alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, MiscEditor, SettingsEditor, TagsEditor} alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, MiscEditor, SettingsEditor, TagsEditor}
alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents
@@ -54,7 +55,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:page_title, ShellData.title()) |> assign(:page_title, ShellData.title())
|> assign(:page_language, ShellData.ui_language()) |> assign(:page_language, ShellData.ui_language())
|> assign(:client_shortcuts, Commands.client_shortcuts()) |> assign(:client_shortcuts, Commands.client_shortcuts())
|> assign(:offline_mode, true) |> assign(:offline_mode, AI.airplane_mode?(true))
|> assign(:assistant_prompt, "") |> assign(:assistant_prompt, "")
|> assign(:assistant_messages, []) |> assign(:assistant_messages, [])
|> assign(:is_mac_ui, mac_ui?()) |> assign(:is_mac_ui, mac_ui?())
@@ -81,6 +82,7 @@ defmodule BDS.Desktop.ShellLive do
|> assign(:media_editor_translation_forms, %{}) |> assign(:media_editor_translation_forms, %{})
|> assign(:settings_editor_search, "") |> assign(:settings_editor_search, "")
|> assign(:settings_editor_project_draft, %{}) |> assign(:settings_editor_project_draft, %{})
|> assign(:settings_editor_endpoint_models, %{})
|> assign(:settings_editor_publishing_draft, %{}) |> assign(:settings_editor_publishing_draft, %{})
|> assign(:settings_editor_new_category, "") |> assign(:settings_editor_new_category, "")
|> assign(:style_editor_theme, nil) |> assign(:style_editor_theme, nil)
@@ -343,7 +345,11 @@ defmodule BDS.Desktop.ShellLive do
end end
def handle_event("toggle_offline_mode", _params, socket) do def handle_event("toggle_offline_mode", _params, socket) do
socket = assign(socket, :offline_mode, not socket.assigns.offline_mode) next_mode = not socket.assigns.offline_mode
:ok = AI.set_airplane_mode(next_mode)
socket = assign(socket, :offline_mode, next_mode)
{:noreply, reload_shell(socket, socket.assigns.workbench)} {:noreply, reload_shell(socket, socket.assigns.workbench)}
end end
@@ -535,6 +541,11 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, SettingsEditor.update_ai_draft(socket, params, &reload_shell/2)} {:noreply, SettingsEditor.update_ai_draft(socket, params, &reload_shell/2)}
end end
def handle_event("refresh_settings_ai_models", %{"endpoint" => endpoint}, socket) do
endpoint_key = String.to_existing_atom(endpoint)
{:noreply, SettingsEditor.refresh_ai_models(socket, endpoint_key, &reload_shell/2, &append_output_entry/5)}
end
def handle_event("save_settings_ai", _params, socket) do def handle_event("save_settings_ai", _params, socket) do
{:noreply, SettingsEditor.save_ai(socket, &reload_shell/2, &append_output_entry/5)} {:noreply, SettingsEditor.save_ai(socket, &reload_shell/2, &append_output_entry/5)}
end end
@@ -1023,7 +1034,7 @@ defmodule BDS.Desktop.ShellLive do
task_status = BDS.Tasks.status_snapshot() task_status = BDS.Tasks.status_snapshot()
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count) activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
page_language = socket.assigns[:page_language] || ShellData.ui_language() page_language = socket.assigns[:page_language] || ShellData.ui_language()
offline_mode = Map.get(socket.assigns, :offline_mode, true) offline_mode = Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true))
socket socket
|> assign(:workbench, workbench) |> assign(:workbench, workbench)

View File

@@ -6,7 +6,6 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
import Ecto.Query import Ecto.Query
alias BDS.AI alias BDS.AI
alias BDS.AI.Model
alias BDS.Metadata alias BDS.Metadata
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.MCP.AgentConfig alias BDS.MCP.AgentConfig
@@ -143,21 +142,39 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
end end
def refresh_ai_models(socket, endpoint_key, reload, append_output) do
attrs = ai_attrs(socket.assigns)
with {:ok, endpoint} <- endpoint_refresh_attrs(endpoint_key, attrs),
{:ok, models} <- AI.list_endpoint_models(endpoint) do
socket
|> assign(:settings_editor_endpoint_models, Map.put(socket.assigns[:settings_editor_endpoint_models] || %{}, endpoint_key, models))
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
socket
|> append_output.(translated("AI Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def save_ai(socket, reload, append_output) do def save_ai(socket, reload, append_output) do
attrs = ai_attrs(socket.assigns) attrs = ai_attrs(socket.assigns)
with :ok <- maybe_put_endpoint(:online, attrs.online_api_key, attrs.default_model), with :ok <- put_endpoint_preferences(:online, attrs.online_url, attrs.online_api_key, attrs.online_chat_model),
:ok <- maybe_put_endpoint(:mistral, attrs.mistral_api_key, attrs.default_model), :ok <- put_endpoint_preferences(:airplane, attrs.offline_url, attrs.offline_api_key, attrs.offline_chat_model),
:ok <- AI.delete_endpoint(:mistral),
:ok <- AI.set_airplane_mode(attrs.offline_mode), :ok <- AI.set_airplane_mode(attrs.offline_mode),
:ok <- maybe_put_model_preference(:default, attrs.default_model), :ok <- maybe_put_model_preference(:chat, attrs.online_chat_model),
:ok <- maybe_put_model_preference(:title, attrs.title_model), :ok <- maybe_put_model_preference(:title, attrs.online_title_model),
:ok <- maybe_put_model_preference(:image_analysis, attrs.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 <- maybe_put_model_preference(:airplane_title, attrs.offline_title_model), :ok <- maybe_put_model_preference(:airplane_title, attrs.offline_title_model),
:ok <- maybe_put_model_preference(:airplane_image_analysis, attrs.offline_image_analysis_model), :ok <- maybe_put_model_preference(:airplane_image_analysis, attrs.offline_image_analysis_model),
:ok <- put_global_setting("ai.system_prompt", attrs.system_prompt) do :ok <- put_global_setting("ai.system_prompt", attrs.system_prompt) do
socket socket
|> assign(:settings_editor_ai_draft, %{}) |> assign(:settings_editor_ai_draft, %{})
|> assign(:offline_mode, attrs.offline_mode)
|> reload.(socket.assigns.workbench) |> reload.(socket.assigns.workbench)
else else
{:error, reason} -> {:error, reason} ->
@@ -364,7 +381,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
metadata = project_metadata(assigns) metadata = project_metadata(assigns)
project_form = Map.merge(project_form(metadata), Map.get(assigns, :settings_editor_project_draft, %{})) project_form = Map.merge(project_form(metadata), Map.get(assigns, :settings_editor_project_draft, %{}))
editor_form = Map.merge(editor_form(), Map.get(assigns, :settings_editor_editor_draft, %{})) editor_form = Map.merge(editor_form(), Map.get(assigns, :settings_editor_editor_draft, %{}))
ai_form = Map.merge(ai_form(), Map.get(assigns, :settings_editor_ai_draft, %{})) ai_form = Map.merge(ai_form(assigns), Map.get(assigns, :settings_editor_ai_draft, %{}))
publishing_form = Map.merge(publishing_form(metadata), Map.get(assigns, :settings_editor_publishing_draft, %{})) publishing_form = Map.merge(publishing_form(metadata), Map.get(assigns, :settings_editor_publishing_draft, %{}))
query = Map.get(assigns, :settings_editor_search, "") query = Map.get(assigns, :settings_editor_search, "")
selected_section = current_settings_section(assigns) selected_section = current_settings_section(assigns)
@@ -385,12 +402,12 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
project_data_path: Map.get(assigns.current_project || %{}, :data_path) || "", project_data_path: Map.get(assigns.current_project || %{}, :data_path) || "",
project_data_default_path: Map.get(assigns.current_project || %{}, :project_path) || "", project_data_default_path: Map.get(assigns.current_project || %{}, :project_path) || "",
template_options: template_options(assigns.projects.active_project_id), template_options: template_options(assigns.projects.active_project_id),
model_options: model_options(), online_endpoint_models: endpoint_model_options(assigns, :online),
image_model_options: image_model_options(), offline_endpoint_models: endpoint_model_options(assigns, :airplane),
project_visible?: section_matches?(query, ~w(project name description url language author category posts bookmarklet)), project_visible?: section_matches?(query, ~w(project name description url language author category posts bookmarklet)),
editor_visible?: section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged)), editor_visible?: section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged)),
content_visible?: section_matches?(query, ~w(content categories templates lists blogmark)), content_visible?: section_matches?(query, ~w(content categories templates lists blogmark)),
ai_visible?: section_matches?(query, ~w(ai assistant model prompt offline endpoint api key title image mistral anthropic)), ai_visible?: section_matches?(query, ~w(ai assistant model prompt airplane offline online endpoint url api key chat title image)),
technology_visible?: section_matches?(query, ~w(technology runtime semantic similarity embedding scripting)), technology_visible?: section_matches?(query, ~w(technology runtime semantic similarity embedding scripting)),
publishing_visible?: section_matches?(query, ~w(publishing ssh scp rsync host user remote path)), publishing_visible?: section_matches?(query, ~w(publishing ssh scp rsync host user remote path)),
mcp_visible?: section_matches?(query, ~w(mcp claude copilot gemini opencode mistral codex agent server)), mcp_visible?: section_matches?(query, ~w(mcp claude copilot gemini opencode mistral codex agent server)),
@@ -468,12 +485,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
draft = Map.get(assigns, :settings_editor_ai_draft, %{}) draft = Map.get(assigns, :settings_editor_ai_draft, %{})
%{ %{
online_url: blank_to_nil(Map.get(draft, "online_url")),
online_api_key: blank_to_nil(Map.get(draft, "online_api_key")), online_api_key: blank_to_nil(Map.get(draft, "online_api_key")),
mistral_api_key: blank_to_nil(Map.get(draft, "mistral_api_key")), online_chat_model: blank_to_nil(Map.get(draft, "online_chat_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")),
offline_url: blank_to_nil(Map.get(draft, "offline_url")),
offline_api_key: blank_to_nil(Map.get(draft, "offline_api_key")),
offline_mode: truthy?(Map.get(draft, "offline_mode")), offline_mode: truthy?(Map.get(draft, "offline_mode")),
default_model: blank_to_nil(Map.get(draft, "default_model")),
title_model: blank_to_nil(Map.get(draft, "title_model")),
image_analysis_model: blank_to_nil(Map.get(draft, "image_analysis_model")),
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_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")),
@@ -521,18 +540,20 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
} }
end end
defp ai_form do defp ai_form(assigns) do
{:ok, online_endpoint} = AI.get_endpoint(:online) {:ok, online_endpoint} = AI.get_endpoint(:online)
{:ok, mistral_endpoint} = AI.get_endpoint(:mistral) {:ok, airplane_endpoint} = AI.get_endpoint(:airplane)
%{ %{
"online_url" => Map.get(online_endpoint || %{}, :url, ""),
"online_api_key" => Map.get(online_endpoint || %{}, :api_key, ""), "online_api_key" => Map.get(online_endpoint || %{}, :api_key, ""),
"mistral_api_key" => Map.get(mistral_endpoint || %{}, :api_key, ""), "online_chat_model" => get_model_preference(:chat) || Map.get(online_endpoint || %{}, :model, ""),
"offline_mode" => AI.airplane_mode?(), "online_title_model" => get_model_preference(:title),
"default_model" => get_model_preference(:default), "online_image_analysis_model" => get_model_preference(:image_analysis),
"title_model" => get_model_preference(:title), "offline_url" => Map.get(airplane_endpoint || %{}, :url, ""),
"image_analysis_model" => get_model_preference(:image_analysis), "offline_api_key" => Map.get(airplane_endpoint || %{}, :api_key, ""),
"offline_chat_model" => get_model_preference(:airplane_chat), "offline_mode" => Map.get(assigns, :offline_mode, AI.airplane_mode?(true)),
"offline_chat_model" => 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" => get_global_setting("ai.system_prompt") || "" "system_prompt" => get_global_setting("ai.system_prompt") || ""
@@ -611,12 +632,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
defp normalize_ai_params(params) do defp normalize_ai_params(params) do
%{ %{
"online_url" => Map.get(params, "online_url", ""),
"online_api_key" => Map.get(params, "online_api_key", ""), "online_api_key" => Map.get(params, "online_api_key", ""),
"mistral_api_key" => Map.get(params, "mistral_api_key", ""), "online_chat_model" => Map.get(params, "online_chat_model", ""),
"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", ""),
"offline_api_key" => Map.get(params, "offline_api_key", ""),
"offline_mode" => truthy?(Map.get(params, "offline_mode")), "offline_mode" => truthy?(Map.get(params, "offline_mode")),
"default_model" => Map.get(params, "default_model", ""),
"title_model" => Map.get(params, "title_model", ""),
"image_analysis_model" => Map.get(params, "image_analysis_model", ""),
"offline_chat_model" => Map.get(params, "offline_chat_model", ""), "offline_chat_model" => Map.get(params, "offline_chat_model", ""),
"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", ""),
@@ -652,7 +675,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
"project" -> section_matches?(query, ~w(project name description data url language author bookmarklet)) "project" -> section_matches?(query, ~w(project name description data url language author bookmarklet))
"editor" -> section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged)) "editor" -> section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged))
"content" -> section_matches?(query, ~w(content categories templates lists blogmark)) "content" -> section_matches?(query, ~w(content categories templates lists blogmark))
"ai" -> section_matches?(query, ~w(ai assistant model prompt offline endpoint api key title image mistral anthropic)) "ai" -> section_matches?(query, ~w(ai assistant model prompt airplane offline online endpoint url api key chat title image))
"technology" -> section_matches?(query, ~w(technology semantic similarity runtime scripting embedding)) "technology" -> section_matches?(query, ~w(technology semantic similarity runtime scripting embedding))
"publishing" -> section_matches?(query, ~w(publishing ssh scp rsync host user remote path)) "publishing" -> section_matches?(query, ~w(publishing ssh scp rsync host user remote path))
"data" -> section_matches?(query, ~w(data rebuild maintenance links thumbnails filesystem)) "data" -> section_matches?(query, ~w(data rebuild maintenance links thumbnails filesystem))
@@ -680,28 +703,6 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
} }
end end
defp model_options do
Repo.all(
from model in Model,
order_by: [asc: model.provider, asc: model.name],
select: %{
id: model.model_id,
provider: model.provider,
name: model.name,
context_window: model.context_window,
max_output_tokens: model.max_output_tokens,
supports_attachment: model.supports_attachment
}
)
|> Enum.map(fn model ->
Map.put(model, :label, model.provider <> " / " <> model.name)
end)
end
defp image_model_options do
Enum.filter(model_options(), & &1.supports_attachment)
end
defp mcp_rows do defp mcp_rows do
Enum.map(@mcp_agents, fn agent -> Enum.map(@mcp_agents, fn agent ->
%{ %{
@@ -768,16 +769,33 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor 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_endpoint(kind, nil, model) do defp put_endpoint_preferences(kind, url, api_key, primary_model) do
case model do if Enum.all?([url, api_key, primary_model], &(blank_to_nil(&1) == nil)) do
nil -> :ok AI.delete_endpoint(kind)
"" -> :ok else
_other -> AI.put_endpoint(kind, %{model: model}) |> normalize_endpoint_result() AI.put_endpoint(kind, %{url: url, api_key: api_key, model: primary_model}) |> normalize_endpoint_result()
end end
end end
defp maybe_put_endpoint(kind, api_key, model) do defp endpoint_model_options(assigns, endpoint_key) do
AI.put_endpoint(kind, %{api_key: api_key, model: model}) |> normalize_endpoint_result() assigns
|> Map.get(:settings_editor_endpoint_models, %{})
|> Map.get(endpoint_key, [])
end
defp endpoint_refresh_attrs(:online, attrs) do
endpoint_refresh_attrs(attrs.online_url, attrs.online_api_key)
end
defp endpoint_refresh_attrs(:airplane, attrs) do
endpoint_refresh_attrs(attrs.offline_url, attrs.offline_api_key)
end
defp endpoint_refresh_attrs(url, api_key) do
case blank_to_nil(url) do
nil -> {:error, :endpoint_not_configured}
loaded_url -> {:ok, %{url: loaded_url, api_key: api_key}}
end
end end
defp normalize_endpoint_result({:ok, _endpoint}), do: :ok defp normalize_endpoint_result({:ok, _endpoint}), do: :ok

View File

@@ -203,90 +203,76 @@
<%= if @settings_editor.ai_visible? do %> <%= if @settings_editor.ai_visible? do %>
<div class="setting-section" id="settings-section-ai"> <div class="setting-section" id="settings-section-ai">
<div class="setting-section-header"><h3><%= translated("AI") %></h3><p class="setting-section-description"><%= translated("Provider keys, model preferences, airplane mode, and system prompt") %></p></div> <div class="setting-section-header"><h3><%= translated("AI") %></h3><p class="setting-section-description"><%= translated("OpenAI-compatible endpoints, model routing, airplane mode, and system prompt") %></p></div>
<form class="setting-section-content" phx-change="change_settings_ai"> <form class="setting-section-content" phx-change="change_settings_ai">
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Anthropic / Online API Key") %></label></div> <div class="setting-info"><label class="setting-label"><%= translated("Online Endpoint URL") %></label></div>
<div class="setting-control">
<div class="setting-input-group">
<input type="url" name="settings_ai[online_url]" value={@settings_editor.ai["online_url"]} />
<button class="secondary" type="button" phx-click="refresh_settings_ai_models" phx-value-endpoint="online"><%= translated("Refresh Online Models") %></button>
</div>
</div>
</div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Online API Key") %></label></div>
<div class="setting-control"><input type="password" name="settings_ai[online_api_key]" value={@settings_editor.ai["online_api_key"]} /></div> <div class="setting-control"><input type="password" name="settings_ai[online_api_key]" value={@settings_editor.ai["online_api_key"]} /></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Mistral API Key") %></label></div> <div class="setting-info"><label class="setting-label"><%= translated("Online Chat Model") %></label></div>
<div class="setting-control"><input type="password" name="settings_ai[mistral_api_key]" value={@settings_editor.ai["mistral_api_key"]} /></div> <div class="setting-control"><input type="text" list="settings-ai-online-models" name="settings_ai[online_chat_model]" value={@settings_editor.ai["online_chat_model"]} /></div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Offline Mode") %></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>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Online Image Analysis Model") %></label></div>
<div class="setting-control"><input type="text" list="settings-ai-online-models" name="settings_ai[online_image_analysis_model]" value={@settings_editor.ai["online_image_analysis_model"]} /></div>
</div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Offline Endpoint URL") %></label></div>
<div class="setting-control">
<div class="setting-input-group">
<input type="url" name="settings_ai[offline_url]" value={@settings_editor.ai["offline_url"]} />
<button class="secondary" type="button" phx-click="refresh_settings_ai_models" phx-value-endpoint="airplane"><%= translated("Refresh Offline Models") %></button>
</div>
</div>
</div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Offline API Key") %></label></div>
<div class="setting-control"><input type="password" name="settings_ai[offline_api_key]" value={@settings_editor.ai["offline_api_key"]} /></div>
</div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Airplane Mode") %></label></div>
<div class="setting-control"><label><input type="checkbox" name="settings_ai[offline_mode]" checked={@settings_editor.ai["offline_mode"]} /> <%= translated("Route AI tasks through the airplane endpoint") %></label></div> <div class="setting-control"><label><input type="checkbox" name="settings_ai[offline_mode]" checked={@settings_editor.ai["offline_mode"]} /> <%= translated("Route AI tasks through the airplane endpoint") %></label></div>
</div> </div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Default Model") %></label></div>
<div class="setting-control">
<select name="settings_ai[default_model]">
<option value=""></option>
<%= for model <- @settings_editor.model_options do %>
<option value={model.id} selected={model.id == @settings_editor.ai["default_model"]}><%= model.label %></option>
<% end %>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Title Model") %></label></div>
<div class="setting-control">
<select name="settings_ai[title_model]">
<option value=""></option>
<%= for model <- @settings_editor.model_options do %>
<option value={model.id} selected={model.id == @settings_editor.ai["title_model"]}><%= model.label %></option>
<% end %>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Image Analysis Model") %></label></div>
<div class="setting-control">
<select name="settings_ai[image_analysis_model]">
<option value=""></option>
<%= for model <- @settings_editor.image_model_options do %>
<option value={model.id} selected={model.id == @settings_editor.ai["image_analysis_model"]}><%= model.label %></option>
<% end %>
</select>
</div>
</div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Offline Chat Model") %></label></div> <div class="setting-info"><label class="setting-label"><%= translated("Offline Chat Model") %></label></div>
<div class="setting-control"> <div class="setting-control"><input type="text" list="settings-ai-offline-models" name="settings_ai[offline_chat_model]" value={@settings_editor.ai["offline_chat_model"]} /></div>
<select name="settings_ai[offline_chat_model]">
<option value=""></option>
<%= for model <- @settings_editor.model_options do %>
<option value={model.id} selected={model.id == @settings_editor.ai["offline_chat_model"]}><%= model.label %></option>
<% end %>
</select>
</div>
</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"> <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>
<select name="settings_ai[offline_title_model]">
<option value=""></option>
<%= for model <- @settings_editor.model_options do %>
<option value={model.id} selected={model.id == @settings_editor.ai["offline_title_model"]}><%= model.label %></option>
<% end %>
</select>
</div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("Offline Image Analysis Model") %></label></div> <div class="setting-info"><label class="setting-label"><%= translated("Offline Image Analysis Model") %></label></div>
<div class="setting-control"> <div class="setting-control"><input type="text" list="settings-ai-offline-models" name="settings_ai[offline_image_analysis_model]" value={@settings_editor.ai["offline_image_analysis_model"]} /></div>
<select name="settings_ai[offline_image_analysis_model]">
<option value=""></option>
<%= for model <- @settings_editor.image_model_options do %>
<option value={model.id} selected={model.id == @settings_editor.ai["offline_image_analysis_model"]}><%= model.label %></option>
<% end %>
</select>
</div>
</div> </div>
<div class="setting-row"> <div class="setting-row">
<div class="setting-info"><label class="setting-label"><%= translated("System Prompt") %></label></div> <div class="setting-info"><label class="setting-label"><%= translated("System Prompt") %></label></div>
<div class="setting-control"><textarea name="settings_ai[system_prompt]" rows="12"><%= @settings_editor.ai["system_prompt"] %></textarea></div> <div class="setting-control"><textarea name="settings_ai[system_prompt]" rows="12"><%= @settings_editor.ai["system_prompt"] %></textarea></div>
</div> </div>
<datalist id="settings-ai-online-models">
<%= for model <- @settings_editor.online_endpoint_models do %>
<option value={model.id}></option>
<% end %>
</datalist>
<datalist id="settings-ai-offline-models">
<%= for model <- @settings_editor.offline_endpoint_models do %>
<option value={model.id}></option>
<% end %>
</datalist>
</form> </form>
<div class="setting-actions"><button class="primary" type="button" phx-click="save_settings_ai"><%= translated("Save") %></button><button class="secondary" type="button" phx-click="reset_settings_ai_prompt"><%= translated("Reset to Default") %></button></div> <div class="setting-actions"><button class="primary" type="button" phx-click="save_settings_ai"><%= translated("Save") %></button><button class="secondary" type="button" phx-click="reset_settings_ai_prompt"><%= translated("Reset to Default") %></button></div>
</div> </div>

View File

@@ -75,6 +75,19 @@ defmodule BDS.AITest do
end end
end end
defmodule FakeEndpointHttpClient do
def get("https://api.example.test/v1/models", _headers) do
{:ok,
%{
status: 200,
headers: %{},
body: Jason.encode!(%{"data" => [%{"id" => "gpt-4.1"}, %{"id" => "gpt-4.1-mini"}]})
}}
end
def get(_url, _headers), do: {:error, :not_found}
end
defmodule FakeRuntime do defmodule FakeRuntime do
def generate(endpoint, request, opts) do def generate(endpoint, request, opts) do
test_pid = Keyword.fetch!(opts, :test_pid) test_pid = Keyword.fetch!(opts, :test_pid)
@@ -224,6 +237,15 @@ defmodule BDS.AITest do
assert_received {:conditional_headers, %{"accept" => "application/json", "if-none-match" => "W/\"catalog-v1\""}} assert_received {:conditional_headers, %{"accept" => "application/json", "if-none-match" => "W/\"catalog-v1\""}}
end end
test "list_endpoint_models reads openai-compatible models from the configured endpoint" do
assert {:ok, models} =
BDS.AI.list_endpoint_models(%{url: "https://api.example.test/v1", api_key: "online-secret"},
http_client: FakeEndpointHttpClient
)
assert [%{id: "gpt-4.1", label: "gpt-4.1"}, %{id: "gpt-4.1-mini", label: "gpt-4.1-mini"}] = models
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(:online, %{ BDS.AI.put_endpoint(:online, %{

View File

@@ -18,6 +18,20 @@ defmodule BDS.Desktop.ShellLiveTest do
alias BDS.ImportDefinitions alias BDS.ImportDefinitions
alias BDS.UI.{Session, Workbench} alias BDS.UI.{Session, Workbench}
defmodule FakeEndpointModelHttpClient do
def get("https://api.example.test/v1/models", _headers) do
{:ok,
%{status: 200, headers: %{}, body: Jason.encode!(%{"data" => [%{"id" => "gpt-4.1"}, %{"id" => "gpt-4.1-mini"}]})}}
end
def get("http://localhost:11434/v1/models", _headers) do
{:ok,
%{status: 200, headers: %{}, body: Jason.encode!(%{"data" => [%{"id" => "llama3.3"}, %{"id" => "llava:latest"}]})}}
end
def get(_url, _headers), do: {:error, :not_found}
end
@endpoint BDS.Desktop.Endpoint @endpoint BDS.Desktop.Endpoint
setup do setup do
@@ -34,6 +48,7 @@ defmodule BDS.Desktop.ShellLiveTest do
original_shell_platform = Application.get_env(:bds, :shell_platform) original_shell_platform = Application.get_env(:bds, :shell_platform)
original_git_remote_state_provider = Application.get_env(:bds, :git_remote_state_provider) original_git_remote_state_provider = Application.get_env(:bds, :git_remote_state_provider)
original_ai_http_client = Application.get_env(:bds, :ai_http_client)
on_exit(fn -> on_exit(fn ->
if is_nil(original_shell_platform) do if is_nil(original_shell_platform) do
@@ -47,6 +62,12 @@ defmodule BDS.Desktop.ShellLiveTest do
else else
Application.put_env(:bds, :git_remote_state_provider, original_git_remote_state_provider) Application.put_env(:bds, :git_remote_state_provider, original_git_remote_state_provider)
end end
if is_nil(original_ai_http_client) do
Application.delete_env(:bds, :ai_http_client)
else
Application.put_env(:bds, :ai_http_client, original_ai_http_client)
end
end) end)
%{project: project, temp_dir: temp_dir} %{project: project, temp_dir: temp_dir}
@@ -211,12 +232,12 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ ~s(aria-label="Media") assert html =~ ~s(aria-label="Media")
assert html =~ ~s(data-view="media") assert html =~ ~s(data-view="media")
html = settings_html =
view view
|> element("[data-testid='activity-button'][data-view='settings']") |> element("[data-testid='activity-button'][data-view='settings']")
|> render_click() |> render_click()
assert html =~ ~s(data-testid="sidebar-open-item") assert settings_html =~ ~s(data-testid="sidebar-open-item")
html = html =
view view
@@ -387,6 +408,142 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "Automatic AI actions stay gated by airplane mode." assert html =~ "Automatic AI actions stay gated by airplane mode."
end end
test "ai settings expose two openai-compatible endpoints and clear legacy mistral config" do
assert {:ok, _endpoint} =
AI.put_endpoint(:mistral, %{
url: "https://legacy.example.test/v1",
api_key: "legacy-secret",
model: "legacy-model"
})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html =
view
|> element("[data-testid='activity-button'][data-view='settings']")
|> render_click()
html =
view
|> element("[data-testid='sidebar-open-item'][data-item-id='settings-project']")
|> render_click()
assert html =~ "AI"
assert html =~ "Online Endpoint URL"
assert html =~ "Offline Endpoint URL"
assert html =~ "Online API Key"
assert html =~ "Offline API Key"
refute html =~ "Mistral API Key"
refute html =~ "Anthropic / Online API Key"
_html =
render_change(view, "change_settings_ai", %{
"settings_ai" => %{
"online_url" => "https://api.example.test/v1",
"online_api_key" => "online-secret",
"online_chat_model" => "gpt-4.1",
"online_title_model" => "gpt-4.1-mini",
"online_image_analysis_model" => "gpt-4.1-vision",
"offline_url" => "http://localhost:11434/v1",
"offline_api_key" => "",
"offline_chat_model" => "llama3.3",
"offline_title_model" => "llama3.2",
"offline_image_analysis_model" => "llava:latest",
"offline_mode" => "true",
"system_prompt" => "You are the local test prompt."
}
})
_html = render_click(view, "save_settings_ai")
assert {:ok, online_endpoint} = AI.get_endpoint(:online)
assert online_endpoint.url == "https://api.example.test/v1"
assert online_endpoint.api_key == "online-secret"
assert online_endpoint.model == "gpt-4.1"
assert {:ok, offline_endpoint} = AI.get_endpoint(:airplane)
assert offline_endpoint.url == "http://localhost:11434/v1"
assert offline_endpoint.api_key in [nil, ""]
assert offline_endpoint.model == "llama3.3"
assert {:ok, nil} = AI.get_endpoint(:mistral)
assert AI.airplane_mode?()
assert {:ok, "gpt-4.1"} = AI.get_model_preference(:chat)
assert {:ok, "gpt-4.1-mini"} = AI.get_model_preference(:title)
assert {:ok, "gpt-4.1-vision"} = AI.get_model_preference(:image_analysis)
assert {:ok, "llama3.3"} = AI.get_model_preference(:airplane_chat)
assert {:ok, "llama3.2"} = AI.get_model_preference(:airplane_title)
assert {:ok, "llava:latest"} = AI.get_model_preference(:airplane_image_analysis)
end
test "ai settings refresh models from the configured endpoints" do
Application.put_env(:bds, :ai_http_client, FakeEndpointModelHttpClient)
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
_html =
view
|> element("[data-testid='activity-button'][data-view='settings']")
|> render_click()
html =
view
|> element("[data-testid='sidebar-open-item'][data-item-id='settings-project']")
|> render_click()
assert html =~ "Refresh Online Models"
assert html =~ "Refresh Offline Models"
_html =
render_change(view, "change_settings_ai", %{
"settings_ai" => %{
"online_url" => "https://api.example.test/v1",
"offline_url" => "http://localhost:11434/v1"
}
})
html =
view
|> element("button[phx-click='refresh_settings_ai_models'][phx-value-endpoint='online']")
|> render_click()
assert html =~ ~s(<option value="gpt-4.1"></option>)
assert html =~ ~s(<option value="gpt-4.1-mini"></option>)
html =
view
|> element("button[phx-click='refresh_settings_ai_models'][phx-value-endpoint='airplane']")
|> render_click()
assert html =~ ~s(<option value="llama3.3"></option>)
assert html =~ ~s(<option value="llava:latest"></option>)
end
test "status bar airplane toggle persists the active ai mode" do
assert :ok = AI.set_airplane_mode(false)
{:ok, view, html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
refute html =~ ~s(status-bar-item offline-badge active)
refute AI.airplane_mode?()
html =
view
|> element("[data-testid='status-offline-button']")
|> render_click()
assert html =~ ~s(status-bar-item offline-badge active)
assert AI.airplane_mode?()
html =
view
|> element("[data-testid='status-offline-button']")
|> render_click()
refute html =~ ~s(status-bar-item offline-badge active)
refute AI.airplane_mode?()
end
test "sidebar open supports preview and pin intents for entity tabs" do test "sidebar open supports preview and pin intents for entity tabs" do
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)