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.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

View File

@@ -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

View File

@@ -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())

View File

@@ -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()

View File

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

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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"},

View File

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