overlay for post suggestions uses AI now

This commit is contained in:
2026-05-03 12:42:03 +02:00
parent b9797809aa
commit 9f17954ce3
9 changed files with 515 additions and 30 deletions

View File

@@ -6,6 +6,7 @@ defmodule BDS.AI.OneShot do
alias BDS.AI.Runtime alias BDS.AI.Runtime
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.MapUtils alias BDS.MapUtils
alias BDS.Posts
alias BDS.Posts.Post alias BDS.Posts.Post
alias BDS.Repo alias BDS.Repo
@@ -202,7 +203,7 @@ defmodule BDS.AI.OneShot do
end end
defp one_shot_system_prompt(:analyze_post) do 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 end
defp one_shot_system_prompt(:translate_post) do 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 extract_json_response(_response), do: {:error, %{kind: :invalid_json_response}}
defp normalize_post_input(%Post{} = post) do 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 end
defp normalize_post_input(post_id) when is_binary(post_id) do defp normalize_post_input(post_id) when is_binary(post_id) do

View File

@@ -228,6 +228,26 @@ defmodule BDS.Desktop.Overlay do
} }
end 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 defp normalize_ai_fields(fields) do
Enum.map(fields, fn field -> Enum.map(fields, fn field ->
%{ %{
@@ -236,7 +256,8 @@ defmodule BDS.Desktop.Overlay do
current_value: Map.get(field, :current_value, ""), current_value: Map.get(field, :current_value, ""),
suggested_value: Map.get(field, :suggested_value, ""), suggested_value: Map.get(field, :suggested_value, ""),
accepted: not Map.get(field, :locked, false), 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)
end end

View File

@@ -717,7 +717,27 @@ defmodule BDS.Desktop.ShellLive do
) )
end 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 end
def handle_event("close_overlay", _params, socket) do def handle_event("close_overlay", _params, socket) do
@@ -1222,6 +1242,68 @@ defmodule BDS.Desktop.ShellLive do
{:noreply, socket} {:noreply, socket}
end 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 def handle_info(:reload_shell, socket) do
{:noreply, reload_shell(socket, socket.assigns.workbench)} {:noreply, reload_shell(socket, socket.assigns.workbench)}
end end
@@ -1564,6 +1646,43 @@ defmodule BDS.Desktop.ShellLive do
defp shell_command_atom(action), do: ShellCommandRunner.shell_command_atom(action) 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 defp mac_ui? do
case Application.get_env(:bds, :shell_platform) do case Application.get_env(:bds, :shell_platform) do
nil -> match?({:unix, :darwin}, :os.type()) nil -> match?({:unix, :darwin}, :os.type())

View File

@@ -219,22 +219,25 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
key: "title", key: "title",
label: ShellData.translate("Title", %{}, page_language), label: ShellData.translate("Title", %{}, page_language),
current_value: post.title || title, current_value: post.title || title,
suggested_value: refine_title(post.title || title), suggested_value: "",
locked: false locked: false,
loading: true
}, },
%{ %{
key: "excerpt", key: "excerpt",
label: ShellData.translate("Excerpt", %{}, page_language), label: ShellData.translate("Excerpt", %{}, page_language),
current_value: post.excerpt || subtitle, current_value: post.excerpt || subtitle,
suggested_value: refine_excerpt(post.title || title, post.excerpt || subtitle), suggested_value: "",
locked: false locked: false,
loading: true
}, },
%{ %{
key: "slug", key: "slug",
label: ShellData.translate("Slug", %{}, page_language), label: ShellData.translate("Slug", %{}, page_language),
current_value: post.slug || slugify(post.title || title), current_value: post.slug || slugify(post.title || title),
suggested_value: refine_slug(post.slug || slugify(post.title || title)), suggested_value: "",
locked: post.status == :published locked: post.status == :published,
loading: true
} }
] ]
@@ -253,22 +256,25 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
key: "title", key: "title",
label: ShellData.translate("Title", %{}, page_language), label: ShellData.translate("Title", %{}, page_language),
current_value: media.title || title, current_value: media.title || title,
suggested_value: refine_title(media.title || title), suggested_value: "",
locked: false locked: false,
loading: true
}, },
%{ %{
key: "alt", key: "alt",
label: ShellData.translate("Alt Text", %{}, page_language), label: ShellData.translate("Alt Text", %{}, page_language),
current_value: media.alt || "", current_value: media.alt || "",
suggested_value: media.alt || title, suggested_value: "",
locked: false locked: false,
loading: true
}, },
%{ %{
key: "caption", key: "caption",
label: ShellData.translate("Caption", %{}, page_language), label: ShellData.translate("Caption", %{}, page_language),
current_value: media.caption || "", current_value: media.caption || "",
suggested_value: refine_excerpt(title, media.caption || title), suggested_value: "",
locked: false 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 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 defp slugify(value) do
value value
|> to_string() |> to_string()

View File

@@ -16,7 +16,7 @@
<input <input
type="checkbox" type="checkbox"
checked={field.accepted} checked={field.accepted}
disabled={field.locked} disabled={field.locked or field.loading}
phx-click="overlay_toggle_ai_field" phx-click="overlay_toggle_ai_field"
phx-value-key={field.key} phx-value-key={field.key}
/> />
@@ -24,8 +24,9 @@
</label> </label>
<div class="ai-suggestion-content"> <div class="ai-suggestion-content">
<div class="ai-suggestion-label"><%= field.label %></div> <div class="ai-suggestion-label"><%= field.label %></div>
<div class="ai-suggestion-current"><%= field.current_value %></div> <div class={["ai-suggestion-value", if(field.loading, do: "loading")]}>
<div class="ai-suggestion-value"><%= field.suggested_value %></div> <%= if field.loading, do: "Loading…", else: field.suggested_value %>
</div>
</div> </div>
</div> </div>
<% end %> <% end %>

View File

@@ -2496,6 +2496,15 @@ button svg * {
color: #9d9d9d; color: #9d9d9d;
} }
.ai-suggestion-value {
min-height: 1.4em;
}
.ai-suggestion-value.loading {
color: var(--accent-color);
font-style: italic;
}
.ai-suggestions-modal-footer, .ai-suggestions-modal-footer,
.confirm-delete-modal-footer, .confirm-delete-modal-footer,
.confirm-dialog-actions { .confirm-dialog-actions {

View File

@@ -150,6 +150,17 @@ defmodule BDS.AITest do
usage: usage(22, 14, 0, 0) 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 -> :analyze_image ->
{:ok, {:ok,
%{ %{
@@ -478,6 +489,43 @@ defmodule BDS.AITest do
assert request.model == "gpt-4.1-mini" assert request.model == "gpt-4.1-mini"
end 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 test "analyze_import_taxonomy uses the selected model override and returns only valid existing-term mappings" do
assert {:ok, _endpoint} = assert {:ok, _endpoint} =
BDS.AI.put_endpoint( BDS.AI.put_endpoint(

View File

@@ -71,6 +71,53 @@ defmodule BDS.Desktop.OverlayTest do
assert confirm_dialog.message =~ "Cannot be undone" assert confirm_dialog.message =~ "Cannot be undone"
end 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 defp sample_context do
%{ %{
current_tab: %{type: :post, id: "post-1", title: "Trip Notes", subtitle: "Draft"}, current_tab: %{type: :post, id: "post-1", title: "Trip Notes", subtitle: "Draft"},

View File

@@ -126,6 +126,49 @@ defmodule BDS.Desktop.ShellLiveTest do
end end
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 @endpoint BDS.Desktop.Endpoint
setup do setup do
@@ -2270,6 +2313,207 @@ defmodule BDS.Desktop.ShellLiveTest do
refute html =~ ~s(phx-value-mode="visual") refute html =~ ~s(phx-value-mode="visual")
end 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", test "script and template editors surface lifecycle state, load published file content, and allow publishing drafts",
%{project: project} do %{project: project} do
{:ok, draft_script} = {:ok, draft_script} =