feat: better parity in layout for media and preferences

This commit is contained in:
2026-04-26 21:50:31 +02:00
parent c34c7cd3e0
commit 334ffe6f6a
14 changed files with 1786 additions and 340 deletions

View File

@@ -3,11 +3,22 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
use Phoenix.Component
import Ecto.Query
alias BDS.AI
alias BDS.AI.Model
alias BDS.Metadata
alias BDS.Desktop.ShellData
alias BDS.MCP.AgentConfig
alias BDS.Persistence
alias BDS.Repo
alias BDS.Settings.Setting
alias BDS.Templates.Template
embed_templates "settings_editor_html/*"
@settings_sections ~w(project editor content ai technology publishing data mcp)
@themes [
"default",
"amber",
@@ -33,6 +44,21 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
@supported_languages ["en", "de", "fr", "it", "es"]
@protected_categories MapSet.new(["article", "aside", "page", "picture"])
@default_category_settings %{
"article" => %{title: "article", render_in_lists: true, show_title: true},
"picture" => %{title: "picture", render_in_lists: true, show_title: true},
"aside" => %{title: "aside", render_in_lists: true, show_title: false},
"page" => %{title: "page", render_in_lists: false, show_title: true}
}
@mcp_agents [
%{id: :claude_code, label: "Claude Code", supported?: true},
%{id: :claude_desktop, label: "Claude Desktop", supported?: false},
%{id: :github_copilot, label: "GitHub Copilot", supported?: true},
%{id: :gemini_cli, label: "Gemini CLI", supported?: false},
%{id: :opencode, label: "OpenCode", supported?: false},
%{id: :mistral_vibe, label: "Mistral Vibe", supported?: false},
%{id: :openai_codex, label: "OpenAI Codex", supported?: false}
]
def assign_socket(socket) do
case socket.assigns[:current_tab] do
@@ -65,6 +91,30 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|> reload.(socket.assigns.workbench)
end
def update_editor_draft(socket, params, reload) do
socket
|> assign(:settings_editor_editor_draft, normalize_editor_params(params))
|> reload.(socket.assigns.workbench)
end
def save_editor(socket, reload, append_output) do
attrs = editor_attrs(socket.assigns)
with :ok <- put_global_setting("ui.preferred_editor_mode", attrs.default_mode),
:ok <- put_global_setting("ui.git_diff_view_style", attrs.diff_view_style),
:ok <- put_global_setting("ui.git_diff_word_wrap", boolean_string(attrs.wrap_long_lines)),
:ok <- put_global_setting("ui.git_diff_hide_unchanged_regions", boolean_string(attrs.hide_unchanged_regions)) do
socket
|> assign(:settings_editor_editor_draft, %{})
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
socket
|> append_output.(translated("Editor Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def save_project(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
@@ -87,6 +137,50 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|> reload.(socket.assigns.workbench)
end
def update_ai_draft(socket, params, reload) do
socket
|> assign(:settings_editor_ai_draft, normalize_ai_params(params))
|> reload.(socket.assigns.workbench)
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),
: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(: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, %{})
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
socket
|> append_output.(translated("AI Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def reset_ai_prompt(socket, reload, append_output) do
case put_global_setting("ai.system_prompt", "") do
:ok ->
socket
|> assign(:settings_editor_ai_draft, %{})
|> reload.(socket.assigns.workbench)
{:error, reason} ->
socket
|> append_output.(translated("AI Settings"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def save_publishing(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
@@ -150,6 +244,54 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
end
end
def reset_categories(socket, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
result =
Enum.reduce_while(category_names(project_metadata(socket.assigns)), :ok, fn category, _acc ->
if MapSet.member?(@protected_categories, category) do
{:cont, :ok}
else
case Metadata.remove_category(project_id, category) do
{:ok, _metadata} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end
end)
with :ok <- result,
:ok <- ensure_default_categories(project_id),
:ok <- reset_default_category_settings(project_id) do
socket
|> assign(:settings_editor_new_category, "")
|> reload.(socket.assigns.workbench)
else
{:error, reason} ->
socket
|> append_output.(translated("Categories"), inspect(reason), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def toggle_mcp_agent(socket, agent, reload, append_output) do
case find_mcp_agent(agent) do
%{id: agent_id, supported?: true} = config ->
if mcp_configured?(config) do
{:ok, _payload} = AgentConfig.remove_from_config(agent_id)
reload.(socket, socket.assigns.workbench)
else
install_root = Application.app_dir(:bds)
{:ok, _payload} = AgentConfig.add_to_config(agent_id, install_root: install_root)
reload.(socket, socket.assigns.workbench)
end
_other ->
socket
|> append_output.(translated("MCP"), translated("This MCP agent is not supported in the rewrite yet"), nil, "error")
|> reload.(socket.assigns.workbench)
end
end
def save_category(socket, params, reload, append_output) do
project_id = socket.assigns.projects.active_project_id
category = Map.get(params, "category", "")
@@ -221,18 +363,37 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
def build_settings(assigns) 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, %{}))
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)
visible_sections = visible_settings_sections(query)
%{
search_query: query,
selected_section: selected_section,
active_sections: visible_sections,
project: project_form,
editor: editor_form,
categories: category_rows(metadata),
ai: ai_form,
technology: technology_form(project_form),
publishing: publishing_form,
mcp: mcp_rows(),
new_category: Map.get(assigns, :settings_editor_new_category, ""),
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(),
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)),
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)),
data_visible?: section_matches?(query, ~w(data rebuild maintenance folder filesystem)),
supported_languages: @supported_languages,
protected_categories: @protected_categories
@@ -281,6 +442,17 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
}
end
defp editor_attrs(assigns) do
draft = Map.get(assigns, :settings_editor_editor_draft, %{})
%{
default_mode: Map.get(draft, "default_mode", "markdown"),
diff_view_style: Map.get(draft, "diff_view_style", "inline"),
wrap_long_lines: truthy?(Map.get(draft, "wrap_long_lines")),
hide_unchanged_regions: truthy?(Map.get(draft, "hide_unchanged_regions"))
}
end
defp publishing_attrs(assigns) do
draft = Map.get(assigns, :settings_editor_publishing_draft, %{})
@@ -292,10 +464,26 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
}
end
defp ai_attrs(assigns) do
draft = Map.get(assigns, :settings_editor_ai_draft, %{})
%{
online_api_key: blank_to_nil(Map.get(draft, "online_api_key")),
mistral_api_key: blank_to_nil(Map.get(draft, "mistral_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")),
system_prompt: Map.get(draft, "system_prompt", "")
}
end
defp project_metadata(assigns) do
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
{:ok, metadata} -> metadata
_other -> %{}
end
end
@@ -313,6 +501,15 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
}
end
defp editor_form do
%{
"default_mode" => get_global_setting("ui.preferred_editor_mode") || "markdown",
"diff_view_style" => get_global_setting("ui.git_diff_view_style") || "inline",
"wrap_long_lines" => get_global_setting("ui.git_diff_word_wrap") == "true",
"hide_unchanged_regions" => get_global_setting("ui.git_diff_hide_unchanged_regions") == "true"
}
end
defp publishing_form(metadata) do
prefs = Map.get(metadata, :publishing_preferences, %{})
@@ -324,6 +521,30 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
}
end
defp ai_form do
{:ok, online_endpoint} = AI.get_endpoint(:online)
{:ok, mistral_endpoint} = AI.get_endpoint(:mistral)
%{
"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),
"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") || ""
}
end
defp technology_form(project_form) do
%{
"semantic_similarity_enabled" => Map.get(project_form, "semantic_similarity_enabled", false)
}
end
defp current_theme(assigns) do
assigns
|> project_metadata()
@@ -354,6 +575,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
end)
end
defp category_names(metadata), do: Map.get(metadata, :categories, [])
defp normalize_project_params(params) do
%{
"name" => Map.get(params, "name", ""),
@@ -368,6 +591,15 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
}
end
defp normalize_editor_params(params) do
%{
"default_mode" => Map.get(params, "default_mode", "markdown"),
"diff_view_style" => Map.get(params, "diff_view_style", "inline"),
"wrap_long_lines" => truthy?(Map.get(params, "wrap_long_lines")),
"hide_unchanged_regions" => truthy?(Map.get(params, "hide_unchanged_regions"))
}
end
defp normalize_publishing_params(params) do
%{
"ssh_host" => Map.get(params, "ssh_host", ""),
@@ -377,6 +609,217 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
}
end
defp normalize_ai_params(params) do
%{
"online_api_key" => Map.get(params, "online_api_key", ""),
"mistral_api_key" => Map.get(params, "mistral_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", ""),
"system_prompt" => Map.get(params, "system_prompt", "")
}
end
defp current_settings_section(assigns) do
meta = current_tab_meta(assigns)
meta
|> Map.get(:sidebar_item_id, "settings-project")
|> to_string()
|> String.replace_prefix("settings-", "")
|> case do
section when section in @settings_sections -> section
_other -> "project"
end
end
defp current_tab_meta(assigns) do
current_tab = Map.get(assigns, :current_tab)
case current_tab do
%{type: type, id: id} -> Map.get(assigns[:tab_meta] || %{}, {type, id}, %{})
_other -> %{}
end
end
defp visible_settings_sections(query) do
Enum.filter(@settings_sections, fn section ->
case section 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))
"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))
"mcp" -> section_matches?(query, ~w(mcp claude copilot gemini opencode mistral codex agent server))
end
end)
end
defp template_options(project_id) do
%{
post:
Repo.all(
from template in Template,
where: template.project_id == ^project_id and template.kind == :post,
order_by: [asc: template.title],
select: %{slug: template.slug, title: template.title}
),
list:
Repo.all(
from template in Template,
where: template.project_id == ^project_id and template.kind == :list,
order_by: [asc: template.title],
select: %{slug: template.slug, title: template.title}
)
}
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 ->
%{
id: agent.id,
label: agent.label,
supported?: agent.supported?,
configured?: mcp_configured?(agent),
config_path: mcp_config_path(agent)
}
end)
end
defp find_mcp_agent(agent) do
normalized =
agent
|> to_string()
|> String.to_existing_atom()
Enum.find(@mcp_agents, &(&1.id == normalized))
rescue
_error -> nil
end
defp mcp_configured?(%{supported?: false}), do: false
defp mcp_configured?(%{id: agent_id}) do
path = AgentConfig.config_path(agent_id, System.user_home!())
if File.exists?(path) do
path
|> File.read!()
|> Jason.decode!()
|> mcp_server_present?(agent_id)
else
false
end
rescue
_error -> false
end
defp mcp_config_path(%{supported?: false}), do: nil
defp mcp_config_path(%{id: agent_id}), do: AgentConfig.config_path(agent_id, System.user_home!())
defp mcp_server_present?(config, :github_copilot) do
config
|> Map.get("servers", %{})
|> Map.has_key?("bDS")
end
defp mcp_server_present?(config, _agent_id) do
config
|> Map.get("mcpServers", %{})
|> Map.has_key?("bDS")
end
defp get_model_preference(key) do
case AI.get_model_preference(key) do
{:ok, value} -> value || ""
_other -> ""
end
end
defp maybe_put_model_preference(_key, nil), 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_endpoint(kind, nil, model) do
case model do
nil -> :ok
"" -> :ok
_other -> AI.put_endpoint(kind, %{model: 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()
end
defp normalize_endpoint_result({:ok, _endpoint}), do: :ok
defp normalize_endpoint_result({:error, reason}), do: {:error, reason}
defp ensure_default_categories(project_id) do
Enum.reduce_while(Map.keys(@default_category_settings), :ok, fn category, _acc ->
case Metadata.add_category(project_id, category) do
{:ok, _metadata} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp reset_default_category_settings(project_id) do
Enum.reduce_while(@default_category_settings, :ok, fn {category, settings}, _acc ->
case Metadata.update_category_settings(project_id, category, settings) do
{:ok, _metadata} -> {:cont, :ok}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
defp get_global_setting(key) do
case Repo.get(Setting, key) do
%Setting{value: value} -> value
_other -> nil
end
end
defp put_global_setting(key, value) do
setting = Repo.get(Setting, key) || %Setting{}
setting
|> Setting.changeset(%{key: key, value: to_string(value || ""), updated_at: Persistence.now_ms()})
|> Repo.insert_or_update()
|> case do
{:ok, _setting} -> :ok
{:error, reason} -> {:error, reason}
end
end
defp section_matches?("", _keywords), do: true
defp section_matches?(query, keywords), do: Enum.any?(keywords, &String.contains?(&1, String.downcase(query)))
@@ -390,6 +833,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
end
defp truthy?(value), do: value in [true, "true", "on", "1", 1]
defp boolean_string(true), do: "true"
defp boolean_string(false), do: "false"
defp parse_integer(nil, fallback), do: fallback
defp parse_integer(value, _fallback) when is_integer(value), do: value
defp parse_integer(value, fallback) do
@@ -406,4 +851,4 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
trimmed -> trimmed
end
end
end
end