diff --git a/assets/css/shell.css b/assets/css/shell.css index 8adf5de..7c70768 100644 --- a/assets/css/shell.css +++ b/assets/css/shell.css @@ -602,14 +602,16 @@ background: transparent; color: inherit; cursor: pointer; - opacity: 0.4; font-size: 13px; padding: 0 4px; } .status-bar-item.offline-badge.active { - background-color: rgba(255, 196, 0, 0.28); + background-color: #e6a800; + color: #000; + font-weight: 600; opacity: 1; + border-radius: 3px; } .project-selector { diff --git a/assets/js/hooks/chat_surface.js b/assets/js/hooks/chat_surface.js index 3baf0a3..f9594ad 100644 --- a/assets/js/hooks/chat_surface.js +++ b/assets/js/hooks/chat_surface.js @@ -3,6 +3,9 @@ export const ChatSurface = { this.stickToBottom = true; this.scrollContainer = null; + this._enterKeyHandled = false; + this._prevInputValue = ""; + this.autoResize = () => { const textarea = this.el.querySelector(".chat-input"); @@ -85,11 +88,34 @@ export const ChatSurface = { this.stickToBottom = distanceFromBottom < 48; }; + this._submitChat = () => { + const form = this.el.querySelector(".chat-input-wrapper"); + if (form && typeof form.requestSubmit === "function") { + form.requestSubmit(); + } else { + const sendButton = this.el.querySelector("[data-testid='chat-send-button']"); + if (sendButton) sendButton.click(); + } + }; + this.handleInput = (event) => { if (!event.target.closest(".chat-input")) { return; } + const textarea = event.target; + + if (!this._enterKeyHandled && textarea.value.includes("\n") && !this._prevInputValue.includes("\n")) { + textarea.value = textarea.value.replace(/\n/g, ""); + this._prevInputValue = textarea.value; + this.stickToBottom = true; + this.autoResize(); + this._submitChat(); + return; + } + + this._enterKeyHandled = false; + this._prevInputValue = textarea.value; this.stickToBottom = true; this.autoResize(); }; @@ -101,12 +127,8 @@ export const ChatSurface = { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); - - const sendButton = this.el.querySelector("[data-testid='chat-send-button']"); - - if (sendButton && !sendButton.disabled) { - sendButton.click(); - } + this._enterKeyHandled = true; + this._submitChat(); } }; diff --git a/config/config.exs b/config/config.exs index b6c4f22..e682335 100644 --- a/config/config.exs +++ b/config/config.exs @@ -4,7 +4,9 @@ config :bds, ecto_repos: [BDS.Repo] config :bds, BDS.Repo, - database: Path.expand("../priv/data/bds_dev.db", __DIR__), + database: + System.get_env("BDS_DATABASE_PATH") || + Path.expand("~/Library/Application Support/BDS2/bds_dev.db"), pool_size: 5, journal_mode: :wal, busy_timeout: 15_000, @@ -21,6 +23,9 @@ config :bds, :desktop, title: "Blogging Desktop Server", secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001" +config :bds, :ai_secret_key, + "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001" + config :bds, BDS.Desktop.Endpoint, url: [host: "127.0.0.1"], adapter: Bandit.PhoenixAdapter, @@ -80,7 +85,9 @@ config :bds, :embeddings, # Cache downloaded model files under the app data directory so they persist # across sessions (ModelCaching invariant). Overridden at runtime in prod. -config :bumblebee, :cache_dir, Path.expand("../priv/data/models", __DIR__) +config :bumblebee, :cache_dir, + System.get_env("BDS_MODEL_CACHE_DIR") || + Path.expand("~/Library/Application Support/BDS2/models") config :logger, :console, format: "$time $metadata[$level] $message\n", diff --git a/config/prod.exs b/config/prod.exs index 1fca42b..0a00304 100644 --- a/config/prod.exs +++ b/config/prod.exs @@ -1,7 +1,6 @@ import Config config :bds, BDS.Repo, - database: Path.expand("../priv/data/bds_prod.db", __DIR__), pool_size: 5, stacktrace: false, show_sensitive_data_on_connection_error: false diff --git a/config/runtime.exs b/config/runtime.exs index 3de106b..289a676 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -3,7 +3,9 @@ import Config if config_env() == :prod do database_path = System.get_env("BDS_DATABASE_PATH") || - Path.expand("../priv/data/bds_prod.db", __DIR__) + Path.expand("~/Library/Application Support/BDS2/bds.db") + + File.mkdir_p!(Path.dirname(database_path)) config :bds, BDS.Repo, database: database_path, diff --git a/lib/bds/desktop/main_window.ex b/lib/bds/desktop/main_window.ex index 5621c2d..c561c94 100644 --- a/lib/bds/desktop/main_window.ex +++ b/lib/bds/desktop/main_window.ex @@ -190,7 +190,7 @@ defmodule BDS.Desktop.MainWindow do end defp config_dir do - case :filename.basedir(:user_config, "bds") do + case :filename.basedir(:user_config, "BDS2") do path when is_list(path) -> List.to_string(path) path -> path end diff --git a/lib/bds/desktop/shell_live.ex b/lib/bds/desktop/shell_live.ex index 4aad5e9..23f52a2 100644 --- a/lib/bds/desktop/shell_live.ex +++ b/lib/bds/desktop/shell_live.ex @@ -398,15 +398,6 @@ defmodule BDS.Desktop.ShellLive do def handle_event("overlay_confirm", params, socket), do: OverlayManager.handle_event("overlay_confirm", params, socket, overlay_callbacks()) - def handle_event("overlay_select_gallery_image", params, socket), - do: - OverlayManager.handle_event( - "overlay_select_gallery_image", - params, - socket, - overlay_callbacks() - ) - def handle_event("overlay_close_lightbox", params, socket), do: OverlayManager.handle_event("overlay_close_lightbox", params, socket, overlay_callbacks()) diff --git a/lib/bds/desktop/shell_live/chat_editor.ex b/lib/bds/desktop/shell_live/chat_editor.ex index 4af2096..feccdd4 100644 --- a/lib/bds/desktop/shell_live/chat_editor.ex +++ b/lib/bds/desktop/shell_live/chat_editor.ex @@ -90,10 +90,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do end def handle_event("send_chat_editor_message", _params, socket) do + Logger.info("CHAT send_chat_editor_message called, input=#{inspect(socket.assigns.input)}") {:noreply, do_send_message(socket)} end def handle_event("abort_chat_editor_message", _params, socket) do + Logger.info("CHAT abort_chat_editor_message called") {:noreply, do_abort_message(socket)} end diff --git a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex index d66506a..3633590 100644 --- a/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex +++ b/lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex @@ -154,7 +154,7 @@
<%= if @chat_editor.action_error do %> diff --git a/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex b/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex index b842483..24deecf 100644 --- a/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex +++ b/lib/bds/desktop/shell_live/settings_editor/ai_settings.ex @@ -9,8 +9,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do @spec ai_form(term()) :: term() def ai_form(assigns) do - {:ok, online_endpoint} = AI.get_endpoint(:online) - {:ok, airplane_endpoint} = AI.get_endpoint(:airplane) + online_endpoint = safe_endpoint(:online) + airplane_endpoint = safe_endpoint(:airplane) %{ "online_url" => Map.get(online_endpoint || %{}, :url, ""), @@ -168,6 +168,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do end end + defp safe_endpoint(kind) do + case AI.get_endpoint(kind) do + {:ok, ep} -> ep + _error -> nil + end + end + defp ai_attrs(assigns) do draft = Map.get(assigns, :settings_editor_ai_draft, %{}) diff --git a/lib/bds/mac_bundle/dylibs.ex b/lib/bds/mac_bundle/dylibs.ex index bedbed3..7af07f7 100644 --- a/lib/bds/mac_bundle/dylibs.ex +++ b/lib/bds/mac_bundle/dylibs.ex @@ -76,23 +76,48 @@ defmodule BDS.MacBundle.Dylibs do File.mkdir_p!(frameworks_dir) with {:ok, {_seen, externals}} <- collect(nif_path, MapSet.new(), []) do - copied = + externals = Enum.reverse(externals) + + {physical, logical_entries, _inode_map} = externals - |> Enum.reverse() - |> Enum.map(fn src -> + |> Enum.reduce({[], [], %{}}, fn src, {phys_acc, log_acc, inode_map} -> dest = Path.join(frameworks_dir, Path.basename(src)) - File.cp!(src, dest) - File.chmod!(dest, 0o644) - {src, dest} + + if File.exists?(dest) do + {phys_acc, [{src, dest} | log_acc], inode_map} + else + ino = file_inode(src) + + case Map.get(inode_map, ino) do + nil -> + File.cp!(src, dest) + File.chmod!(dest, 0o644) + {[{src, dest} | phys_acc], [{src, dest} | log_acc], + Map.put(inode_map, ino, dest)} + + existing_dest -> + # Same inode already copied under a different name. + # Point this logical entry to the physical copy. + {phys_acc, [{src, existing_dest} | log_acc], inode_map} + end + end end) + copied = Enum.reverse(logical_entries) + physical = Enum.reverse(physical) + with :ok <- rewrite(nif_path, copied, nif_loader_prefix), - :ok <- rewrite_each(copied) do - {:ok, Enum.map(copied, &elem(&1, 1))} + :ok <- rewrite_each(physical) do + {:ok, Enum.map(physical, &elem(&1, 1))} end end end + defp file_inode(path) do + stat = File.stat!(path) + {stat.major_device, stat.inode} + end + # Depth-first transitive collection of external dependency paths. Returns # `{:ok, {seen, acc}}` where `acc` is the reverse-discovery-order path list. defp collect(binary, seen, acc) do @@ -100,16 +125,17 @@ defmodule BDS.MacBundle.Dylibs do {:ok, deps} -> deps |> Enum.filter(&external?/1) - |> Enum.reject(&MapSet.member?(seen, &1)) |> Enum.reduce_while({:ok, {seen, acc}}, fn dep, {:ok, {seen_acc, list_acc}} -> - seen_acc = MapSet.put(seen_acc, dep) - - case collect(dep, seen_acc, [dep | list_acc]) do - {:ok, _} = ok -> {:cont, ok} - error -> {:halt, error} + if MapSet.member?(seen_acc, dep) do + {:cont, {:ok, {seen_acc, list_acc}}} + else + seen_acc = MapSet.put(seen_acc, dep) + case collect(dep, seen_acc, [dep | list_acc]) do + {:ok, _} = ok -> {:cont, ok} + error -> {:halt, error} + end end end) - error -> error end diff --git a/lib/bds/projects.ex b/lib/bds/projects.ex index a0cc8f0..36b8c52 100644 --- a/lib/bds/projects.ex +++ b/lib/bds/projects.ex @@ -329,7 +329,7 @@ defmodule BDS.Projects do end defp private_app_dir do - case :filename.basedir(:user_config, "bds") do + case :filename.basedir(:user_config, "BDS2") do path when is_list(path) -> List.to_string(path) path -> path end diff --git a/priv/static/assets/app.css b/priv/static/assets/app.css index 3866b1c..e3669ad 100644 --- a/priv/static/assets/app.css +++ b/priv/static/assets/app.css @@ -1076,13 +1076,15 @@ button svg, button svg * { background: transparent; color: inherit; cursor: pointer; - opacity: 0.4; font-size: 13px; padding: 0 4px; } .status-bar-item.offline-badge.active { - background-color: rgba(255, 196, 0, 0.28); + background-color: #e6a800; + color: #000; + font-weight: 600; opacity: 1; + border-radius: 3px; } .project-selector { position: relative; diff --git a/priv/static/assets/app.js b/priv/static/assets/app.js index 0488d1d..d2a4d92 100644 --- a/priv/static/assets/app.js +++ b/priv/static/assets/app.js @@ -9143,6 +9143,8 @@ removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}" mounted() { this.stickToBottom = true; this.scrollContainer = null; + this._enterKeyHandled = false; + this._prevInputValue = ""; this.autoResize = () => { const textarea = this.el.querySelector(".chat-input"); if (!textarea) { @@ -9202,10 +9204,30 @@ removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}" const distanceFromBottom = this.scrollContainer.scrollHeight - this.scrollContainer.scrollTop - this.scrollContainer.clientHeight; this.stickToBottom = distanceFromBottom < 48; }; + this._submitChat = () => { + const form = this.el.querySelector(".chat-input-wrapper"); + if (form && typeof form.requestSubmit === "function") { + form.requestSubmit(); + } else { + const sendButton = this.el.querySelector("[data-testid='chat-send-button']"); + if (sendButton) sendButton.click(); + } + }; this.handleInput = (event) => { if (!event.target.closest(".chat-input")) { return; } + const textarea = event.target; + if (!this._enterKeyHandled && textarea.value.includes("\n") && !this._prevInputValue.includes("\n")) { + textarea.value = textarea.value.replace(/\n/g, ""); + this._prevInputValue = textarea.value; + this.stickToBottom = true; + this.autoResize(); + this._submitChat(); + return; + } + this._enterKeyHandled = false; + this._prevInputValue = textarea.value; this.stickToBottom = true; this.autoResize(); }; @@ -9215,10 +9237,8 @@ removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}" } if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { event.preventDefault(); - const sendButton = this.el.querySelector("[data-testid='chat-send-button']"); - if (sendButton && !sendButton.disabled) { - sendButton.click(); - } + this._enterKeyHandled = true; + this._submitChat(); } }; this.el.addEventListener("input", this.handleInput); diff --git a/test/bds/desktop/settings_search_test.exs b/test/bds/desktop/settings_search_test.exs index 52da432..a8190c2 100644 --- a/test/bds/desktop/settings_search_test.exs +++ b/test/bds/desktop/settings_search_test.exs @@ -2,6 +2,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.SettingsSearchTest do use ExUnit.Case, async: false alias BDS.Desktop.ShellLive.SettingsEditor + alias BDS.Repo + alias BDS.Settings.Setting describe "build_settings/1 — search filter" do setup do @@ -111,4 +113,62 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.SettingsSearchTest do assert SettingsEditor.build_settings(%{projects: %{active_project_id: nil}}) == nil end end + + describe "build_settings/1 — handles undecryptable API key gracefully" do + setup do + :ok = Ecto.Adapters.SQL.Sandbox.checkout(BDS.Repo) + temp_dir = + Path.join(System.tmp_dir!(), "bds-settings-cipher-#{System.unique_integer([:positive])}") + + File.mkdir_p!(temp_dir) + + on_exit(fn -> File.rm_rf(temp_dir) end) + + {:ok, project} = + BDS.Projects.create_project(%{name: "CipherTest", data_path: temp_dir}) + + now = DateTime.utc_now() |> DateTime.to_unix(:millisecond) + + Repo.insert!(%Setting{ + key: "ai.online.url", + value: "https://example.com", + updated_at: now + }) + + Repo.insert!(%Setting{ + key: "ai.online.model", + value: "gpt-4", + updated_at: now + }) + + Repo.insert!(%Setting{ + key: "__encrypted_ai.online.api_key", + value: "NOT_VALID_BASE64", + updated_at: now + }) + + %{project: project, temp_dir: temp_dir} + end + + test "does not crash when encrypted API key cannot be decrypted", %{ + project: project, + temp_dir: temp_dir + } do + result = + SettingsEditor.build_settings(%{ + projects: %{active_project_id: project.id}, + current_project: %{data_path: temp_dir}, + settings_editor_search: "", + settings_editor_project_draft: %{}, + settings_editor_editor_draft: %{}, + settings_editor_ai_draft: %{}, + settings_editor_publishing_draft: %{}, + current_tab: %{type: :settings, id: "settings"}, + tab_meta: %{} + }) + + assert is_map(result) + assert result.ai_visible? + end + end end diff --git a/test/bds/projects_test.exs b/test/bds/projects_test.exs index d59569d..e1f4df6 100644 --- a/test/bds/projects_test.exs +++ b/test/bds/projects_test.exs @@ -119,7 +119,7 @@ defmodule BDS.ProjectsTest do test "project_cache_dir never falls back into the project data directory" do # Private app-internal artifacts (the embeddings index) must live under the - # OS private app directory (macOS: ~/Library/Application Support/bds), never + # OS private app directory (macOS: ~/Library/Application Support/BDS2), never # inside priv/data/projects/