From 9f17954ce388fc6a132bb951f532a18da21449a1 Mon Sep 17 00:00:00 2001 From: Chili Palmer Date: Sun, 3 May 2026 12:42:03 +0200 Subject: [PATCH] overlay for post suggestions uses AI now --- lib/bds/ai/one_shot.ex | 5 +- lib/bds/desktop/overlay.ex | 23 +- lib/bds/desktop/shell_live.ex | 121 ++++++++- .../desktop/shell_live/overlay_components.ex | 41 ++- .../overlay_html/shell_overlay.html.heex | 7 +- priv/ui/app.css | 9 + test/bds/ai_test.exs | 48 ++++ test/bds/desktop/overlay_test.exs | 47 ++++ test/bds/desktop/shell_live_test.exs | 244 ++++++++++++++++++ 9 files changed, 515 insertions(+), 30 deletions(-) diff --git a/lib/bds/ai/one_shot.ex b/lib/bds/ai/one_shot.ex index 8867e4c..7f0768a 100644 --- a/lib/bds/ai/one_shot.ex +++ b/lib/bds/ai/one_shot.ex @@ -6,6 +6,7 @@ defmodule BDS.AI.OneShot do alias BDS.AI.Runtime alias BDS.Media.Media alias BDS.MapUtils + alias BDS.Posts alias BDS.Posts.Post alias BDS.Repo @@ -202,7 +203,7 @@ defmodule BDS.AI.OneShot do end defp one_shot_system_prompt(:analyze_post) do - "Return JSON with keys title, excerpt, and slug." + "Return JSON with keys title, excerpt, and slug. Each value must be a single string (not an array or object)." end defp one_shot_system_prompt(:translate_post) do @@ -280,7 +281,7 @@ defmodule BDS.AI.OneShot do defp extract_json_response(_response), do: {:error, %{kind: :invalid_json_response}} defp normalize_post_input(%Post{} = post) do - {:ok, %{title: post.title || "", excerpt: post.excerpt || "", content: post.content || ""}} + {:ok, %{title: post.title || "", excerpt: post.excerpt || "", content: Posts.editor_body(post)}} end defp normalize_post_input(post_id) when is_binary(post_id) do diff --git a/lib/bds/desktop/overlay.ex b/lib/bds/desktop/overlay.ex index 7632bdd..15917ef 100644 --- a/lib/bds/desktop/overlay.ex +++ b/lib/bds/desktop/overlay.ex @@ -228,6 +228,26 @@ defmodule BDS.Desktop.Overlay do } end + def set_ai_suggestions(%{kind: :ai_suggestions} = overlay, suggestions) do + fields = + Enum.map(overlay.fields, fn field -> + case Map.get(suggestions, field.key) do + nil -> + field + + value when is_binary(value) and value != "" -> + %{field | suggested_value: value, loading: false} + + _other -> + field + end + end) + + %{overlay | fields: fields} + end + + def set_ai_suggestions(overlay, _suggestions), do: overlay + defp normalize_ai_fields(fields) do Enum.map(fields, fn field -> %{ @@ -236,7 +256,8 @@ defmodule BDS.Desktop.Overlay do current_value: Map.get(field, :current_value, ""), suggested_value: Map.get(field, :suggested_value, ""), accepted: not Map.get(field, :locked, false), - locked: Map.get(field, :locked, false) + locked: Map.get(field, :locked, false), + loading: Map.get(field, :loading, false) } end) end diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index c85e4dd..33c3d21 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -717,7 +717,27 @@ defmodule BDS.Desktop.ShellLive do ) end - {:noreply, assign(socket, :shell_overlay, overlay)} + socket = assign(socket, :shell_overlay, overlay) + + socket = + if kind == "ai_suggestions" and not is_nil(overlay) do + if socket.assigns.offline_mode do + socket + |> assign(:shell_overlay, nil) + |> append_output_entry( + translated("AI Suggestions"), + translated("Automatic AI actions stay gated by airplane mode."), + nil, + "info" + ) + else + spawn_ai_suggestions_task(socket) + end + else + socket + end + + {:noreply, socket} end def handle_event("close_overlay", _params, socket) do @@ -1222,6 +1242,68 @@ defmodule BDS.Desktop.ShellLive do {:noreply, socket} end + def handle_info({:ai_suggestions_result, type, id, result}, socket) do + socket = + case socket.assigns[:shell_overlay] do + %{kind: :ai_suggestions} = overlay -> + current_tab = socket.assigns.current_tab + + if current_tab && current_tab.type == type && current_tab.id == id do + suggestions = + case type do + :post -> + %{ + "title" => result.title, + "excerpt" => result.excerpt, + "slug" => result.slug + } + + :media -> + %{ + "title" => result.title, + "alt" => result.alt, + "caption" => result.caption + } + end + + assign(socket, :shell_overlay, Overlay.set_ai_suggestions(overlay, suggestions)) + else + socket + end + + _other -> + socket + end + + {:noreply, socket} + end + + def handle_info({:ai_suggestions_error, type, id, reason}, socket) do + socket = + case socket.assigns[:shell_overlay] do + %{kind: :ai_suggestions} -> + current_tab = socket.assigns.current_tab + + if current_tab && current_tab.type == type && current_tab.id == id do + socket + |> assign(:shell_overlay, nil) + |> append_output_entry( + translated("AI Suggestions"), + inspect(reason), + nil, + "error" + ) + else + socket + end + + _other -> + socket + end + + {:noreply, socket} + end + def handle_info(:reload_shell, socket) do {:noreply, reload_shell(socket, socket.assigns.workbench)} end @@ -1564,6 +1646,43 @@ defmodule BDS.Desktop.ShellLive do defp shell_command_atom(action), do: ShellCommandRunner.shell_command_atom(action) + defp spawn_ai_suggestions_task(socket) do + current_tab = socket.assigns.current_tab + + case current_tab do + %{type: :post, id: post_id} -> + parent = self() + + Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn -> + case AI.analyze_post(post_id) do + {:ok, result} -> + send(parent, {:ai_suggestions_result, :post, post_id, result}) + + {:error, reason} -> + send(parent, {:ai_suggestions_error, :post, post_id, reason}) + end + end) + + %{type: :media, id: media_id} -> + parent = self() + + Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn -> + case AI.analyze_image(media_id) do + {:ok, result} -> + send(parent, {:ai_suggestions_result, :media, media_id, result}) + + {:error, reason} -> + send(parent, {:ai_suggestions_error, :media, media_id, reason}) + end + end) + + _other -> + :ok + end + + socket + end + defp mac_ui? do case Application.get_env(:bds, :shell_platform) do nil -> match?({:unix, :darwin}, :os.type()) diff --git a/lib/bds/desktop/shell_live/overlay_components.ex b/lib/bds/desktop/shell_live/overlay_components.ex index e4f7f71..3a97b07 100644 --- a/lib/bds/desktop/shell_live/overlay_components.ex +++ b/lib/bds/desktop/shell_live/overlay_components.ex @@ -219,22 +219,25 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: post.title || title, - suggested_value: refine_title(post.title || title), - locked: false + suggested_value: "", + locked: false, + loading: true }, %{ key: "excerpt", label: ShellData.translate("Excerpt", %{}, page_language), current_value: post.excerpt || subtitle, - suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle), - locked: false + suggested_value: "", + locked: false, + loading: true }, %{ key: "slug", label: ShellData.translate("Slug", %{}, page_language), current_value: post.slug || slugify(post.title || title), - suggested_value: refine_slug(post.slug || slugify(post.title || title)), - locked: post.status == :published + suggested_value: "", + locked: post.status == :published, + loading: true } ] @@ -253,22 +256,25 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do key: "title", label: ShellData.translate("Title", %{}, page_language), current_value: media.title || title, - suggested_value: refine_title(media.title || title), - locked: false + suggested_value: "", + locked: false, + loading: true }, %{ key: "alt", label: ShellData.translate("Alt Text", %{}, page_language), current_value: media.alt || "", - suggested_value: media.alt || title, - locked: false + suggested_value: "", + locked: false, + loading: true }, %{ key: "caption", label: ShellData.translate("Caption", %{}, page_language), current_value: media.caption || "", - suggested_value: refine_excerpt(title, media.caption || title), - locked: false + suggested_value: "", + locked: false, + loading: true } ] @@ -381,17 +387,6 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do defp pad2(value), do: value |> Integer.to_string() |> String.pad_leading(2, "0") - defp refine_title(nil), do: "" - defp refine_title(title), do: String.trim(title <> " Notes") - - defp refine_excerpt(title, excerpt) do - base = excerpt |> to_string() |> String.trim() - if base == "", do: "#{title} overview", else: base <> "." - end - - defp refine_slug(slug), - do: slug |> to_string() |> String.trim_trailing("-") |> Kernel.<>("-updated") - defp slugify(value) do value |> to_string() diff --git a/lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex b/lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex index 17e957e..dc90194 100644 --- a/lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex +++ b/lib/bds/desktop/shell_live/overlay_html/shell_overlay.html.heex @@ -16,7 +16,7 @@ @@ -24,8 +24,9 @@
<%= field.label %>
-
<%= field.current_value %>
-
<%= field.suggested_value %>
+
+ <%= if field.loading, do: "Loading…", else: field.suggested_value %> +
<% end %> diff --git a/priv/ui/app.css b/priv/ui/app.css index cb1b19a..654d199 100644 --- a/priv/ui/app.css +++ b/priv/ui/app.css @@ -2496,6 +2496,15 @@ button svg * { color: #9d9d9d; } +.ai-suggestion-value { + min-height: 1.4em; +} + +.ai-suggestion-value.loading { + color: var(--accent-color); + font-style: italic; +} + .ai-suggestions-modal-footer, .confirm-delete-modal-footer, .confirm-dialog-actions { diff --git a/test/bds/ai_test.exs b/test/bds/ai_test.exs index acc5f74..f6e54ec 100644 --- a/test/bds/ai_test.exs +++ b/test/bds/ai_test.exs @@ -150,6 +150,17 @@ defmodule BDS.AITest do usage: usage(22, 14, 0, 0) }} + :analyze_post -> + {:ok, + %{ + json: %{ + "title" => "Analyzed " <> (get_in(request.messages, [Access.at(1), "content"]) || ""), + "excerpt" => "Analyzed excerpt", + "slug" => "analyzed-slug" + }, + usage: usage(15, 10, 0, 0) + }} + :analyze_image -> {:ok, %{ @@ -478,6 +489,43 @@ defmodule BDS.AITest do assert request.model == "gpt-4.1-mini" end + test "analyze_post uses editor_body so published posts include filesystem content" do + assert {:ok, _endpoint} = + BDS.AI.put_endpoint( + :online, + %{ + url: "https://api.example.test/v1", + api_key: "online-secret", + model: "gpt-4o-mini" + }, + secret_backend: FakeSecretBackend + ) + + assert :ok = BDS.AI.set_airplane_mode(false) + assert :ok = BDS.AI.put_model_preference(:title, "gpt-4.1-mini") + + assert {:ok, result} = + BDS.AI.analyze_post( + %{ + title: "Draft Post", + excerpt: "Short summary", + content: "# Draft body" + }, + runtime: FakeRuntime, + test_pid: self(), + secret_backend: FakeSecretBackend + ) + + assert result.title =~ "Draft Post" + assert result.excerpt == "Analyzed excerpt" + assert result.slug == "analyzed-slug" + + assert_received {:runtime_request, _endpoint, request} + assert request.operation == :analyze_post + message = get_in(request.messages, [Access.at(1), "content"]) || "" + assert message =~ "# Draft body" + end + test "analyze_import_taxonomy uses the selected model override and returns only valid existing-term mappings" do assert {:ok, _endpoint} = BDS.AI.put_endpoint( diff --git a/test/bds/desktop/overlay_test.exs b/test/bds/desktop/overlay_test.exs index 786e122..15a9839 100644 --- a/test/bds/desktop/overlay_test.exs +++ b/test/bds/desktop/overlay_test.exs @@ -71,6 +71,53 @@ defmodule BDS.Desktop.OverlayTest do assert confirm_dialog.message =~ "Cannot be undone" end + test "ai suggestions overlay starts in loading state and updates from async results" do + context = put_in(sample_context(), [:ai_fields, Access.all(), :loading], true) + context = put_in(context, [:ai_fields, Access.all(), :suggested_value], "") + + ai_modal = Overlay.open(:post, :ai_suggestions, context) + + assert Enum.all?(ai_modal.fields, & &1.loading) + assert Enum.all?(ai_modal.fields, &(&1.suggested_value == "")) + + updated = + Overlay.set_ai_suggestions(ai_modal, %{"title" => "Better Title", "alt" => "Better Alt"}) + + title_field = Enum.find(updated.fields, &(&1.key == "title")) + assert title_field.suggested_value == "Better Title" + refute title_field.loading + + alt_field = Enum.find(updated.fields, &(&1.key == "alt")) + assert alt_field.suggested_value == "Better Alt" + refute alt_field.loading + + caption_field = Enum.find(updated.fields, &(&1.key == "caption")) + assert caption_field.suggested_value == "" + assert caption_field.loading + end + + test "set_ai_suggestions ignores non-string or empty values" do + context = sample_context() + ai_modal = Overlay.open(:post, :ai_suggestions, context) + + updated = + Overlay.set_ai_suggestions(ai_modal, %{ + "title" => "Valid", + "alt" => "", + "caption" => nil, + "extra" => ["array"] + }) + + title_field = Enum.find(updated.fields, &(&1.key == "title")) + assert title_field.suggested_value == "Valid" + + alt_field = Enum.find(updated.fields, &(&1.key == "alt")) + assert alt_field.suggested_value == "Street scene at dusk" + + caption_field = Enum.find(updated.fields, &(&1.key == "caption")) + assert caption_field.suggested_value == "A busy corner at dusk" + end + defp sample_context do %{ current_tab: %{type: :post, id: "post-1", title: "Trip Notes", subtitle: "Draft"}, diff --git a/test/bds/desktop/shell_live_test.exs b/test/bds/desktop/shell_live_test.exs index a0e02ca..22d6e2c 100644 --- a/test/bds/desktop/shell_live_test.exs +++ b/test/bds/desktop/shell_live_test.exs @@ -126,6 +126,49 @@ defmodule BDS.Desktop.ShellLiveTest do end end + defmodule AiSuggestionsServer do + use Plug.Router + import Phoenix.ConnTest, except: [post: 2] + + plug(:match) + plug(:dispatch) + + post "/v1/chat/completions" do + {:ok, request_body, conn} = Plug.Conn.read_body(conn) + request = Jason.decode!(request_body) + send(Application.fetch_env!(:bds, :test_pid), {:ai_suggestions_request, request}) + + operation = get_in(request, ["messages", Access.at(0), "content"]) || "" + + content = + cond do + String.contains?(operation, "image") -> + Jason.encode!(%{ + "title" => "AI Image Title", + "alt" => "AI Alt Text", + "caption" => "AI Caption" + }) + + true -> + Jason.encode!(%{ + "title" => "AI Suggested Title", + "excerpt" => "AI Suggested Excerpt", + "slug" => "ai-suggested-slug" + }) + end + + body = + Jason.encode!(%{ + "choices" => [%{"message" => %{"content" => content}}], + "usage" => %{"prompt_tokens" => 20, "completion_tokens" => 10} + }) + + conn + |> Plug.Conn.put_resp_content_type("application/json") + |> send_resp(200, body) + end + end + @endpoint BDS.Desktop.Endpoint setup do @@ -2270,6 +2313,207 @@ defmodule BDS.Desktop.ShellLiveTest do refute html =~ ~s(phx-value-mode="visual") end + test "ai suggestions overlay is gated by offline mode for posts", %{project: project} do + assert :ok = AI.set_airplane_mode(true) + + {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Offline Post", + content: "Some content" + }) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + html = + render_click(view, "pin_sidebar_item", %{ + "route" => "post", + "id" => post.id, + "title" => post.title, + "subtitle" => "draft" + }) + + assert html =~ ~s(data-testid="post-editor") + + html = + view + |> element("[data-testid='post-editor'] .quick-actions-btn") + |> render_click() + + assert html =~ "quick-actions-menu" + + html = + view + |> element("[phx-click='open_overlay'][phx-value-kind='ai_suggestions']") + |> render_click() + + refute html =~ "ai-suggestions-modal" + assert html =~ "Automatic AI actions stay gated by airplane mode" + end + + test "ai suggestions overlay fetches async results for posts when online", %{project: project} do + Application.put_env(:bds, :test_pid, self()) + + server = + start_supervised!({Bandit, plug: AiSuggestionsServer, port: 0, startup_log: false}) + + {:ok, {_address, port}} = ThousandIsland.listener_info(server) + + assert :ok = AI.set_airplane_mode(false) + + assert {:ok, _endpoint} = + AI.put_endpoint(:online, %{ + url: "http://127.0.0.1:#{port}/v1", + api_key: "test-secret", + model: "gpt-test" + }) + + assert :ok = AI.put_model_preference(:title, "gpt-test") + + {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Online Post", + content: "Some content for AI analysis" + }) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + html = + render_click(view, "pin_sidebar_item", %{ + "route" => "post", + "id" => post.id, + "title" => post.title, + "subtitle" => "draft" + }) + + assert html =~ ~s(data-testid="post-editor") + + html = + view + |> element("[data-testid='post-editor'] .quick-actions-btn") + |> render_click() + + assert html =~ "quick-actions-menu" + + html = + view + |> element("[phx-click='open_overlay'][phx-value-kind='ai_suggestions']") + |> render_click() + + assert html =~ "ai-suggestions-modal" + assert html =~ "Loading" + + assert_receive {:ai_suggestions_request, _request}, 2_000 + + Process.sleep(200) + html = render(view) + + assert html =~ "AI Suggested Title" + assert html =~ "AI Suggested Excerpt" + assert html =~ "ai-suggested-slug" + refute html =~ "Loading" + end + + test "ai suggestions overlay is gated by offline mode for media", %{project: project} do + assert :ok = AI.set_airplane_mode(true) + + temp_dir = + Path.join(System.tmp_dir!(), "bds-shell-live-#{System.unique_integer([:positive])}") + + File.mkdir_p!(temp_dir) + media_source_path = Path.join(temp_dir, "offline-media.jpg") + File.write!(media_source_path, "fake image body") + + {:ok, media} = + Media.import_media(%{ + project_id: project.id, + source_path: media_source_path, + title: "Offline Media" + }) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + html = + render_click(view, "pin_sidebar_item", %{ + "route" => "media", + "id" => media.id, + "title" => media.title, + "subtitle" => "draft" + }) + + assert html =~ ~s(data-testid="media-editor") + + html = render_click(view, "toggle_media_editor_quick_actions", %{"id" => media.id}) + assert html =~ "quick-actions-menu" + + html = + view + |> element("[phx-click='open_overlay'][phx-value-kind='ai_suggestions']") + |> render_click() + + refute html =~ "ai-suggestions-modal" + assert html =~ "Automatic AI actions stay gated by airplane mode" + end + + test "ai suggestions async error closes overlay and shows toast", %{project: project} do + Application.put_env(:bds, :test_pid, self()) + + server = + start_supervised!({Bandit, plug: AiSuggestionsServer, port: 0, startup_log: false}) + + {:ok, {_address, port}} = ThousandIsland.listener_info(server) + + assert :ok = AI.set_airplane_mode(false) + + assert {:ok, _endpoint} = + AI.put_endpoint(:online, %{ + url: "http://127.0.0.1:#{port}/v1", + api_key: "test-secret", + model: "gpt-test" + }) + + assert :ok = AI.put_model_preference(:title, "gpt-test") + + {:ok, post} = + Posts.create_post(%{ + project_id: project.id, + title: "Error Post", + content: "Some content" + }) + + {:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive) + + html = + render_click(view, "pin_sidebar_item", %{ + "route" => "post", + "id" => post.id, + "title" => post.title, + "subtitle" => "draft" + }) + + assert html =~ ~s(data-testid="post-editor") + + html = + view + |> element("[data-testid='post-editor'] .quick-actions-btn") + |> render_click() + + assert html =~ "quick-actions-menu" + + html = + view + |> element("[phx-click='open_overlay'][phx-value-kind='ai_suggestions']") + |> render_click() + + assert html =~ "ai-suggestions-modal" + + send(view.pid, {:ai_suggestions_error, :post, post.id, :test_error}) + html = render(view) + + refute html =~ "ai-suggestions-modal" + end + test "script and template editors surface lifecycle state, load published file content, and allow publishing drafts", %{project: project} do {:ok, draft_script} =