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} =