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.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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 %>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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} =
|
||||||
|
|||||||
Reference in New Issue
Block a user