Compare commits

..

4 Commits

20 changed files with 507 additions and 37 deletions

View File

@@ -127,10 +127,20 @@ within the configured budget instead of hanging; dialyzer clean.
--- ---
### TD-03: Fix `BDS.AI.InFlight` ETS ownership and creation race ### TD-03: Fix `BDS.AI.InFlight` ETS ownership and creation race ✅ DONE (2026-06-11)
**Severity: High (correctness).** **Severity: High (correctness).**
**Status: implemented.** `BDS.AI.InFlight` is now a minimal GenServer whose
`init/1` creates the named table (`:named_table, :public, :set,
read_concurrency: true`); it is supervised in `BDS.Application` (before
anything that uses chat), so the table lives for the VM's lifetime and the
concurrent-first-use race is impossible by construction. The lazy `table/0`
creation path is deleted; `register/unregister/lookup` reference the named
table directly. `test/bds/ai/in_flight_test.exs` proves registrations survive
the death of the registering process and that the supervised process owns the
table.
**Context.** `lib/bds/ai/in_flight.ex` creates its named ETS table lazily in **Context.** `lib/bds/ai/in_flight.ex` creates its named ETS table lazily in
whichever process first calls `table/0`. Two defects: (1) the table is owned whichever process first calls `table/0`. Two defects: (1) the table is owned
by that first caller — typically a transient LiveView or chat task — so when by that first caller — typically a transient LiveView or chat task — so when
@@ -158,10 +168,23 @@ concurrent-first-use race is impossible by construction.
--- ---
### TD-04: Flush embedding indexes on shutdown (or delete the dead `flush_all`) ### TD-04: Flush embedding indexes on shutdown (or delete the dead `flush_all`) ✅ DONE (2026-06-11)
**Severity: Medium (perf/contract), High confidence.** **Severity: Medium (perf/contract), High confidence.**
**Status: implemented.** `Shutdown.persist_safely/0` now calls
`BDS.Embeddings.Index.flush_all()` next to `MainWindow.persist_now()`; each
persist step is hardened individually (own rescue/catch) so one failure never
blocks quit or skips the other step. `terminate/2` stays as defense-in-depth
for supervised restarts. A test proves a debounced (unsaved) index reaches
disk through the real shutdown path before the hard quit fires. The
`terminate/2` audit found no other graceful-shutdown dependency:
`job_runner.ex` only detaches in-memory state (moot under SIGKILL),
`automation.ex` is the test-automation harness whose ports die with the VM,
and `main_window.ex` bounds persistence was already covered by
`MainWindow.persist_now()` in the shutdown path. The code now matches the
spec's DebouncedPersistence invariant (`specs/embedding.allium:216`).
**Context.** App shutdown SIGKILLs the BEAM (`BDS.Desktop.Shutdown.quit/0` **Context.** App shutdown SIGKILLs the BEAM (`BDS.Desktop.Shutdown.quit/0`
a documented and legitimate workaround for a wxWidgets static-destructor a documented and legitimate workaround for a wxWidgets static-destructor
segfault on macOS). Consequence: **no `terminate/2` callback in the whole app segfault on macOS). Consequence: **no `terminate/2` callback in the whole app

View File

@@ -111,6 +111,19 @@ defmodule BDS.AI do
end end
end end
@doc """
True when the airplane (local) endpoint has both a URL and a model
configured, so gated AI features can run against the local model.
"""
@spec airplane_endpoint_configured?() :: boolean()
def airplane_endpoint_configured? do
present_setting?(get_setting("ai.airplane.url")) and
present_setting?(get_setting("ai.airplane.model"))
end
defp present_setting?(value) when is_binary(value), do: String.trim(value) != ""
defp present_setting?(_value), do: false
@spec put_model_preference(atom(), String.t()) :: @spec put_model_preference(atom(), String.t()) ::
:ok | {:error, :unknown_model_preference | term()} :ok | {:error, :unknown_model_preference | term()}
def put_model_preference(key, model) when is_atom(key) and is_binary(model) do def put_model_preference(key, model) when is_atom(key) and is_binary(model) do

View File

