feat: AI provider setup now more what we want
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -203,90 +203,76 @@
|
||||
|
||||
<%= if @settings_editor.ai_visible? do %>
|
||||
<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">
|
||||
<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>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Mistral API Key") %></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-info"><label class="setting-label"><%= translated("Online Chat Model") %></label></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 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>
|
||||
<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-info"><label class="setting-label"><%= translated("Offline Chat Model") %></label></div>
|
||||
<div class="setting-control">
|
||||
<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 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>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Offline Title Model") %></label></div>
|
||||
<div class="setting-control">
|
||||
<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 class="setting-control"><input type="text" list="settings-ai-offline-models" name="settings_ai[offline_title_model]" value={@settings_editor.ai["offline_title_model"]} /></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= translated("Offline Image Analysis Model") %></label></div>
|
||||
<div class="setting-control">
|
||||
<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 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>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user