overlay for post suggestions uses AI now
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 %>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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} =
|
||||
|
||||
Reference in New Issue
Block a user