@@ -1,29 +1,38 @@
defmodule BDS.AI.InFlight do defmodule BDS.AI.InFlight do
@moduledoc false @moduledoc false
# Registry of in-flight chat tasks keyed by conversation id. The named ETS
# table is owned by this supervised GenServer (started from the application
# supervision tree), so registrations survive the exit of the registering
# process and there is no creation race between concurrent first callers.
use GenServer
@table :bds_ai_in_flight @table :bds_ai_in_flight
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@impl true
def init(_opts) do
table = :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
{:ok, table}
end
def register(conversation_id, pid) when is_binary(conversation_id) and is_pid(pid) do def register(conversation_id, pid) when is_binary(conversation_id) and is_pid(pid) do
:ets.insert(table(), {conversation_id, pid}) :ets.insert(@table, {conversation_id, pid})
:ok :ok
end end
def unregister(conversation_id) when is_binary(conversation_id) do def unregister(conversation_id) when is_binary(conversation_id) do
:ets.delete(table(), conversation_id) :ets.delete(@table, conversation_id)
:ok :ok
end end
def lookup(conversation_id) when is_binary(conversation_id) do def lookup(conversation_id) when is_binary(conversation_id) do
case :ets.lookup(table(), conversation_id) do case :ets.lookup(@table, conversation_id) do
[{^conversation_id, pid}] -> pid [{^conversation_id, pid}] -> pid
_other -> nil _other -> nil
end end
end end
defp table do
case :ets.whereis(@table) do
:undefined -> :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
table -> table
end
end
end end

View File

@@ -0,0 +1,42 @@
defmodule BDS.AI.JsonContent do
@moduledoc """
Decodes JSON object payloads from model responses, tolerating the markdown
code fences and surrounding prose that smaller (local) models often emit
instead of bare JSON.
"""
@fence_pattern ~r/```(?:json)?\s*\n?(.*?)```/is
@spec decode(term()) :: map() | nil
def decode(content) when is_binary(content) do
decode_strict(content) || decode_fenced(content) || decode_embedded_object(content)
end
def decode(_content), do: nil
defp decode_strict(content) do
case Jason.decode(content) do
{:ok, decoded} when is_map(decoded) -> decoded
_other -> nil
end
end
defp decode_fenced(content) do
case Regex.run(@fence_pattern, content, capture: :all_but_first) do
[inner] -> decode_strict(String.trim(inner)) || decode_embedded_object(inner)
_no_fence -> nil
end
end
defp decode_embedded_object(content) do
with {start, _length} <- :binary.match(content, "{"),
[{last, _} | _] <- content |> :binary.matches("}") |> Enum.take(-1),
true <- last > start do
content
|> binary_part(start, last - start + 1)
|> decode_strict()
else
_no_object -> nil
end
end
end

View File

