diff --git a/lib/bds/ai.ex b/lib/bds/ai.ex index b009bf5..be94f9d 100644 --- a/lib/bds/ai.ex +++ b/lib/bds/ai.ex @@ -75,6 +75,11 @@ defmodule BDS.AI do :ok 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 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)) end - def airplane_mode? do - get_setting("ai.airplane_mode_enabled") == "true" + def airplane_mode?(default \\ false) when is_boolean(default) do + case get_setting("ai.airplane_mode_enabled") do + nil -> default + "false" -> false + _other -> true + end end def put_model_preference(key, model) when is_atom(key) and is_binary(model) do @@ -709,11 +718,11 @@ defmodule BDS.AI do end 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 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 defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, _extra) do @@ -721,7 +730,7 @@ defmodule BDS.AI do end 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 defp resolve_model_for_operation(_operation, :airplane, endpoint, _extra) do @@ -729,7 +738,7 @@ defmodule BDS.AI do end 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 defp validate_runtime_target(:analyze_image, model, _mode) do diff --git a/lib/bds/ai/openai_compatible_runtime.ex b/lib/bds/ai/openai_compatible_runtime.ex index 9071193..c70a01d 100644 --- a/lib/bds/ai/openai_compatible_runtime.ex +++ b/lib/bds/ai/openai_compatible_runtime.ex @@ -3,6 +3,23 @@ defmodule BDS.AI.OpenAICompatibleRuntime do 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 url = completions_url(endpoint.url) @@ -57,6 +74,36 @@ defmodule BDS.AI.OpenAICompatibleRuntime do 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, ""), do: headers defp maybe_put_auth(headers, api_key), do: Map.put(headers, "authorization", "Bearer #{api_key}") diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 3be8d6d..87e4455 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive do import Phoenix.HTML + alias BDS.AI alias BDS.Desktop.{FilePicker, FolderPicker, Overlay, ShellCommands, ShellData} alias BDS.Desktop.ShellLive.{ChatEditor, CodeEntityEditor, MediaEditor, MiscEditor, SettingsEditor, TagsEditor} alias BDS.Desktop.ShellLive.OverlayComponents, as: ShellOverlayComponents @@ -54,7 +55,7 @@ defmodule BDS.Desktop.ShellLive do |> assign(:page_title, ShellData.title()) |> assign(:page_language, ShellData.ui_language()) |> assign(:client_shortcuts, Commands.client_shortcuts()) - |> assign(:offline_mode, true) + |> assign(:offline_mode, AI.airplane_mode?(true)) |> assign(:assistant_prompt, "") |> assign(:assistant_messages, []) |> assign(:is_mac_ui, mac_ui?()) @@ -81,6 +82,7 @@ defmodule BDS.Desktop.ShellLive do |> assign(:media_editor_translation_forms, %{}) |> assign(:settings_editor_search, "") |> assign(:settings_editor_project_draft, %{}) + |> assign(:settings_editor_endpoint_models, %{}) |> assign(:settings_editor_publishing_draft, %{}) |> assign(:settings_editor_new_category, "") |> assign(:style_editor_theme, nil) @@ -343,7 +345,11 @@ defmodule BDS.Desktop.ShellLive do end 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)} end @@ -535,6 +541,11 @@ defmodule BDS.Desktop.ShellLive do {:noreply, SettingsEditor.update_ai_draft(socket, params, &reload_shell/2)} 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 {:noreply, SettingsEditor.save_ai(socket, &reload_shell/2, &append_output_entry/5)} end @@ -1023,7 +1034,7 @@ defmodule BDS.Desktop.ShellLive do task_status = BDS.Tasks.status_snapshot() activity_buttons = Workbench.activity_buttons(workbench, git_badge_count) 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 |> assign(:workbench, workbench) diff --git a/lib/bds/desktop/shell_live/settings_editor.ex b/lib/bds/desktop/shell_live/settings_editor.ex index f93924b..cc09617 100644 --- a/lib/bds/desktop/shell_live/settings_editor.ex +++ b/lib/bds/desktop/shell_live/settings_editor.ex @@ -6,7 +6,6 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do import Ecto.Query alias BDS.AI - alias BDS.AI.Model alias BDS.Metadata alias BDS.Desktop.ShellData alias BDS.MCP.AgentConfig @@ -143,21 +142,39 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do |> reload.(socket.assigns.workbench) 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 attrs = ai_attrs(socket.assigns) - with :ok <- maybe_put_endpoint(:online, attrs.online_api_key, attrs.default_model), - :ok <- maybe_put_endpoint(:mistral, attrs.mistral_api_key, attrs.default_model), + with :ok <- put_endpoint_preferences(:online, attrs.online_url, attrs.online_api_key, attrs.online_chat_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 <- maybe_put_model_preference(:default, attrs.default_model), - :ok <- maybe_put_model_preference(:title, attrs.title_model), - :ok <- maybe_put_model_preference(:image_analysis, attrs.image_analysis_model), + :ok <- maybe_put_model_preference(:chat, attrs.online_chat_model), + :ok <- maybe_put_model_preference(:title, attrs.online_title_model), + :ok <- maybe_put_model_preference(:image_analysis, attrs.online_image_analysis_model), :ok <- maybe_put_model_preference(:airplane_chat, attrs.offline_chat_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 <- put_global_setting("ai.system_prompt", attrs.system_prompt) do socket |> assign(:settings_editor_ai_draft, %{}) + |> assign(:offline_mode, attrs.offline_mode) |> reload.(socket.assigns.workbench) else {:error, reason} -> @@ -364,7 +381,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do metadata = project_metadata(assigns) 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, %{})) - 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, %{})) query = Map.get(assigns, :settings_editor_search, "") 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_default_path: Map.get(assigns.current_project || %{}, :project_path) || "", template_options: template_options(assigns.projects.active_project_id), - model_options: model_options(), - image_model_options: image_model_options(), + online_endpoint_models: endpoint_model_options(assigns, :online), + offline_endpoint_models: endpoint_model_options(assigns, :airplane), 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)), 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)), 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)), @@ -468,12 +485,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do 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")), - 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")), - 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_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")), @@ -521,18 +540,20 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do } end - defp ai_form do + defp ai_form(assigns) do {: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, ""), - "mistral_api_key" => Map.get(mistral_endpoint || %{}, :api_key, ""), - "offline_mode" => AI.airplane_mode?(), - "default_model" => get_model_preference(:default), - "title_model" => get_model_preference(:title), - "image_analysis_model" => get_model_preference(:image_analysis), - "offline_chat_model" => get_model_preference(:airplane_chat), + "online_chat_model" => get_model_preference(:chat) || Map.get(online_endpoint || %{}, :model, ""), + "online_title_model" => get_model_preference(:title), + "online_image_analysis_model" => get_model_preference(:image_analysis), + "offline_url" => Map.get(airplane_endpoint || %{}, :url, ""), + "offline_api_key" => Map.get(airplane_endpoint || %{}, :api_key, ""), + "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_image_analysis_model" => get_model_preference(:airplane_image_analysis), "system_prompt" => get_global_setting("ai.system_prompt") || "" @@ -611,12 +632,14 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do defp normalize_ai_params(params) do %{ + "online_url" => Map.get(params, "online_url", ""), "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")), - "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_title_model" => Map.get(params, "offline_title_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)) "editor" -> section_matches?(query, ~w(editor mode markdown preview diff wrap unchanged)) "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)) "publishing" -> section_matches?(query, ~w(publishing ssh scp rsync host user remote path)) "data" -> section_matches?(query, ~w(data rebuild maintenance links thumbnails filesystem)) @@ -680,28 +703,6 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do } 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 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, value), do: AI.put_model_preference(key, value) - defp maybe_put_endpoint(kind, nil, model) do - case model do - nil -> :ok - "" -> :ok - _other -> AI.put_endpoint(kind, %{model: model}) |> normalize_endpoint_result() + defp put_endpoint_preferences(kind, url, api_key, primary_model) do + if Enum.all?([url, api_key, primary_model], &(blank_to_nil(&1) == nil)) do + AI.delete_endpoint(kind) + else + AI.put_endpoint(kind, %{url: url, api_key: api_key, model: primary_model}) |> normalize_endpoint_result() end end - defp maybe_put_endpoint(kind, api_key, model) do - AI.put_endpoint(kind, %{api_key: api_key, model: model}) |> normalize_endpoint_result() + defp endpoint_model_options(assigns, endpoint_key) do + 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 defp normalize_endpoint_result({:ok, _endpoint}), do: :ok diff --git a/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex b/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex index 218b168..6db8fca 100644 --- a/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex +++ b/lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex @@ -203,90 +203,76 @@ <%= if @settings_editor.ai_visible? do %>
<%= translated("Provider keys, model preferences, airplane mode, and system prompt") %>
<%= translated("OpenAI-compatible endpoints, model routing, airplane mode, and system prompt") %>