feat: AI provider setup now more what we want
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, %{
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user