Compare commits
4 Commits
9325de2db4
...
eac6d543d2
| Author | SHA1 | Date | |
|---|---|---|---|
| eac6d543d2 | |||
| 8546080a3d | |||
| d8b24c9b72 | |||
| 63e35d19e3 |
27
TECHDEBTS.md
27
TECHDEBTS.md
@@ -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).**
|
||||
|
||||
**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
|
||||
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
|
||||
@@ -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.**
|
||||
|
||||
**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` —
|
||||
a documented and legitimate workaround for a wxWidgets static-destructor
|
||||
segfault on macOS). Consequence: **no `terminate/2` callback in the whole app
|
||||
|
||||
@@ -111,6 +111,19 @@ defmodule BDS.AI do
|
||||
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()) ::
|
||||
:ok | {:error, :unknown_model_preference | term()}
|
||||
def put_model_preference(key, model) when is_atom(key) and is_binary(model) do
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
defmodule BDS.AI.InFlight do
|
||||
@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
|
||||
|
||||
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
|
||||
:ets.insert(table(), {conversation_id, pid})
|
||||
:ets.insert(@table, {conversation_id, pid})
|
||||
:ok
|
||||
end
|
||||
|
||||
def unregister(conversation_id) when is_binary(conversation_id) do
|
||||
:ets.delete(table(), conversation_id)
|
||||
:ets.delete(@table, conversation_id)
|
||||
:ok
|
||||
end
|
||||
|
||||
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
|
||||
_other -> nil
|
||||
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
|
||||
|
||||
42
lib/bds/ai/json_content.ex
Normal file
42
lib/bds/ai/json_content.ex
Normal 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
|
||||
@@ -4,6 +4,7 @@ defmodule BDS.AI.OneShot do
|
||||
require Logger
|
||||
|
||||
alias BDS.AI.Chat
|
||||
alias BDS.AI.JsonContent
|
||||
alias BDS.AI.OpenAICompatibleRuntime
|
||||
alias BDS.AI.Runtime
|
||||
alias BDS.Media.Media
|
||||
@@ -213,7 +214,9 @@ defmodule BDS.AI.OneShot do
|
||||
messages: [
|
||||
%{
|
||||
"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",
|
||||
@@ -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(%{content: content}) when is_binary(content) do
|
||||
case Jason.decode(content) do
|
||||
{:ok, json} when is_map(json) ->
|
||||
case JsonContent.decode(content) do
|
||||
json when is_map(json) ->
|
||||
{:ok, json}
|
||||
|
||||
_other ->
|
||||
nil ->
|
||||
Logger.error(
|
||||
"AI extract_json_response failed to parse content as JSON. Content: #{String.slice(content, 0, 1000)}"
|
||||
)
|
||||
|
||||
@@ -182,14 +182,7 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_json_content(nil), do: nil
|
||||
|
||||
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 decode_json_content(content), do: BDS.AI.JsonContent.decode(content)
|
||||
|
||||
defp completions_url(url) do
|
||||
cond do
|
||||
|
||||
@@ -32,6 +32,7 @@ defmodule BDS.Application do
|
||||
BDS.Repo,
|
||||
BDS.RepoBootstrap,
|
||||
BDS.Tasks,
|
||||
BDS.AI.InFlight,
|
||||
BDS.Preview,
|
||||
BDS.Publishing,
|
||||
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
|
||||
|
||||
@@ -414,7 +414,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks())
|
||||
|
||||
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,
|
||||
append_output_entry(
|
||||
socket,
|
||||
|
||||
@@ -230,7 +230,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
not is_nil(socket.assigns.request) ->
|
||||
build_data(socket)
|
||||
|
||||
socket.assigns.offline_mode ->
|
||||
socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() ->
|
||||
Notify.output(
|
||||
dgettext("ui", "Chat"),
|
||||
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
|
||||
@@ -239,7 +239,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|
||||
build_data(socket)
|
||||
|
||||
ModelSelection.needs_api_key?(false) ->
|
||||
ModelSelection.needs_api_key?(socket.assigns.offline_mode) ->
|
||||
build_data(socket)
|
||||
|
||||
true ->
|
||||
|
||||
@@ -434,7 +434,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
socket =
|
||||
with %{} = definition <- ImportDefinitions.get_definition(definition_id),
|
||||
%{} = 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(
|
||||
dgettext("ui", "Import"),
|
||||
BDS.Gettext.lgettext(
|
||||
|
||||
@@ -82,7 +82,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
||||
%{} = definition <- ImportDefinitions.get_definition(definition_id),
|
||||
%{} = report <- ImportDefinitions.decode_analysis_result(definition) do
|
||||
cond do
|
||||
socket.assigns.offline_mode ->
|
||||
socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() ->
|
||||
socket
|
||||
|> append_output.(
|
||||
dgettext("ui", "Import"),
|
||||
|
||||
@@ -153,7 +153,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
end
|
||||
|
||||
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(
|
||||
socket,
|
||||
dgettext("ui", "Detect Language"),
|
||||
@@ -346,7 +346,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
def handle_event("refresh_media_translation", %{"language" => language}, socket) do
|
||||
media = socket.assigns.media
|
||||
|
||||
if socket.assigns.offline_mode do
|
||||
if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
|
||||
notify_output(
|
||||
socket,
|
||||
dgettext("ui", "Translate"),
|
||||
@@ -539,7 +539,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
end
|
||||
|
||||
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(
|
||||
socket,
|
||||
dgettext("ui", "Translate"),
|
||||
|
||||
@@ -66,7 +66,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
|
||||
socket =
|
||||
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.(
|
||||
socket,
|
||||
dgettext("ui", "AI Suggestions"),
|
||||
|
||||
@@ -707,7 +707,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
end
|
||||
|
||||
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(
|
||||
socket,
|
||||
dgettext("ui", "Detect Language"),
|
||||
@@ -756,7 +756,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
end
|
||||
|
||||
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(
|
||||
socket,
|
||||
dgettext("ui", "Translate"),
|
||||
|
||||
@@ -89,8 +89,17 @@ defmodule BDS.Desktop.Shutdown do
|
||||
:ok
|
||||
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
|
||||
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
|
||||
rescue
|
||||
_error -> :ok
|
||||
|
||||
39
test/bds/ai/in_flight_test.exs
Normal file
39
test/bds/ai/in_flight_test.exs
Normal 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
|
||||
69
test/bds/ai/json_content_test.exs
Normal file
69
test/bds/ai/json_content_test.exs
Normal 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
|
||||
@@ -3,7 +3,6 @@ defmodule BDS.AITest do
|
||||
|
||||
import ExUnit.CaptureLog
|
||||
import Ecto.Query
|
||||
require Logger
|
||||
|
||||
alias BDS.Media.Media
|
||||
alias BDS.Persistence
|
||||
@@ -366,6 +365,31 @@ defmodule BDS.AITest do
|
||||
assert Repo.get(Setting, "__encrypted_ai.online.api_key") == nil
|
||||
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
|
||||
assert {:ok, result} =
|
||||
BDS.AI.refresh_model_catalog(http_client: FakeHttpClient)
|
||||
@@ -611,6 +635,95 @@ defmodule BDS.AITest do
|
||||
assert log =~ "This is not valid JSON"
|
||||
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
|
||||
assert {:ok, _endpoint} =
|
||||
BDS.AI.put_endpoint(
|
||||
|
||||
@@ -3230,6 +3230,129 @@ defmodule BDS.Desktop.ShellLiveTest do
|
||||
assert html =~ "Automatic AI actions stay gated by airplane mode"
|
||||
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
|
||||
Application.put_env(:bds, :test_pid, self())
|
||||
|
||||
|
||||
@@ -256,6 +256,39 @@ defmodule BDS.DesktopTest do
|
||||
assert_receive :window_quit_requested
|
||||
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
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user