@@ -4,6 +4,7 @@ defmodule BDS.AI.OneShot do
require Logger require Logger
alias BDS.AI.Chat alias BDS.AI.Chat
alias BDS.AI.JsonContent
alias BDS.AI.OpenAICompatibleRuntime alias BDS.AI.OpenAICompatibleRuntime
alias BDS.AI.Runtime alias BDS.AI.Runtime
alias BDS.Media.Media alias BDS.Media.Media
@@ -213,7 +214,9 @@ defmodule BDS.AI.OneShot do
messages: [ messages: [
%{ %{
"role" => "system", "role" => "system",
"content" => one_shot_system_prompt(operation, language, source_language) "content" =>
one_shot_system_prompt(operation, language, source_language) <>
" Output raw JSON only, without markdown code fences."
}, },
%{ %{
"role" => "user", "role" => "user",
@@ -351,11 +354,11 @@ defmodule BDS.AI.OneShot do
defp extract_json_response(%{json: json}) when is_map(json), do: {:ok, json} defp extract_json_response(%{json: json}) when is_map(json), do: {:ok, json}
defp extract_json_response(%{content: content}) when is_binary(content) do defp extract_json_response(%{content: content}) when is_binary(content) do
case Jason.decode(content) do case JsonContent.decode(content) do
{:ok, json} when is_map(json) -> json when is_map(json) ->
{:ok, json} {:ok, json}
_other -> nil ->
Logger.error( Logger.error(
"AI extract_json_response failed to parse content as JSON. Content: #{String.slice(content, 0, 1000)}" "AI extract_json_response failed to parse content as JSON. Content: #{String.slice(content, 0, 1000)}"
) )

View File

@@ -182,14 +182,7 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
end end
end end
defp decode_json_content(nil), do: nil defp decode_json_content(content), do: BDS.AI.JsonContent.decode(content)
defp decode_json_content(content) when is_binary(content) do
case Jason.decode(content) do
{:ok, decoded} when is_map(decoded) -> decoded
_other -> nil
end
end
defp completions_url(url) do defp completions_url(url) do
cond do cond do

View File

@@ -32,6 +32,7 @@ defmodule BDS.Application do
BDS.Repo, BDS.Repo,
BDS.RepoBootstrap, BDS.RepoBootstrap,
BDS.Tasks, BDS.Tasks,
BDS.AI.InFlight,
BDS.Preview, BDS.Preview,
BDS.Publishing, BDS.Publishing,
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor}, {Task.Supervisor, name: BDS.Tasks.TaskSupervisor},

View File

@@ -414,7 +414,7 @@ defmodule BDS.Desktop.ShellLive do
do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks()) do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks())
def handle_event("add_gallery_images", %{"post-id" => post_id}, socket) do def handle_event("add_gallery_images", %{"post-id" => post_id}, socket) do
if socket.assigns.offline_mode do if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
{:noreply, {:noreply,
append_output_entry( append_output_entry(
socket, socket,

View File

@@ -230,7 +230,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
not is_nil(socket.assigns.request) -> not is_nil(socket.assigns.request) ->
build_data(socket) build_data(socket)
socket.assigns.offline_mode -> socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() ->
Notify.output( Notify.output(
dgettext("ui", "Chat"), dgettext("ui", "Chat"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."), dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
@@ -239,7 +239,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
build_data(socket) build_data(socket)
ModelSelection.needs_api_key?(false) -> ModelSelection.needs_api_key?(socket.assigns.offline_mode) ->
build_data(socket) build_data(socket)
true -> true ->

View File

@@ -434,7 +434,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
socket = socket =
with %{} = definition <- ImportDefinitions.get_definition(definition_id), with %{} = definition <- ImportDefinitions.get_definition(definition_id),
%{} = report <- ImportDefinitions.decode_analysis_result(definition) do %{} = report <- ImportDefinitions.decode_analysis_result(definition) do
if socket.assigns.offline_mode? do if socket.assigns.offline_mode? and not AI.airplane_endpoint_configured?() do
notify_output( notify_output(
dgettext("ui", "Import"), dgettext("ui", "Import"),
BDS.Gettext.lgettext( BDS.Gettext.lgettext(

View File

@@ -82,7 +82,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
%{} = definition <- ImportDefinitions.get_definition(definition_id), %{} = definition <- ImportDefinitions.get_definition(definition_id),
%{} = report <- ImportDefinitions.decode_analysis_result(definition) do %{} = report <- ImportDefinitions.decode_analysis_result(definition) do
cond do cond do
socket.assigns.offline_mode -> socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() ->
socket socket
|> append_output.( |> append_output.(
dgettext("ui", "Import"), dgettext("ui", "Import"),

View File

@@ -153,7 +153,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
def handle_event("detect_media_editor_language", _params, socket) do def handle_event("detect_media_editor_language", _params, socket) do
if socket.assigns.offline_mode do if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
notify_output( notify_output(
socket, socket,
dgettext("ui", "Detect Language"), dgettext("ui", "Detect Language"),
@@ -346,7 +346,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
def handle_event("refresh_media_translation", %{"language" => language}, socket) do def handle_event("refresh_media_translation", %{"language" => language}, socket) do
media = socket.assigns.media media = socket.assigns.media
if socket.assigns.offline_mode do if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
notify_output( notify_output(
socket, socket,
dgettext("ui", "Translate"), dgettext("ui", "Translate"),
@@ -539,7 +539,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
end end
defp do_translate(socket, language) do defp do_translate(socket, language) do
if socket.assigns.offline_mode do if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
notify_output( notify_output(
socket, socket,
dgettext("ui", "Translate"), dgettext("ui", "Translate"),

View File

@@ -66,7 +66,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
socket = socket =
if kind == "ai_suggestions" and not is_nil(overlay) do if kind == "ai_suggestions" and not is_nil(overlay) do
if socket.assigns.offline_mode do if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
callbacks.append_output.( callbacks.append_output.(
socket, socket,
dgettext("ui", "AI Suggestions"), dgettext("ui", "AI Suggestions"),

View File

@@ -707,7 +707,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
defp do_detect_language(socket) do defp do_detect_language(socket) do
if Map.get(socket.assigns, :offline_mode, true) do if Map.get(socket.assigns, :offline_mode, true) and not AI.airplane_endpoint_configured?() do
notify_output( notify_output(
socket, socket,
dgettext("ui", "Detect Language"), dgettext("ui", "Detect Language"),
@@ -756,7 +756,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
end end
defp do_translate(socket, language) do defp do_translate(socket, language) do
if Map.get(socket.assigns, :offline_mode, true) do if Map.get(socket.assigns, :offline_mode, true) and not AI.airplane_endpoint_configured?() do
notify_output( notify_output(
socket, socket,
dgettext("ui", "Translate"), dgettext("ui", "Translate"),

View File

@@ -89,8 +89,17 @@ defmodule BDS.Desktop.Shutdown do
:ok :ok
end end
# quit/0 SIGKILLs the BEAM, so no terminate/2 callback ever runs on shutdown;
# everything that must reach disk has to be flushed here. Each step is
# hardened individually so one failure never blocks quit or the other steps.
defp persist_safely do defp persist_safely do
MainWindow.persist_now() persist_step(fn -> MainWindow.persist_now() end)
persist_step(fn -> BDS.Embeddings.Index.flush_all() end)
:ok
end
defp persist_step(fun) do
fun.()
:ok :ok
rescue rescue
_error -> :ok _error -> :ok

View File

@@ -0,0 +1,39 @@
defmodule BDS.AI.InFlightTest do
use ExUnit.Case, async: true
alias BDS.AI.InFlight
test "registrations survive the death of the registering process" do
conversation_id = unique_conversation_id()
target = self()
{pid, ref} =
spawn_monitor(fn ->
InFlight.register(conversation_id, self())
send(target, :registered)
end)
assert_receive :registered
assert_receive {:DOWN, ^ref, :process, ^pid, _reason}
assert InFlight.lookup(conversation_id) == pid
assert InFlight.unregister(conversation_id) == :ok
assert InFlight.lookup(conversation_id) == nil
end
test "lookup returns nil for unknown conversations" do
assert InFlight.lookup(unique_conversation_id()) == nil
end
test "the named table is owned by the supervised InFlight process" do
owner = :ets.info(:bds_ai_in_flight, :owner)
assert is_pid(owner)
assert owner == Process.whereis(InFlight)
end
defp unique_conversation_id do
"in-flight-test-" <> Integer.to_string(System.unique_integer([:positive]))
end
end

View File

@@ -0,0 +1,69 @@
defmodule BDS.AI.JsonContentTest do
use ExUnit.Case, async: true
alias BDS.AI.JsonContent
test "decodes a bare JSON object" do
assert %{"title" => "Sunset"} = JsonContent.decode(~s({"title": "Sunset"}))
end
test "decodes a JSON object wrapped in a json markdown fence" do
content = """
```json
{
"title": "Ahornblätter im Herbstlicht",
"alt": "Nahaufnahme von Ahornblättern",
"caption": "Einige Ahornblätter verfärben sich."
}
```
"""
assert %{
"title" => "Ahornblätter im Herbstlicht",
"alt" => "Nahaufnahme von Ahornblättern",
"caption" => "Einige Ahornblätter verfärben sich."
} = JsonContent.decode(content)
end
test "decodes a JSON object wrapped in an untagged markdown fence" do
assert %{"language_code" => "de"} =
JsonContent.decode("```\n{\"language_code\": \"de\"}\n```")
end
test "decodes a fenced JSON object with an uppercase language tag" do
assert %{"slug" => "herbst"} = JsonContent.decode("```JSON\n{\"slug\": \"herbst\"}\n```")
end
test "decodes a fenced JSON object surrounded by prose" do
content = """
Here is the requested metadata:
```json
{"title": "Herbst"}
```
Let me know if you need anything else.
"""
assert %{"title" => "Herbst"} = JsonContent.decode(content)
end
test "decodes a bare JSON object surrounded by prose" do
content = ~s(Sure! {"title": "Herbst", "alt": "Blätter"} Hope this helps.)
assert %{"title" => "Herbst", "alt" => "Blätter"} = JsonContent.decode(content)
end
test "returns nil for content without a JSON object" do
assert JsonContent.decode("This is not valid JSON") == nil
end
test "returns nil for a JSON array" do
assert JsonContent.decode(~s([1, 2, 3])) == nil
end
test "returns nil for nil and non-binary input" do
assert JsonContent.decode(nil) == nil
assert JsonContent.decode(42) == nil
end
end

View File

@@ -3,7 +3,6 @@ defmodule BDS.AITest do
import ExUnit.CaptureLog import ExUnit.CaptureLog
import Ecto.Query import Ecto.Query
require Logger
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.Persistence alias BDS.Persistence
@@ -366,6 +365,31 @@ defmodule BDS.AITest do
assert Repo.get(Setting, "__encrypted_ai.online.api_key") == nil assert Repo.get(Setting, "__encrypted_ai.online.api_key") == nil
end end
test "airplane_endpoint_configured? reflects the airplane endpoint url and model" do
refute BDS.AI.airplane_endpoint_configured?()
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(
:airplane,
%{url: "http://localhost:11434/v1", api_key: nil, model: ""},
secret_backend: FakeSecretBackend
)
refute BDS.AI.airplane_endpoint_configured?()
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(
:airplane,
%{url: "http://localhost:11434/v1", api_key: nil, model: "llama3.3"},
secret_backend: FakeSecretBackend
)
assert BDS.AI.airplane_endpoint_configured?()
assert :ok = BDS.AI.delete_endpoint(:airplane)
refute BDS.AI.airplane_endpoint_configured?()
end
test "refresh_model_catalog stores providers, models, modalities, and etag metadata" do test "refresh_model_catalog stores providers, models, modalities, and etag metadata" do
assert {:ok, result} = assert {:ok, result} =
BDS.AI.refresh_model_catalog(http_client: FakeHttpClient) BDS.AI.refresh_model_catalog(http_client: FakeHttpClient)
@@ -611,6 +635,95 @@ defmodule BDS.AITest do
assert log =~ "This is not valid JSON" assert log =~ "This is not valid JSON"
end end
test "analyze_image accepts JSON wrapped in markdown fences from local models" do
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(
:airplane,
%{
url: "http://localhost:11434/v1",
api_key: nil,
model: "llama-default"
},
secret_backend: FakeSecretBackend
)
assert :ok = BDS.AI.set_airplane_mode(true)
assert :ok = BDS.AI.put_model_preference(:airplane_image_analysis, "qwen-vision")
assert :ok =
BDS.AI.put_model_capabilities("qwen-vision", %{
supports_attachment: true,
supports_tool_calls: false,
disables_reasoning: false
})
defmodule FencedJsonContentRuntime do
def generate(_endpoint, _request, _opts) do
content = """
```json
{
"title": "Ahornblätter im Herbstlicht",
"alt": "Nahaufnahme von Ahornblättern",
"caption": "Einige Ahornblätter verfärben sich."
}
```
"""
{:ok,
%{
content: content,
json: nil,
tool_calls: [],
usage: %{input_tokens: 4, output_tokens: 2}
}}
end
end
assert {:ok, result} =
BDS.AI.analyze_image(
%{
mime_type: "image/png",
image_url: "data:image/png;base64,abc123"
},
runtime: FencedJsonContentRuntime,
test_pid: self(),
secret_backend: FakeSecretBackend
)
assert result.title == "Ahornblätter im Herbstlicht"
assert result.alt == "Nahaufnahme von Ahornblättern"
assert result.caption == "Einige Ahornblätter verfärben sich."
end
test "one-shot system prompts demand raw JSON without markdown fences" do
assert {:ok, _endpoint} =
BDS.AI.put_endpoint(
:airplane,
%{
url: "http://localhost:11434/v1",
api_key: nil,
model: "llama-default"
},
secret_backend: FakeSecretBackend
)
assert :ok = BDS.AI.set_airplane_mode(true)
assert {:ok, _result} =
BDS.AI.analyze_post(
%{title: "Title", excerpt: "Excerpt", content: "Content"},
runtime: FakeRuntime,
test_pid: self(),
secret_backend: FakeSecretBackend
)
assert_received {:runtime_request, _endpoint, request}
system_content = get_in(request.messages, [Access.at(0), "content"])
assert system_content =~ "raw JSON only"
assert system_content =~ "without markdown code fences"
end
test "airplane mode routes title tasks to airplane endpoint and offline title model" do test "airplane mode routes title tasks to airplane endpoint and offline title model" do
assert {:ok, _endpoint} = assert {:ok, _endpoint} =
BDS.AI.put_endpoint( BDS.AI.put_endpoint(

View File

@@ -3230,6 +3230,129 @@ defmodule BDS.Desktop.ShellLiveTest do
assert html =~ "Automatic AI actions stay gated by airplane mode" assert html =~ "Automatic AI actions stay gated by airplane mode"
end end
test "ai suggestions overlay uses the local model in airplane mode for media", %{
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(true)
assert {:ok, _endpoint} =
AI.put_endpoint(:airplane, %{
url: "http://127.0.0.1:#{port}/v1",
api_key: nil,
model: "llava-local"
})
assert :ok = AI.put_model_preference(:airplane_image_analysis, "llava-local")
assert :ok = AI.put_model_capabilities("llava-local", %{supports_attachment: 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, "airplane-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: "Airplane 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 =
view
|> element("[data-testid='media-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_receive {:ai_suggestions_request, request}, 2_000
assert request["model"] == "llava-local"
Process.sleep(200)
html = render(view)
assert html =~ "AI Image Title"
assert html =~ "AI Alt Text"
assert html =~ "AI Caption"
end
test "chat editor sends messages to the local model in airplane mode" do
Application.put_env(:bds, :test_pid, self())
server =
start_supervised!({Bandit, plug: TitleChatServer, port: 0, startup_log: false})
{:ok, {_address, port}} = ThousandIsland.listener_info(server)
assert :ok = AI.set_airplane_mode(true)
assert {:ok, _endpoint} =
AI.put_endpoint(:airplane, %{
url: "http://127.0.0.1:#{port}/v1",
api_key: nil,
model: "llama-local"
})
assert {:ok, conversation} = AI.start_chat(%{title: "New Chat"})
{:ok, view, _html} = live_isolated(build_conn(), BDS.Desktop.ShellLive)
html =
render_click(view, "pin_sidebar_item", %{
"route" => "chat",
"id" => conversation.id,
"title" => conversation.title,
"subtitle" => "chat"
})
assert html =~ ~s(data-testid="chat-send-button")
_html =
view
|> element(".chat-input-wrapper")
|> render_change(%{"message" => "Beschreibe das neueste Bild"})
_html =
view
|> element("[data-testid='chat-send-button']")
|> render_click()
assert_receive {:title_chat_request, request}, 2_000
assert request["model"] == "llama-local"
Process.sleep(350)
html = render(view)
assert html =~ "Ich habe die Posts pro Monat ermittelt."
end
test "ai suggestions overlay fetches async results for media when online", %{project: project} do test "ai suggestions overlay fetches async results for media when online", %{project: project} do
Application.put_env(:bds, :test_pid, self()) Application.put_env(:bds, :test_pid, self())

View File

@@ -256,6 +256,39 @@ defmodule BDS.DesktopTest do
assert_receive :window_quit_requested assert_receive :window_quit_requested
end end
test "app-owned shutdown flushes pending embedding index saves before hard quit" do
previous_module = Application.get_env(:bds, :desktop_shutdown_module)
previous_quit_module = Application.get_env(:bds, :desktop_window_quit_module)
previous_pid = Application.get_env(:bds, :desktop_shutdown_test_pid)
Application.put_env(:bds, :desktop_shutdown_module, BDS.Desktop.Shutdown)
Application.put_env(:bds, :desktop_window_quit_module, FakeWindowQuit)
Application.put_env(:bds, :desktop_shutdown_test_pid, self())
project_id = "shutdown-flush-#{System.unique_integer([:positive])}"
index_path = BDS.Embeddings.Index.path(project_id)
on_exit(fn ->
restore_env(:desktop_shutdown_module, previous_module)
restore_env(:desktop_window_quit_module, previous_quit_module)
restore_env(:desktop_shutdown_test_pid, previous_pid)
:ok = BDS.Embeddings.Index.forget(project_id)
File.rm_rf(Path.dirname(index_path))
end)
vector = for offset <- 1..384, into: <<>>, do: <<:math.sin(offset)::float-32-little>>
:ok = BDS.Embeddings.Index.put(project_id, 384, [%{label: 1, post_id: 101, vector: vector}])
# the save is debounced; without a shutdown flush nothing is on disk yet
refute File.exists?(index_path)
assert :ok = BDS.Desktop.Shutdown.request_quit()
# quit is the final shutdown step, so persistence has completed by now
assert_receive :window_quit_requested
assert File.exists?(index_path)
end
test "the app owns final termination instead of delegating to Desktop.Window/System.halt" do test "the app owns final termination instead of delegating to Desktop.Window/System.halt" do
# Desktop.Window.quit/0 routes through System.halt/1, which runs the wx C++ # Desktop.Window.quit/0 routes through System.halt/1, which runs the wx C++
# static destructors on exit and crashes on macOS. The app-owned shutdown # static destructors on exit and crashes on macOS. The app-owned shutdown