Compare commits
3 Commits
d78cb5c07b
...
57bdcef990
| Author | SHA1 | Date | |
|---|---|---|---|
| 57bdcef990 | |||
| 52809b39b1 | |||
| e3e94ac2d9 |
204
AUDIT.md
Normal file
204
AUDIT.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# Elixir Antipattern Audit — Working Document
|
||||
|
||||
Verified 2026-06-25 against `lib/bds/` (233 modules). Every item below is a standalone task.
|
||||
Follow `AGENTS.md`: test-first, fix all failures, clean build/credo/dialyzer/deps.audit.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Delete the dead externally-driven task API
|
||||
|
||||
**File:** `lib/bds/tasks.ex` (573 lines, GenServer)
|
||||
|
||||
`tasks.ex` has two task patterns. One is live; one is dead.
|
||||
|
||||
- **Live (keep):** `submit_task/3` runs a work function under `Task.Supervisor`; the GenServer auto-tracks lifecycle via its `{ref, result}` and `{:DOWN, ...}` handlers, and progress flows through the `reporter` closure passed into the work function. Used by 8 call sites across 3 modules (`publishing.ex:57`; `auto_translation.ex:182,215`; `shell_commands.ex:83,94,219,418,554`). The read/control functions are also exposed through the Lua script API (`lib/bds/scripting/capabilities.ex:240-247`): `get`, `status_snapshot`, `cancel`, `get_all`, `get_running`, `clear_completed`.
|
||||
- **Dead (delete):** the externally-driven pattern — register a task that runs elsewhere, then manually push progress and mark it complete/failed. Zero production callers; only `test/bds/tasks_test.exs` and `test/bds/scripting/api_test.exs` exercise it.
|
||||
|
||||
### Delete exactly these
|
||||
|
||||
Public functions and their matching `handle_call` clauses:
|
||||
|
||||
| Public function | `handle_call` clause |
|
||||
|---|---|
|
||||
| `register_external_task/2` (line 51) | line 166 |
|
||||
| `report_progress/3` (line 55) | line 172 |
|
||||
| `complete_task/2` (line 59) | line 176 |
|
||||
| `fail_task/2` (line 63) | line 192 |
|
||||
|
||||
### Keep these — do NOT delete
|
||||
|
||||
- `submit_task/3`, `get_task/1`, `status_snapshot/0`, `list_tasks/0`, `list_running_tasks/0`, `cancel_task/1`, `clear_completed/0` — all live, most exposed via the script API.
|
||||
- `clear_finished/0` (line 43) and its `handle_call` (line 120). It has zero production callers but is used as a test-reset helper in 5 test files (`tasks_test.exs:7`, `post_translations_test.exs:57`, `real_blog_rebuild_diagnostic_test.exs:41`, `shell_commands_test.exs:105`, `shell_live_test.exs:1409`). `clear_completed/0` is not a drop-in replacement — it clears only `:completed`, not `:failed`/`:cancelled`, and its semantics are documented in the script API.
|
||||
- `maybe_report_progress/4` (private, line 347). It is shared: the `{:task_progress, ...}` `handle_info` (line 209) on the live path calls it. It does not become unused.
|
||||
|
||||
### Tests
|
||||
|
||||
- Delete only the test blocks in `tasks_test.exs` / `api_test.exs` that exclusively cover `register_external_task` / `report_progress` / `complete_task` / `fail_task`.
|
||||
- Do NOT touch test setup/`on_exit` that calls `clear_finished/0` for reset — those tests do not cover it, they use it.
|
||||
|
||||
### Verify after
|
||||
|
||||
- `grep -rn "register_external_task\|complete_task\|fail_task" lib test` returns no hits.
|
||||
- `grep -rn "Tasks.report_progress" lib test` returns no hits (`Maintenance.Progress.report_progress/4` and the script-side `bds.report_progress` are different functions — leave them).
|
||||
- No deleted function is wired into `lib/bds/scripting/capabilities.ex` or `api_docs.ex` (confirmed: the script API exposes none of the four).
|
||||
- Build/test/credo/dialyzer clean.
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Log silently-swallowed errors
|
||||
|
||||
**Pattern:** a `rescue`/`catch` clause that discards the error with no log line and returns a bare `:ok`, `nil`, or `:error` (e.g. `rescue _error -> :ok`, `catch :exit, _reason -> :ok`, `rescue _error -> nil`).
|
||||
|
||||
> **Inventory is non-exhaustive — treat the table as a starting set, not a closed list.**
|
||||
> Before remediating, re-sweep with `grep -rnE "_(error|reason|exception|e) -> (nil|:ok|:error)" lib` (≈27 hits).
|
||||
> The table below is not the full set — e.g. `main_window.ex` 174/175 returns `nil`, and `_error -> nil`
|
||||
> sites exist in `scripting/capabilities/app_shell.ex:142` and `settings_editor/ai_settings.ex:174`. Triage all hits, not just the table.
|
||||
|
||||
| File | Lines | Clause / return |
|
||||
|------|-------|-----------------|
|
||||
| `lib/bds/desktop/shutdown.ex` | 5 `rescue` + 5 `catch` pairs: 27/29, 104/106, 122/124, 135/137, 160/162 | `_error -> :ok` / `:exit,_reason -> :ok` |
|
||||
| `lib/bds/desktop/main_window.ex` | 24/25 | `catch :exit,_reason -> :ok` |
|
||||
| `lib/bds/desktop/deep_link.ex` | 68/69 | `catch :exit,_reason -> :ok` |
|
||||
| `lib/bds/git.ex` | 564/565 | `catch :error,_reason -> :ok` |
|
||||
| `lib/bds/desktop/automation.ex` | 15/16, 222, 370/371 | `catch :exit -> :ok`, `rescue -> :ok`, `catch :error -> :ok` |
|
||||
| `lib/bds/preview.ex` | 448 | `rescue _error -> :ok` |
|
||||
| `lib/bds/desktop/shell_live/post_editor/list_values.ex` | 93 | `_error -> :ok` |
|
||||
| `lib/bds/desktop/shell_live/import_editor/analysis_state.ex` | 283 | `_error -> :ok` |
|
||||
| `lib/bds/desktop/shell_live/import_editor.ex` | 1420 | `_error -> :ok` |
|
||||
| `lib/bds/desktop/shell_live/chat_editor.ex` | 999 | `_error -> :ok` |
|
||||
| `lib/bds/mcp/server.ex` | 338 (`rescue` keyword), body 339 | `_error -> :ok` |
|
||||
| `lib/bds/embeddings/index.ex` | 347/348 → `:ok`, **387/388 → `:error`** | both `rescue _exception -> ...` |
|
||||
|
||||
**Action:** add a log line to each swallowing clause without changing control flow or its return value. The recipe is per-clause, not a single find/replace — the var name and return value vary:
|
||||
- `rescue`/`catch` returning `:ok` → bind the value and log, keep `:ok`: `error -> Logger.debug("swallowed in <ctx>: #{inspect(error)}"); :ok`
|
||||
- The `index.ex:387` clause returns **`:error`**, not `:ok` — log, then keep returning `:error`. The `_error -> :ok` recipe does not apply here.
|
||||
- `catch :exit,_reason`/`catch :error,_reason` clauses bind `_reason` (rename to `reason`), not `error`.
|
||||
|
||||
Use `Logger.warning` only where the error is genuinely unexpected.
|
||||
|
||||
**`shutdown.ex` / Wx-teardown exception:** the `shutdown.ex`, `main_window.ex`, and `deep_link.ex` catches wrap Wx event-table / `Desktop.Env` calls during teardown or headless startup. Use `Logger.debug` only — do not escalate to `warning`.
|
||||
|
||||
**Done when:** no `rescue`/`catch` clause in the listed files (plus any found by the re-sweep) discards its error without a log line, and every clause keeps its original return value.
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Add @spec to tasks.ex and embeddings.ex
|
||||
|
||||
| File | Public functions | Current @specs |
|
||||
|------|------------------|----------------|
|
||||
| `lib/bds/tasks.ex` | 10 non-callback public functions after Task 1 | 0 |
|
||||
| `lib/bds/embeddings.ex` | 20 | 0 |
|
||||
|
||||
**Scope of "public functions":** spec every non-callback public function. In `tasks.ex` after deletion that is **10**: the 8 client-API functions (`submit_task/3`, `get_task/1`, `status_snapshot/0`, `list_tasks/0`, `list_running_tasks/0`, `clear_completed/0`, `clear_finished/0`, `cancel_task/1`) **plus** `start_link/1` (line 12) and `topic/0` (line 16) — `topic/0` is used outside the module (`shell_commands.ex:566`). Do NOT spec the three `@impl` GenServer callbacks (`init/1` line 67, `handle_call/3` line 79, `handle_info/2` line 208); `start_link/1` is **not** an `@impl` callback. (`tasks.ex` has 31 total public `def`s; 21 are callback clauses.) Limited to these two files — adjacent surfaces like `ai.ex` are already fully specced; this is not a repo-wide gap.
|
||||
|
||||
**Action:** add `@spec` to every public function. Run `mix dialyzer` to validate inferred types.
|
||||
**Sequence:** do this after Task 1 so you don't spec the deleted functions.
|
||||
**Done when:** 100% `@spec` coverage on public functions in both files; dialyzer clean.
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Dedup sidebar `maybe_where_all_*` helpers
|
||||
|
||||
**File:** `lib/bds/ui/sidebar.ex` (1,137 lines)
|
||||
|
||||
Three near-identical helpers — `maybe_where_all_tags/2` (429), `maybe_where_all_categories/2` (445), `maybe_where_all_media_tags/2` (695) — differing only in the JSON field name and one extra `AND lower(value) != 'page'` condition.
|
||||
|
||||
**Action:** extract one parametric helper, keeping the `(query, []) -> query` empty-list clauses. All three queries use a single positional binding, so do NOT pass a binding argument — parametrize over the JSON field atom and an optional "exclude page" flag, building the fragment with `field(x, ^field_atom)`:
|
||||
```elixir
|
||||
defp where_all_in_json_array(query, items, field_atom, exclude_page? \\ false)
|
||||
```
|
||||
Three ~15-line functions become one helper + three 2-line callers.
|
||||
**Done when:** identical query output (verify via existing sidebar/search tests); build/credo clean.
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Enrich the AI facade @moduledoc (no code change)
|
||||
|
||||
**File:** `lib/bds/ai.ex` (210 lines)
|
||||
|
||||
This module is the stable public AI surface and the airplane-mode boundary required by `AGENTS.md`. It is NOT pure delegation: 8 functions hold real logic — `put_endpoint`, `get_endpoint`, `delete_endpoint`, `set_airplane_mode`, `airplane_mode?`, `airplane_endpoint_configured?`, `put_model_preference`, `get_model_preference`. It is used by 6 production modules (`posts/auto_translation.ex`, `scripting/capabilities/bridges.ex`, `chat_editor/message_build.ex`, `chat_editor/model_selection.ex`, `settings_editor/ai_settings.ex`, `sidebar_create.ex`). The MCP server does not use it (0 `BDS.AI` references in `lib/bds/mcp/`).
|
||||
|
||||
A `@moduledoc` already exists (line 2) but only describes endpoint/catalog/dispatch. **Action:** extend it to state that this module owns the airplane-mode / endpoint / model-preference logic and is the stable public AI surface, with `Catalog`/`OneShot`/`Chat` as internals. No code change.
|
||||
|
||||
---
|
||||
|
||||
## Task 6 — WITHDRAWN (already handled)
|
||||
|
||||
Original claim: `Task.async/1` in `sidebar_create.ex:50` result is never handled.
|
||||
|
||||
**False on current HEAD.** The parent LiveView already handles both the task
|
||||
reply and the monitor-down path: `handle_info({ref, result}, ...)` at
|
||||
`shell_live.ex:563` (→ `handle_file_picker_result/2`) and
|
||||
`handle_info({:DOWN, ref, ...}, ...)` at `shell_live.ex:585`, both keyed on the
|
||||
stored `file_picker_task` ref. The result is not lost.
|
||||
|
||||
Only remaining nit (not worth its own task): the picker uses `Task.async`
|
||||
(linked) rather than `Task.Supervisor.async_nolink`, so a crash in the picker
|
||||
task *could* take down the LiveView before `{:DOWN}` fires. Low-risk, not
|
||||
zero-risk: `file_picker.ex` is a simple path that mostly returns tagged tuples.
|
||||
No reproduced failure mode and no separate task — if `sidebar_create.ex:50` is
|
||||
edited for another reason, switch `Task.async` → `Task.Supervisor.async_nolink`
|
||||
opportunistically.
|
||||
|
||||
---
|
||||
|
||||
## Task 7 — Dedup `search_posts` / `search_media`
|
||||
|
||||
**File:** `lib/bds/search.ex` (862 lines)
|
||||
|
||||
Mirrored trios: `search_posts/3` (108) → `search_posts_blank/2` (118) → `search_posts_fts/3` (139), and `search_media/3` (182) → `search_media_blank/2` (192) → `search_media_fts/3` (213), plus `apply_post_filters/2` (254) / `apply_media_filters/2` (267). Same CTE+join FTS *pattern*.
|
||||
|
||||
**The shapes are NOT identical** (earlier "common `%{entities:}`" claim was wrong):
|
||||
- Return maps differ by key: `%{posts: ...}` (132, 167) vs `%{media: ...}` (206, 241) — no shared `entities:` key.
|
||||
- FTS subquery select + join keys differ: `post_id` (146, 152) vs `media_id` (220, 226).
|
||||
|
||||
**Action:** do NOT force one broad `search_entities/5`. Extract the genuinely shared *pieces* instead — blank-search query builder, FTS CTE/join setup (parametrized by FTS table + join-key column), and pagination assembly — and let each entity keep its own result key and filter function. Smaller shared helpers, not one generic mega-function.
|
||||
**Caution:** FTS SQL and filter semantics must stay byte-identical per entity, and each public function must keep returning its existing `%{posts:}` / `%{media:}` shape — verify against existing search tests before and after.
|
||||
**Done when:** all search tests pass unchanged; net line reduction; credo clean.
|
||||
|
||||
---
|
||||
|
||||
## Task 8 — Extract api_docs static data (only if recompile hurts)
|
||||
|
||||
**File:** `lib/bds/scripting/api_docs.ex` (1,554 lines)
|
||||
|
||||
A `@methods` module attribute of map literals, surfaced by a public `render/0`
|
||||
(`api_docs.ex:1211`); any API change forces a full recompile. (The module is
|
||||
not inert static data — the earlier "0 public functions" note was wrong.)
|
||||
|
||||
**Conditional, not a default cleanup.** Extracting to `priv/api_docs/methods.yaml`
|
||||
(or JSON) loaded at startup trades compile-time structure for runtime schema
|
||||
validation — a tradeoff, not a clear win. **Only do this after measuring actual
|
||||
compile pain.** If recompile isn't hurting, skip. The API-docs-sync tests
|
||||
(`AGENTS.md`) must still pass against the loaded data.
|
||||
|
||||
---
|
||||
|
||||
## Task 9 — Split oversized files (one per PR)
|
||||
|
||||
Guideline: no file > ~600 lines; split by responsibility.
|
||||
|
||||
| File | Lines | Notes |
|
||||
|------|-------|-------|
|
||||
| `scripting/api_docs.ex` | 1,554 | mostly data; if Task 8 runs it shrinks, but Task 8 is conditional/skippable — if skipped, split the non-data code here instead |
|
||||
| `desktop/shell_live.ex` | 1,466 | 68 public functions — split into sub-modules; do this first |
|
||||
| `ui/sidebar.ex` | 1,137 | shrinks after Task 4 |
|
||||
| `ai/chat_tools.ex` | 1,096 | review for splitting |
|
||||
| `ai/chat.ex` | 1,024 | review for splitting |
|
||||
| `search.ex` | 862 | shrinks after Task 7; split → `search/posts.ex`, `search/media.ex`, `search/filters.ex` |
|
||||
|
||||
**Action:** lowest priority; one file per change set, each its own reviewable PR. Start with `shell_live.ex`.
|
||||
|
||||
---
|
||||
|
||||
## Order
|
||||
|
||||
1. Task 1 — delete dead task API (pure deletion, no behavior risk)
|
||||
2. Task 2 — log swallowed errors
|
||||
3. Task 3 — @specs (after Task 1)
|
||||
4. Task 4 — sidebar dedup
|
||||
5. Task 5 — AI facade moduledoc
|
||||
6. ~~Task 6~~ — withdrawn (already handled in `shell_live.ex`)
|
||||
7. Task 7 — search dedup
|
||||
8. Task 8 — api_docs extract (conditional: only if recompile pain is measured)
|
||||
9. Task 9 — file splitting (ongoing, lowest priority)
|
||||
@@ -1,7 +1,15 @@
|
||||
defmodule BDS.AI do
|
||||
@moduledoc """
|
||||
Public interface for AI features — endpoint configuration, secret management,
|
||||
model catalog access, and dispatching chat and one-shot inference requests.
|
||||
Stable public interface for AI features.
|
||||
|
||||
This facade owns the app-level AI state that other modules are expected to
|
||||
depend on directly: endpoint configuration, secret management, airplane-mode
|
||||
gating, and per-feature model preferences, alongside model catalog access and
|
||||
dispatching chat and one-shot inference requests.
|
||||
|
||||
`BDS.AI.Catalog`, `BDS.AI.OneShot`, and `BDS.AI.Chat` are internal
|
||||
implementation modules behind this boundary. Callers should treat `BDS.AI` as
|
||||
the durable surface for AI behavior and configuration.
|
||||
"""
|
||||
|
||||
alias BDS.AI.Catalog
|
||||
|
||||
@@ -9,6 +9,7 @@ defmodule BDS.AI.Chat do
|
||||
alias BDS.AI.CatalogProvider
|
||||
alias BDS.AI.ChatConversation
|
||||
alias BDS.AI.ChatMessage
|
||||
alias BDS.AI.ChatTitleGenerator
|
||||
alias BDS.AI.ChatTools
|
||||
alias BDS.AI.InFlight
|
||||
alias BDS.AI.OpenAICompatibleRuntime
|
||||
@@ -24,8 +25,6 @@ defmodule BDS.AI.Chat do
|
||||
|
||||
@default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts."
|
||||
@default_max_output_tokens 16_384
|
||||
@title_max_output_tokens 256
|
||||
@chat_title_max_length 30
|
||||
@chat_max_tool_rounds 10
|
||||
@chat_await_timeout_margin_ms 5_000
|
||||
@default_context_window 128_000
|
||||
@@ -34,7 +33,7 @@ defmodule BDS.AI.Chat do
|
||||
def start_chat(attrs \\ %{}) when is_map(attrs) do
|
||||
now = Persistence.now_ms()
|
||||
model = MapUtils.attr(attrs, :model)
|
||||
title = MapUtils.attr(attrs, :title) || generated_chat_title(model)
|
||||
title = MapUtils.attr(attrs, :title) || ChatTitleGenerator.generated_chat_title(model)
|
||||
|
||||
%ChatConversation{}
|
||||
|> ChatConversation.changeset(%{
|
||||
@@ -430,11 +429,11 @@ defmodule BDS.AI.Chat do
|
||||
conversation = Repo.get!(ChatConversation, conversation_id)
|
||||
|
||||
cond do
|
||||
chat_user_message_count(conversation_id) < 1 ->
|
||||
ChatTitleGenerator.chat_user_message_count(conversation_id) < 1 ->
|
||||
Logger.debug("Chat title generation skipped reason=:no_user_messages")
|
||||
{:ok, reply}
|
||||
|
||||
not generated_chat_title?(conversation.title, conversation.model) ->
|
||||
not ChatTitleGenerator.generated_chat_title?(conversation.title, conversation.model) ->
|
||||
Logger.debug(
|
||||
"Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}"
|
||||
)
|
||||
@@ -444,7 +443,7 @@ defmodule BDS.AI.Chat do
|
||||
true ->
|
||||
Logger.debug("Chat title generation requested conversation_id=#{conversation_id}")
|
||||
|
||||
case generate_chat_title(user_content, opts) do
|
||||
case ChatTitleGenerator.generate_chat_title(user_content, opts) do
|
||||
{:ok, title} when is_binary(title) and title != "" ->
|
||||
now = Persistence.now_ms()
|
||||
|
||||
@@ -465,87 +464,6 @@ defmodule BDS.AI.Chat do
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_chat_title(user_content, opts) when is_binary(user_content) do
|
||||
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
||||
|
||||
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
|
||||
:ok <- Runtime.validate_target(:chat_title, model, mode),
|
||||
request <- build_chat_title_request(user_content, model),
|
||||
{:ok, response} <-
|
||||
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
|
||||
title = sanitize_chat_title(Map.get(response, :content))
|
||||
|
||||
if title == "" do
|
||||
Logger.warning("Chat title generation returned an empty title",
|
||||
model: model,
|
||||
content: inspect(Map.get(response, :content)),
|
||||
usage: inspect(Map.get(response, :usage))
|
||||
)
|
||||
end
|
||||
|
||||
{:ok, title}
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.warning("Chat title generation failed", reason: inspect(reason))
|
||||
error
|
||||
|
||||
other ->
|
||||
Logger.warning("Chat title generation failed", reason: inspect(other))
|
||||
other
|
||||
end
|
||||
end
|
||||
|
||||
defp build_chat_title_request(user_content, model) do
|
||||
%{
|
||||
operation: :chat_title,
|
||||
model: model,
|
||||
max_output_tokens: @title_max_output_tokens,
|
||||
messages: [
|
||||
%{
|
||||
"role" => "system",
|
||||
"content" =>
|
||||
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text."
|
||||
},
|
||||
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
defp sanitize_chat_title(title) when is_binary(title) do
|
||||
title =
|
||||
title
|
||||
|> String.trim()
|
||||
|> String.trim_leading("\"")
|
||||
|> String.trim_leading("'")
|
||||
|> String.trim_trailing("\"")
|
||||
|> String.trim_trailing("'")
|
||||
|> String.trim_trailing(".")
|
||||
|> String.trim_trailing("!")
|
||||
|> String.trim_trailing("?")
|
||||
|
||||
if String.length(title) > @chat_title_max_length do
|
||||
String.slice(title, 0, @chat_title_max_length - 3) <> "..."
|
||||
else
|
||||
title
|
||||
end
|
||||
end
|
||||
|
||||
defp sanitize_chat_title(_title), do: ""
|
||||
|
||||
defp chat_user_message_count(conversation_id) do
|
||||
Repo.aggregate(
|
||||
from(message in ChatMessage,
|
||||
where: message.conversation_id == ^conversation_id and message.role == :user
|
||||
),
|
||||
:count,
|
||||
:id
|
||||
)
|
||||
end
|
||||
|
||||
defp generated_chat_title?(title, model) do
|
||||
title in [generated_chat_title(nil), generated_chat_title(model)]
|
||||
end
|
||||
|
||||
defp chat_round(
|
||||
_conversation,
|
||||
_messages,
|
||||
@@ -882,9 +800,6 @@ defmodule BDS.AI.Chat do
|
||||
)
|
||||
end
|
||||
|
||||
defp generated_chat_title(nil), do: "New Chat"
|
||||
defp generated_chat_title(model), do: "Chat with #{model}"
|
||||
|
||||
defp load_chat_messages(conversation_id) do
|
||||
Repo.all(
|
||||
from message in ChatMessage,
|
||||
|
||||
99
lib/bds/ai/chat_title_generator.ex
Normal file
99
lib/bds/ai/chat_title_generator.ex
Normal file
@@ -0,0 +1,99 @@
|
||||
defmodule BDS.AI.ChatTitleGenerator do
|
||||
@moduledoc false
|
||||
|
||||
require Logger
|
||||
|
||||
alias BDS.AI.ChatMessage
|
||||
alias BDS.AI.OpenAICompatibleRuntime
|
||||
alias BDS.AI.Runtime
|
||||
alias BDS.Repo
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
@title_max_output_tokens 256
|
||||
@chat_title_max_length 30
|
||||
|
||||
def generate_chat_title(user_content, opts) when is_binary(user_content) do
|
||||
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
||||
|
||||
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
|
||||
:ok <- Runtime.validate_target(:chat_title, model, mode),
|
||||
request <- build_chat_title_request(user_content, model),
|
||||
{:ok, response} <-
|
||||
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
|
||||
title = sanitize_chat_title(Map.get(response, :content))
|
||||
|
||||
if title == "" do
|
||||
Logger.warning("Chat title generation returned an empty title",
|
||||
model: model,
|
||||
content: inspect(Map.get(response, :content)),
|
||||
usage: inspect(Map.get(response, :usage))
|
||||
)
|
||||
end
|
||||
|
||||
{:ok, title}
|
||||
else
|
||||
{:error, reason} = error ->
|
||||
Logger.warning("Chat title generation failed", reason: inspect(reason))
|
||||
error
|
||||
|
||||
other ->
|
||||
Logger.warning("Chat title generation failed", reason: inspect(other))
|
||||
other
|
||||
end
|
||||
end
|
||||
|
||||
def build_chat_title_request(user_content, model) do
|
||||
%{
|
||||
operation: :chat_title,
|
||||
model: model,
|
||||
max_output_tokens: @title_max_output_tokens,
|
||||
messages: [
|
||||
%{
|
||||
"role" => "system",
|
||||
"content" =>
|
||||
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text."
|
||||
},
|
||||
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
def sanitize_chat_title(title) when is_binary(title) do
|
||||
title =
|
||||
title
|
||||
|> String.trim()
|
||||
|> String.trim_leading("\"")
|
||||
|> String.trim_leading("'")
|
||||
|> String.trim_trailing("\"")
|
||||
|> String.trim_trailing("'")
|
||||
|> String.trim_trailing(".")
|
||||
|> String.trim_trailing("!")
|
||||
|> String.trim_trailing("?")
|
||||
|
||||
if String.length(title) > @chat_title_max_length do
|
||||
String.slice(title, 0, @chat_title_max_length - 3) <> "..."
|
||||
else
|
||||
title
|
||||
end
|
||||
end
|
||||
|
||||
def sanitize_chat_title(_title), do: ""
|
||||
|
||||
def chat_user_message_count(conversation_id) do
|
||||
Repo.aggregate(
|
||||
from(message in ChatMessage,
|
||||
where: message.conversation_id == ^conversation_id and message.role == :user
|
||||
),
|
||||
:count,
|
||||
:id
|
||||
)
|
||||
end
|
||||
|
||||
def generated_chat_title?(title, model) do
|
||||
title in [generated_chat_title(nil), generated_chat_title(model)]
|
||||
end
|
||||
|
||||
def generated_chat_title(nil), do: "New Chat"
|
||||
def generated_chat_title(model), do: "Chat with #{model}"
|
||||
end
|
||||
49
lib/bds/ai/chat_tool_query_helpers.ex
Normal file
49
lib/bds/ai/chat_tool_query_helpers.ex
Normal file
@@ -0,0 +1,49 @@
|
||||
defmodule BDS.AI.ChatToolQueryHelpers do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.Search
|
||||
|
||||
def normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
|
||||
def normalize_limit(_value), do: 10
|
||||
|
||||
def normalize_offset(value) when is_integer(value) and value >= 0, do: value
|
||||
def normalize_offset(_value), do: 0
|
||||
|
||||
def search_filters(arguments) do
|
||||
%{}
|
||||
|> BDS.MapUtils.maybe_put(:category, BDS.MapUtils.blank_to_nil(arguments["category"]))
|
||||
|> BDS.MapUtils.maybe_put(:tags, BDS.MapUtils.blank_to_nil(arguments["tags"]))
|
||||
|> BDS.MapUtils.maybe_put(:language, BDS.MapUtils.blank_to_nil(arguments["language"]))
|
||||
|> BDS.MapUtils.maybe_put(
|
||||
:missing_translation_language,
|
||||
BDS.MapUtils.blank_to_nil(arguments["missingTranslationLanguage"])
|
||||
)
|
||||
|> BDS.MapUtils.maybe_put(:year, arguments["year"])
|
||||
|> BDS.MapUtils.maybe_put(:month, arguments["month"])
|
||||
|> BDS.MapUtils.maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|
||||
|> Map.put(:offset, normalize_offset(arguments["offset"]))
|
||||
|> Map.put(:limit, normalize_limit(arguments["limit"]))
|
||||
end
|
||||
|
||||
def search_all_counted_posts(project_id, arguments) do
|
||||
filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
|
||||
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
|
||||
|
||||
filters = Map.put(filters, :limit, max(total, 1))
|
||||
{:ok, result} = Search.search_posts(project_id, "", filters)
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def search_result(project_id, query, filters, mapper) do
|
||||
{:ok, result} = Search.search_posts(project_id, query, filters)
|
||||
|
||||
%{
|
||||
posts: Enum.map(result.posts, mapper),
|
||||
total: result.total,
|
||||
offset: result.offset,
|
||||
limit: result.limit,
|
||||
has_more: result.offset + result.limit < result.total
|
||||
}
|
||||
end
|
||||
end
|
||||
350
lib/bds/ai/chat_tool_schemas.ex
Normal file
350
lib/bds/ai/chat_tool_schemas.ex
Normal file
@@ -0,0 +1,350 @@
|
||||
defmodule BDS.AI.ChatToolSchemas do
|
||||
@moduledoc false
|
||||
|
||||
def tool_spec(name, description, parameters) do
|
||||
%{
|
||||
"type" => "function",
|
||||
"function" => %{
|
||||
"name" => name,
|
||||
"description" => description,
|
||||
"parameters" => parameters
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def limit_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def post_search_schema(require_query) do
|
||||
schema = %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"query" => %{"type" => "string"},
|
||||
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
|
||||
"category" => %{"type" => "string"},
|
||||
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||
"language" => %{"type" => "string"},
|
||||
"missingTranslationLanguage" => %{"type" => "string"},
|
||||
"year" => %{"type" => "integer"},
|
||||
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
|
||||
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50},
|
||||
"offset" => %{"type" => "integer", "minimum" => 0}
|
||||
}
|
||||
}
|
||||
|
||||
if require_query, do: Map.put(schema, "required", ["query"]), else: schema
|
||||
end
|
||||
|
||||
def count_posts_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"groupBy" => %{
|
||||
"type" => "array",
|
||||
"items" => %{
|
||||
"type" => "string",
|
||||
"enum" => ["year", "month", "tag", "category", "status"]
|
||||
}
|
||||
},
|
||||
"year" => %{"type" => "integer"},
|
||||
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
|
||||
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
|
||||
"category" => %{"type" => "string"},
|
||||
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
|
||||
},
|
||||
"required" => ["groupBy"]
|
||||
}
|
||||
end
|
||||
|
||||
def post_id_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{"postId" => %{"type" => "string"}},
|
||||
"required" => ["postId"]
|
||||
}
|
||||
end
|
||||
|
||||
def media_id_schema(extra_properties \\ %{}) do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties),
|
||||
"required" => ["mediaId"]
|
||||
}
|
||||
end
|
||||
|
||||
def update_post_metadata_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"postId" => %{"type" => "string"},
|
||||
"title" => %{"type" => "string"},
|
||||
"excerpt" => %{"type" => "string"},
|
||||
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||
"categories" => %{"type" => "array", "items" => %{"type" => "string"}}
|
||||
},
|
||||
"required" => ["postId"]
|
||||
}
|
||||
end
|
||||
|
||||
def update_media_metadata_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"mediaId" => %{"type" => "string"},
|
||||
"title" => %{"type" => "string"},
|
||||
"alt" => %{"type" => "string"},
|
||||
"caption" => %{"type" => "string"},
|
||||
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
|
||||
},
|
||||
"required" => ["mediaId"]
|
||||
}
|
||||
end
|
||||
|
||||
def render_table_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string", "description" => "Optional table title"},
|
||||
"columns" => %{
|
||||
"type" => "array",
|
||||
"items" => %{"type" => "string"},
|
||||
"description" => "Column header names"
|
||||
},
|
||||
"rows" => %{
|
||||
"type" => "array",
|
||||
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||
"description" => "Table rows, each row is an array of cell values"
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def render_chart_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"chartType" => %{
|
||||
"type" => "string",
|
||||
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
|
||||
"description" =>
|
||||
"The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations."
|
||||
},
|
||||
"title" => %{"type" => "string", "description" => "Optional chart title"},
|
||||
"series" => %{
|
||||
"type" => "array",
|
||||
"description" => "Array of data points.",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string", "description" => "Data point label"},
|
||||
"value" => %{"type" => "number", "description" => "Data point value"},
|
||||
"segments" => %{
|
||||
"type" => "array",
|
||||
"description" =>
|
||||
"Segments within this data point. Required for stacked-bar and heatmap charts.",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string"},
|
||||
"value" => %{"type" => "number"}
|
||||
},
|
||||
"required" => ["label", "value"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required" => ["label"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required" => ["chartType", "series"]
|
||||
}
|
||||
end
|
||||
|
||||
def render_form_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string", "description" => "Optional form title"},
|
||||
"fields" => %{
|
||||
"type" => "array",
|
||||
"description" => "Form fields to display",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"key" => %{"type" => "string", "description" => "Field identifier"},
|
||||
"label" => %{"type" => "string", "description" => "Field label shown to user"},
|
||||
"inputType" => %{
|
||||
"type" => "string",
|
||||
"enum" => ["text", "textarea", "select", "checkbox", "date", "number"],
|
||||
"description" => "Type of input control"
|
||||
},
|
||||
"placeholder" => %{"type" => "string", "description" => "Placeholder text"},
|
||||
"defaultValue" => %{"type" => "string", "description" => "Default value"},
|
||||
"options" => %{
|
||||
"type" => "array",
|
||||
"description" => "Options for select fields",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string"},
|
||||
"value" => %{"type" => "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required" => %{"type" => "boolean", "description" => "Whether the field is required"}
|
||||
},
|
||||
"required" => ["key", "label", "inputType"]
|
||||
}
|
||||
},
|
||||
"submitLabel" => %{"type" => "string", "description" => "Label for the submit button"},
|
||||
"submitAction" => %{"type" => "string", "description" => "Action to dispatch on submit"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def render_card_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string", "description" => "Card title"},
|
||||
"subtitle" => %{"type" => "string", "description" => "Optional subtitle"},
|
||||
"body" => %{"type" => "string", "description" => "Card body text (supports markdown)"},
|
||||
"actions" => %{
|
||||
"type" => "array",
|
||||
"description" => "Optional action buttons on the card",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string", "description" => "Button label"},
|
||||
"action" => %{"type" => "string", "description" => "Action name to dispatch"},
|
||||
"payload" => %{
|
||||
"type" => "object",
|
||||
"description" => "Optional action payload"
|
||||
}
|
||||
},
|
||||
"required" => ["label", "action"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def render_metric_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string", "description" => "Metric label"},
|
||||
"value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def render_list_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string", "description" => "Optional list title"},
|
||||
"items" => %{
|
||||
"type" => "array",
|
||||
"items" => %{"type" => "string"},
|
||||
"description" => "List items"
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def render_tabs_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string", "description" => "Optional tabs title"},
|
||||
"tabs" => %{
|
||||
"type" => "array",
|
||||
"description" => "Array of tabs",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string", "description" => "Tab label"},
|
||||
"content" => %{
|
||||
"type" => "array",
|
||||
"description" => "Content items within the tab",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"type" => %{
|
||||
"type" => "string",
|
||||
"enum" => ["text", "metric", "list", "chart", "table"],
|
||||
"description" => "Content type"
|
||||
},
|
||||
"text" => %{"type" => "string", "description" => "Text content (for type text)"},
|
||||
"label" => %{"type" => "string", "description" => "Label (for type metric)"},
|
||||
"value" => %{"type" => "string", "description" => "Display value (for type metric)"},
|
||||
"title" => %{"type" => "string", "description" => "Title (for type list, chart, or table)"},
|
||||
"items" => %{
|
||||
"type" => "array",
|
||||
"items" => %{"type" => "string"},
|
||||
"description" => "Items (for type list)"
|
||||
},
|
||||
"chartType" => %{
|
||||
"type" => "string",
|
||||
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
|
||||
"description" => "Chart type (for type chart)"
|
||||
},
|
||||
"series" => %{
|
||||
"type" => "array",
|
||||
"description" => "Data series (for type chart)"
|
||||
},
|
||||
"columns" => %{
|
||||
"type" => "array",
|
||||
"items" => %{"type" => "string"},
|
||||
"description" => "Column headers (for type table)"
|
||||
},
|
||||
"rows" => %{
|
||||
"type" => "array",
|
||||
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||
"description" => "Table rows (for type table)"
|
||||
}
|
||||
},
|
||||
"required" => ["type"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required" => ["label", "content"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def render_mindmap_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string", "description" => "Optional mind map title"},
|
||||
"nodes" => %{
|
||||
"type" => "array",
|
||||
"description" => "Flat array of nodes. The first node is the root. Each node references children by ID.",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"id" => %{"type" => "string", "description" => "Unique node identifier"},
|
||||
"label" => %{"type" => "string", "description" => "Node label text"},
|
||||
"children" => %{
|
||||
"type" => "array",
|
||||
"items" => %{"type" => "string"},
|
||||
"description" => "IDs of child nodes"
|
||||
}
|
||||
},
|
||||
"required" => ["id", "label"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,8 @@ defmodule BDS.AI.ChatTools do
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.AI.Chat
|
||||
alias BDS.AI.ChatToolQueryHelpers, as: QueryHelpers
|
||||
alias BDS.AI.ChatToolSchemas, as: Schemas
|
||||
alias BDS.Media, as: MediaContext
|
||||
alias BDS.Media.Media
|
||||
alias BDS.MCP.Queries
|
||||
@@ -12,7 +14,6 @@ defmodule BDS.AI.ChatTools do
|
||||
alias BDS.Posts.PostMedia
|
||||
alias BDS.Projects.Project
|
||||
alias BDS.Repo
|
||||
alias BDS.Search
|
||||
|
||||
@spec execute(String.t(), map(), String.t() | nil) :: map()
|
||||
def execute("blog_stats", _arguments, project_id) do
|
||||
@@ -58,8 +59,14 @@ defmodule BDS.AI.ChatTools do
|
||||
|
||||
def execute("search_posts", arguments, project_id) do
|
||||
project_id = project_id || active_project_id()
|
||||
filters = search_filters(arguments)
|
||||
search_result(project_id, arguments["query"] || "", filters, &Queries.post_summary/1)
|
||||
filters = QueryHelpers.search_filters(arguments)
|
||||
|
||||
QueryHelpers.search_result(
|
||||
project_id,
|
||||
arguments["query"] || "",
|
||||
filters,
|
||||
&Queries.post_summary/1
|
||||
)
|
||||
end
|
||||
|
||||
def execute("read_post_by_slug", arguments, project_id) do
|
||||
@@ -81,11 +88,11 @@ defmodule BDS.AI.ChatTools do
|
||||
|
||||
def execute("list_posts", arguments, project_id) do
|
||||
project_id = project_id || active_project_id()
|
||||
limit = normalize_limit(arguments["limit"])
|
||||
offset = normalize_offset(arguments["offset"])
|
||||
filters = search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset})
|
||||
limit = QueryHelpers.normalize_limit(arguments["limit"])
|
||||
offset = QueryHelpers.normalize_offset(arguments["offset"])
|
||||
filters = QueryHelpers.search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset})
|
||||
|
||||
search_result(project_id, "", filters, fn post ->
|
||||
QueryHelpers.search_result(project_id, "", filters, fn post ->
|
||||
post
|
||||
|> Queries.post_summary()
|
||||
|> Map.put("url", "/posts/#{post.slug}")
|
||||
@@ -95,7 +102,7 @@ defmodule BDS.AI.ChatTools do
|
||||
|
||||
def execute("list_media", arguments, project_id) do
|
||||
project_id = project_id || active_project_id()
|
||||
limit = normalize_limit(arguments["limit"])
|
||||
limit = QueryHelpers.normalize_limit(arguments["limit"])
|
||||
|
||||
Repo.all(
|
||||
from(media in Media,
|
||||
@@ -194,7 +201,7 @@ defmodule BDS.AI.ChatTools do
|
||||
def execute("count_posts", arguments, project_id) do
|
||||
project_id = project_id || active_project_id()
|
||||
group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1)
|
||||
result = search_all_counted_posts(project_id, arguments)
|
||||
result = QueryHelpers.search_all_counted_posts(project_id, arguments)
|
||||
|
||||
groups =
|
||||
result.posts
|
||||
@@ -321,7 +328,7 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
name: "blog_stats",
|
||||
spec:
|
||||
tool_spec("blog_stats", "Return aggregate blog statistics", %{
|
||||
Schemas.tool_spec("blog_stats", "Return aggregate blog statistics", %{
|
||||
"type" => "object",
|
||||
"properties" => %{}
|
||||
})
|
||||
@@ -329,7 +336,7 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
name: "get_blog_stats",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"get_blog_stats",
|
||||
"Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.",
|
||||
%{"type" => "object", "properties" => %{}}
|
||||
@@ -338,7 +345,7 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
name: "check_term",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"check_term",
|
||||
"Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or list_posts when unsure whether a term is a category or tag.",
|
||||
%{
|
||||
@@ -351,16 +358,16 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
name: "search_posts",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"search_posts",
|
||||
"Search blog posts using full-text search. Can filter by category, tags, language, missing translation language, year, month, or status. Returns paginated concrete post data with titles, slugs, tags, categories, backlinks, and links_to.",
|
||||
post_search_schema(true)
|
||||
Schemas.post_search_schema(true)
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "read_post",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"read_post",
|
||||
"Read full content and metadata of a specific blog post by ID. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
|
||||
%{
|
||||
@@ -373,7 +380,7 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
name: "read_post_by_slug",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"read_post_by_slug",
|
||||
"Read full content and metadata of a specific blog post by slug. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
|
||||
%{
|
||||
@@ -386,37 +393,37 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
name: "list_posts",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"list_posts",
|
||||
"List blog posts with optional filtering by status, category, tags, language, year, or month. Returns paginated concrete post data with titles, slugs, URLs, statuses, tags, categories, backlinks, and links_to. Use for recent, latest, top, or title-list requests.",
|
||||
post_search_schema(false)
|
||||
Schemas.post_search_schema(false)
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "get_media",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"get_media",
|
||||
"Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.",
|
||||
media_id_schema()
|
||||
Schemas.media_id_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "list_media",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"list_media",
|
||||
"List concrete media data in the active project, including titles, filenames, MIME types, and update times.",
|
||||
limit_schema()
|
||||
Schemas.limit_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "view_image",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"view_image",
|
||||
"View an image thumbnail as a local data URL for visual inspection. Only works with image media files.",
|
||||
media_id_schema(%{
|
||||
Schemas.media_id_schema(%{
|
||||
"size" => %{"type" => "string", "enum" => ["small", "medium", "large"]}
|
||||
})
|
||||
)
|
||||
@@ -424,25 +431,25 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
name: "update_post_metadata",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"update_post_metadata",
|
||||
"Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.",
|
||||
update_post_metadata_schema()
|
||||
Schemas.update_post_metadata_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "update_media_metadata",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"update_media_metadata",
|
||||
"Update metadata for a media file: title, alt text, caption, or tags.",
|
||||
update_media_metadata_schema()
|
||||
Schemas.update_media_metadata_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "list_tags",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"list_tags",
|
||||
"List all tags used across blog posts with post counts.",
|
||||
%{
|
||||
@@ -454,7 +461,7 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
name: "list_categories",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"list_categories",
|
||||
"List all categories used across blog posts with post counts.",
|
||||
%{"type" => "object", "properties" => %{}}
|
||||
@@ -463,46 +470,46 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
name: "count_posts",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"count_posts",
|
||||
"Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.",
|
||||
count_posts_schema()
|
||||
Schemas.count_posts_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "get_post_backlinks",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"get_post_backlinks",
|
||||
"Get all posts that link to a specific post.",
|
||||
post_id_schema()
|
||||
Schemas.post_id_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "get_post_outlinks",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"get_post_outlinks",
|
||||
"Get all posts that a specific post links to.",
|
||||
post_id_schema()
|
||||
Schemas.post_id_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "get_post_media",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"get_post_media",
|
||||
"Get media files linked to a specific post.",
|
||||
post_id_schema()
|
||||
Schemas.post_id_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "get_media_posts",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"get_media_posts",
|
||||
"Get posts that use a specific media file.",
|
||||
media_id_schema()
|
||||
Schemas.media_id_schema()
|
||||
)
|
||||
}
|
||||
]
|
||||
@@ -515,73 +522,73 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
name: "render_card",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"render_card",
|
||||
"Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.",
|
||||
render_card_schema()
|
||||
Schemas.render_card_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "render_table",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"render_table",
|
||||
"Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.",
|
||||
render_table_schema()
|
||||
Schemas.render_table_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "render_chart",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"render_chart",
|
||||
"Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.",
|
||||
render_chart_schema()
|
||||
Schemas.render_chart_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "render_form",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"render_form",
|
||||
"Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.",
|
||||
render_form_schema()
|
||||
Schemas.render_form_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "render_metric",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"render_metric",
|
||||
"Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.",
|
||||
render_metric_schema()
|
||||
Schemas.render_metric_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "render_list",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"render_list",
|
||||
"Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.",
|
||||
render_list_schema()
|
||||
Schemas.render_list_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "render_tabs",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"render_tabs",
|
||||
"Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.",
|
||||
render_tabs_schema()
|
||||
Schemas.render_tabs_schema()
|
||||
)
|
||||
},
|
||||
%{
|
||||
name: "render_mindmap",
|
||||
spec:
|
||||
tool_spec(
|
||||
Schemas.tool_spec(
|
||||
"render_mindmap",
|
||||
"Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.",
|
||||
render_mindmap_schema()
|
||||
Schemas.render_mindmap_schema()
|
||||
)
|
||||
}
|
||||
]
|
||||
@@ -590,394 +597,6 @@ defmodule BDS.AI.ChatTools do
|
||||
end
|
||||
end
|
||||
|
||||
defp tool_spec(name, description, parameters) do
|
||||
%{
|
||||
"type" => "function",
|
||||
"function" => %{
|
||||
"name" => name,
|
||||
"description" => description,
|
||||
"parameters" => parameters
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp limit_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp post_search_schema(require_query) do
|
||||
schema = %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"query" => %{"type" => "string"},
|
||||
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
|
||||
"category" => %{"type" => "string"},
|
||||
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||
"language" => %{"type" => "string"},
|
||||
"missingTranslationLanguage" => %{"type" => "string"},
|
||||
"year" => %{"type" => "integer"},
|
||||
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
|
||||
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50},
|
||||
"offset" => %{"type" => "integer", "minimum" => 0}
|
||||
}
|
||||
}
|
||||
|
||||
if require_query, do: Map.put(schema, "required", ["query"]), else: schema
|
||||
end
|
||||
|
||||
defp count_posts_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"groupBy" => %{
|
||||
"type" => "array",
|
||||
"items" => %{
|
||||
"type" => "string",
|
||||
"enum" => ["year", "month", "tag", "category", "status"]
|
||||
}
|
||||
},
|
||||
"year" => %{"type" => "integer"},
|
||||
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
|
||||
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
|
||||
"category" => %{"type" => "string"},
|
||||
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
|
||||
},
|
||||
"required" => ["groupBy"]
|
||||
}
|
||||
end
|
||||
|
||||
defp post_id_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{"postId" => %{"type" => "string"}},
|
||||
"required" => ["postId"]
|
||||
}
|
||||
end
|
||||
|
||||
defp media_id_schema(extra_properties \\ %{}) do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties),
|
||||
"required" => ["mediaId"]
|
||||
}
|
||||
end
|
||||
|
||||
defp update_post_metadata_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"postId" => %{"type" => "string"},
|
||||
"title" => %{"type" => "string"},
|
||||
"excerpt" => %{"type" => "string"},
|
||||
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||
"categories" => %{"type" => "array", "items" => %{"type" => "string"}}
|
||||
},
|
||||
"required" => ["postId"]
|
||||
}
|
||||
end
|
||||
|
||||
defp update_media_metadata_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"mediaId" => %{"type" => "string"},
|
||||
"title" => %{"type" => "string"},
|
||||
"alt" => %{"type" => "string"},
|
||||
"caption" => %{"type" => "string"},
|
||||
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
|
||||
},
|
||||
"required" => ["mediaId"]
|
||||
}
|
||||
end
|
||||
|
||||
defp render_table_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string", "description" => "Optional table title"},
|
||||
"columns" => %{
|
||||
"type" => "array",
|
||||
"items" => %{"type" => "string"},
|
||||
"description" => "Column header names"
|
||||
},
|
||||
"rows" => %{
|
||||
"type" => "array",
|
||||
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||
"description" => "Table rows, each row is an array of cell values"
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_chart_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"chartType" => %{
|
||||
"type" => "string",
|
||||
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
|
||||
"description" =>
|
||||
"The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations."
|
||||
},
|
||||
"title" => %{"type" => "string", "description" => "Optional chart title"},
|
||||
"series" => %{
|
||||
"type" => "array",
|
||||
"description" => "Array of data points.",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string", "description" => "Data point label"},
|
||||
"value" => %{"type" => "number", "description" => "Data point value"},
|
||||
"segments" => %{
|
||||
"type" => "array",
|
||||
"description" =>
|
||||
"Segments within this data point. Required for stacked-bar and heatmap charts.",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string"},
|
||||
"value" => %{"type" => "number"}
|
||||
},
|
||||
"required" => ["label", "value"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required" => ["label"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required" => ["chartType", "series"]
|
||||
}
|
||||
end
|
||||
|
||||
defp render_form_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string", "description" => "Optional form title"},
|
||||
"fields" => %{
|
||||
"type" => "array",
|
||||
"description" => "Form fields to display",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"key" => %{"type" => "string", "description" => "Field identifier"},
|
||||
"label" => %{"type" => "string", "description" => "Field label shown to user"},
|
||||
"inputType" => %{
|
||||
"type" => "string",
|
||||
"enum" => ["text", "textarea", "select", "checkbox", "date", "number"],
|
||||
"description" => "Type of input control"
|
||||
},
|
||||
"placeholder" => %{"type" => "string", "description" => "Placeholder text"},
|
||||
"defaultValue" => %{"type" => "string", "description" => "Default value"},
|
||||
"options" => %{
|
||||
"type" => "array",
|
||||
"description" => "Options for select fields",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string"},
|
||||
"value" => %{"type" => "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required" => %{"type" => "boolean", "description" => "Whether the field is required"}
|
||||
},
|
||||
"required" => ["key", "label", "inputType"]
|
||||
}
|
||||
},
|
||||
"submitLabel" => %{"type" => "string", "description" => "Label for the submit button"},
|
||||
"submitAction" => %{"type" => "string", "description" => "Action to dispatch on submit"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_card_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string", "description" => "Card title"},
|
||||
"subtitle" => %{"type" => "string", "description" => "Optional subtitle"},
|
||||
"body" => %{"type" => "string", "description" => "Card body text (supports markdown)"},
|
||||
"actions" => %{
|
||||
"type" => "array",
|
||||
"description" => "Optional action buttons on the card",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string", "description" => "Button label"},
|
||||
"action" => %{"type" => "string", "description" => "Action name to dispatch"},
|
||||
"payload" => %{
|
||||
"type" => "object",
|
||||
"description" => "Optional action payload"
|
||||
}
|
||||
},
|
||||
"required" => ["label", "action"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_metric_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string", "description" => "Metric label"},
|
||||
"value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_list_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string", "description" => "Optional list title"},
|
||||
"items" => %{
|
||||
"type" => "array",
|
||||
"items" => %{"type" => "string"},
|
||||
"description" => "List items"
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_tabs_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string", "description" => "Optional tabs title"},
|
||||
"tabs" => %{
|
||||
"type" => "array",
|
||||
"description" => "Array of tabs",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string", "description" => "Tab label"},
|
||||
"content" => %{
|
||||
"type" => "array",
|
||||
"description" => "Content items within the tab",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"type" => %{
|
||||
"type" => "string",
|
||||
"enum" => ["text", "metric", "list", "chart", "table"],
|
||||
"description" => "Content type"
|
||||
},
|
||||
"text" => %{"type" => "string", "description" => "Text content (for type text)"},
|
||||
"label" => %{"type" => "string", "description" => "Label (for type metric)"},
|
||||
"value" => %{"type" => "string", "description" => "Display value (for type metric)"},
|
||||
"title" => %{"type" => "string", "description" => "Title (for type list, chart, or table)"},
|
||||
"items" => %{
|
||||
"type" => "array", "items" => %{"type" => "string"},
|
||||
"description" => "Items (for type list)"
|
||||
},
|
||||
"chartType" => %{
|
||||
"type" => "string",
|
||||
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
|
||||
"description" => "Chart type (for type chart)"
|
||||
},
|
||||
"series" => %{
|
||||
"type" => "array",
|
||||
"description" => "Data series (for type chart)"
|
||||
},
|
||||
"columns" => %{
|
||||
"type" => "array", "items" => %{"type" => "string"},
|
||||
"description" => "Column headers (for type table)"
|
||||
},
|
||||
"rows" => %{
|
||||
"type" => "array", "items" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||
"description" => "Table rows (for type table)"
|
||||
}
|
||||
},
|
||||
"required" => ["type"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required" => ["label", "content"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp render_mindmap_schema do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string", "description" => "Optional mind map title"},
|
||||
"nodes" => %{
|
||||
"type" => "array",
|
||||
"description" => "Flat array of nodes. The first node is the root. Each node references children by ID.",
|
||||
"items" => %{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"id" => %{"type" => "string", "description" => "Unique node identifier"},
|
||||
"label" => %{"type" => "string", "description" => "Node label text"},
|
||||
"children" => %{
|
||||
"type" => "array",
|
||||
"items" => %{"type" => "string"},
|
||||
"description" => "IDs of child nodes"
|
||||
}
|
||||
},
|
||||
"required" => ["id", "label"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
|
||||
defp normalize_limit(_value), do: 10
|
||||
|
||||
defp normalize_offset(value) when is_integer(value) and value >= 0, do: value
|
||||
defp normalize_offset(_value), do: 0
|
||||
|
||||
defp search_filters(arguments) do
|
||||
%{}
|
||||
|> BDS.MapUtils.maybe_put(:category, BDS.MapUtils.blank_to_nil(arguments["category"]))
|
||||
|> BDS.MapUtils.maybe_put(:tags, BDS.MapUtils.blank_to_nil(arguments["tags"]))
|
||||
|> BDS.MapUtils.maybe_put(:language, BDS.MapUtils.blank_to_nil(arguments["language"]))
|
||||
|> BDS.MapUtils.maybe_put(
|
||||
:missing_translation_language,
|
||||
BDS.MapUtils.blank_to_nil(arguments["missingTranslationLanguage"])
|
||||
)
|
||||
|> BDS.MapUtils.maybe_put(:year, arguments["year"])
|
||||
|> BDS.MapUtils.maybe_put(:month, arguments["month"])
|
||||
|> BDS.MapUtils.maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|
||||
|> Map.put(:offset, normalize_offset(arguments["offset"]))
|
||||
|> Map.put(:limit, normalize_limit(arguments["limit"]))
|
||||
end
|
||||
|
||||
defp search_all_counted_posts(project_id, arguments) do
|
||||
filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
|
||||
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
|
||||
|
||||
filters = Map.put(filters, :limit, max(total, 1))
|
||||
{:ok, result} = Search.search_posts(project_id, "", filters)
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
defp search_result(project_id, query, filters, mapper) do
|
||||
{:ok, result} = Search.search_posts(project_id, query, filters)
|
||||
|
||||
%{
|
||||
posts: Enum.map(result.posts, mapper),
|
||||
total: result.total,
|
||||
offset: result.offset,
|
||||
limit: result.limit,
|
||||
has_more: result.offset + result.limit < result.total
|
||||
}
|
||||
end
|
||||
|
||||
defp with_post(arguments, project_id, not_found_result, success_fun) do
|
||||
case Repo.get_by(Post,
|
||||
id: arguments["postId"] || arguments["post_id"],
|
||||
|
||||
@@ -2,6 +2,7 @@ defmodule BDS.Desktop.Automation do
|
||||
@moduledoc false
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
@ready_timeout 60_000
|
||||
@request_timeout 30_000
|
||||
@@ -13,7 +14,9 @@ defmodule BDS.Desktop.Automation do
|
||||
def stop_session(session) do
|
||||
GenServer.stop(session, :normal, @request_timeout)
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
:exit, reason ->
|
||||
Logger.debug("swallowed desktop automation stop_session exit: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
|
||||
def snapshot(session) do
|
||||
@@ -220,7 +223,12 @@ defmodule BDS.Desktop.Automation do
|
||||
defp safe_driver_request(state, payload) do
|
||||
driver_request(state, payload)
|
||||
rescue
|
||||
_error -> :ok
|
||||
error ->
|
||||
Logger.debug(
|
||||
"swallowed desktop automation safe_driver_request error: #{inspect(error)} payload=#{inspect(payload)}"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp shutdown_driver(state) do
|
||||
@@ -368,7 +376,9 @@ defmodule BDS.Desktop.Automation do
|
||||
defp safe_close_port(port) do
|
||||
Port.close(port)
|
||||
catch
|
||||
:error, _reason -> :ok
|
||||
:error, reason ->
|
||||
Logger.debug("swallowed desktop automation safe_close_port error: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp normalize_simple_reply("ok"), do: :ok
|
||||
|
||||
@@ -66,7 +66,9 @@ defmodule BDS.Desktop.DeepLink do
|
||||
try do
|
||||
Desktop.Env.subscribe()
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
:exit, reason ->
|
||||
Logger.debug("swallowed deep_link Desktop.Env.subscribe exit: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ defmodule BDS.Desktop.MainWindow do
|
||||
use GenServer
|
||||
|
||||
alias Desktop.Window
|
||||
require Logger
|
||||
|
||||
@window_id __MODULE__
|
||||
@server_name BDS.Desktop.MainWindow.Watcher
|
||||
@@ -22,7 +23,9 @@ defmodule BDS.Desktop.MainWindow do
|
||||
def persist_now(timeout \\ 100) do
|
||||
GenServer.call(@server_name, :persist_bounds_now, timeout)
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
:exit, reason ->
|
||||
Logger.debug("swallowed main_window persist_now exit: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
|
||||
def window_options(extra_opts \\ []) do
|
||||
@@ -169,10 +172,17 @@ defmodule BDS.Desktop.MainWindow do
|
||||
end
|
||||
end)
|
||||
rescue
|
||||
ErlangError -> nil
|
||||
FunctionClauseError -> nil
|
||||
error in ErlangError ->
|
||||
Logger.debug("swallowed main_window restore_bounds error: #{inspect(error)}")
|
||||
nil
|
||||
|
||||
error in FunctionClauseError ->
|
||||
Logger.debug("swallowed main_window restore_bounds error: #{inspect(error)}")
|
||||
nil
|
||||
catch
|
||||
:exit, _reason -> nil
|
||||
:exit, reason ->
|
||||
Logger.debug("swallowed main_window restore_bounds exit: #{inspect(reason)}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
Bridges,
|
||||
ChatEditor,
|
||||
GalleryImport,
|
||||
GitHandler,
|
||||
ImportEditor,
|
||||
MediaEditor,
|
||||
MenuEditor,
|
||||
@@ -22,6 +23,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
SettingsEditor,
|
||||
SidebarDelete,
|
||||
TagsEditor,
|
||||
TabActions,
|
||||
TemplateEditor
|
||||
}
|
||||
|
||||
@@ -29,25 +31,20 @@ defmodule BDS.Desktop.ShellLive do
|
||||
alias BDS.Desktop.ShellLive.PostEditor
|
||||
alias BDS.Desktop.ShellLive.SidebarComponents, as: ShellSidebarComponents
|
||||
alias BDS.Desktop.ShellLive.SidebarEvents
|
||||
alias BDS.Desktop.ShellLive.SidebarState, as: ShellSidebarState
|
||||
|
||||
alias BDS.Desktop.ShellLive.{
|
||||
ChatSurface,
|
||||
ContentState,
|
||||
Layout,
|
||||
PanelRenderer,
|
||||
SessionUtil,
|
||||
ShellCommandRunner,
|
||||
SidebarCreate,
|
||||
SocketState,
|
||||
TabHelpers,
|
||||
TaskLocalization,
|
||||
TitlebarMenu
|
||||
TitlebarMenu,
|
||||
UrlState
|
||||
}
|
||||
|
||||
import TaskLocalization,
|
||||
only: [
|
||||
localize_task_status: 2
|
||||
]
|
||||
|
||||
import TabHelpers,
|
||||
only: [
|
||||
tab_id_for_route: 2,
|
||||
@@ -64,8 +61,6 @@ defmodule BDS.Desktop.ShellLive do
|
||||
@refresh_interval 1_500
|
||||
|
||||
def refresh_interval, do: @refresh_interval
|
||||
|
||||
@output_entry_limit 20
|
||||
@sidebar_filter_events [
|
||||
"toggle_sidebar_filters",
|
||||
"toggle_sidebar_archive",
|
||||
@@ -180,7 +175,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|> assign(:panel_git_entries, [])
|
||||
|> assign(:auto_save_timers, %{})
|
||||
|> reload_shell(workbench)
|
||||
|> apply_url_params(params)
|
||||
|> UrlState.apply_params(params)
|
||||
|> tap(&sync_menu_bar_locale/1)}
|
||||
end
|
||||
|
||||
@@ -208,7 +203,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
{:noreply,
|
||||
socket
|
||||
|> refresh_sidebar(workbench)
|
||||
|> push_url_state()}
|
||||
|> UrlState.push()}
|
||||
end
|
||||
|
||||
def handle_event("select_panel_tab", %{"tab" => tab}, socket) do
|
||||
@@ -241,17 +236,18 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
|
||||
def handle_event(event, _params, socket) when event in @git_action_events do
|
||||
{:noreply, run_git_action(socket, event)}
|
||||
{:noreply, GitHandler.run_action(socket, event)}
|
||||
end
|
||||
|
||||
def handle_event("git_commit", params, socket) do
|
||||
message = params |> get_in(["git", "message"]) |> to_string() |> String.trim()
|
||||
{:noreply, commit_git(socket, message)}
|
||||
|
||||
{:noreply, GitHandler.commit(socket, message)}
|
||||
end
|
||||
|
||||
def handle_event("git_initialize", params, socket) do
|
||||
remote_url = params |> get_in(["git", "remote_url"]) |> normalize_git_remote_url()
|
||||
{:noreply, initialize_git(socket, remote_url)}
|
||||
remote_url = params |> get_in(["git", "remote_url"]) |> GitHandler.normalize_remote_url()
|
||||
{:noreply, GitHandler.initialize(socket, remote_url)}
|
||||
end
|
||||
|
||||
def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
|
||||
@@ -270,7 +266,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
|
||||
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
|
||||
socket = auto_save_current_post(socket)
|
||||
socket = TabActions.auto_save_current_post(socket)
|
||||
|
||||
workbench =
|
||||
Workbench.open_tab(
|
||||
@@ -286,11 +282,11 @@ defmodule BDS.Desktop.ShellLive do
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> refresh_layout(workbench)
|
||||
|> push_url_state()}
|
||||
|> UrlState.push()}
|
||||
end
|
||||
|
||||
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
|
||||
socket = auto_save_current_post(socket)
|
||||
socket = TabActions.auto_save_current_post(socket)
|
||||
|
||||
type_atom = BoundedAtoms.editor_route(type, :post)
|
||||
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
|
||||
@@ -300,7 +296,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> refresh_layout(workbench)
|
||||
|> push_url_state()}
|
||||
|> UrlState.push()}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
@@ -722,241 +718,13 @@ defmodule BDS.Desktop.ShellLive do
|
||||
index(assigns)
|
||||
end
|
||||
|
||||
defp refresh_layout(socket, workbench) do
|
||||
git_badge_count = socket.assigns[:git_badge_count] || 0
|
||||
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
|
||||
defp refresh_layout(socket, workbench), do: SocketState.refresh_layout(socket, workbench)
|
||||
|
||||
task_status =
|
||||
socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
|
||||
defp refresh_sidebar(socket, workbench), do: SocketState.refresh_sidebar(socket, workbench)
|
||||
|
||||
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot()
|
||||
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
||||
offline_mode = Map.get(socket.assigns, :offline_mode, true)
|
||||
sidebar_data = socket.assigns[:sidebar_data] || %{}
|
||||
current_tab = current_tab(workbench)
|
||||
prev_tab = socket.assigns[:current_tab]
|
||||
defp refresh_content(socket, workbench), do: ContentState.refresh_content(socket, workbench)
|
||||
|
||||
prev_panel_tab =
|
||||
case socket.assigns[:workbench] do
|
||||
%Workbench{panel: %{active_tab: tab}} -> tab
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:workbench, workbench)
|
||||
|> assign(:activity_buttons, activity_buttons)
|
||||
|> assign(
|
||||
:sidebar_header,
|
||||
active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data)
|
||||
)
|
||||
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|
||||
|> assign(:current_tab, current_tab)
|
||||
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
||||
|> assign(
|
||||
:status,
|
||||
ShellData.status_bar(workbench, task_status, dashboard,
|
||||
ui_language: page_language,
|
||||
offline_mode: offline_mode
|
||||
)
|
||||
)
|
||||
|
||||
if panel_data_stale?(current_tab, prev_tab, workbench.panel.active_tab, prev_panel_tab) do
|
||||
refresh_panel_data(socket)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp panel_data_stale?(current_tab, prev_tab, panel_tab, prev_panel_tab) do
|
||||
current_tab != prev_tab or panel_tab != prev_panel_tab
|
||||
end
|
||||
|
||||
defp refresh_panel_data(socket) do
|
||||
panel_tab = socket.assigns.workbench.panel.active_tab
|
||||
|
||||
socket
|
||||
|> assign(
|
||||
:panel_post_links,
|
||||
if(panel_tab == :post_links,
|
||||
do: PanelRenderer.fetch_post_link_entries(socket.assigns),
|
||||
else: socket.assigns[:panel_post_links] || %{backlinks: [], outlinks: []}
|
||||
)
|
||||
)
|
||||
|> assign(
|
||||
:panel_git_entries,
|
||||
if(panel_tab == :git_log,
|
||||
do: PanelRenderer.fetch_git_log_entries(socket.assigns),
|
||||
else: socket.assigns[:panel_git_entries] || []
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp push_url_state(socket) do
|
||||
workbench = socket.assigns.workbench
|
||||
|
||||
params =
|
||||
%{}
|
||||
|> put_url_view(workbench.active_view)
|
||||
|> put_url_tab(workbench.active_tab)
|
||||
|
||||
query = URI.encode_query(params)
|
||||
path = if query == "", do: "/", else: "/?" <> query
|
||||
|
||||
push_event(socket, "url-state", %{path: path})
|
||||
end
|
||||
|
||||
defp put_url_view(params, :posts), do: params
|
||||
defp put_url_view(params, view), do: Map.put(params, "view", Atom.to_string(view))
|
||||
|
||||
defp put_url_tab(params, nil), do: params
|
||||
|
||||
defp put_url_tab(params, {type, id}),
|
||||
do: Map.put(params, "tab", Atom.to_string(type) <> ":" <> id)
|
||||
|
||||
defp apply_url_params(socket, params) when is_map(params) and map_size(params) > 0 do
|
||||
workbench = socket.assigns.workbench
|
||||
|
||||
workbench = apply_url_view(workbench, Map.get(params, "view"))
|
||||
workbench = apply_url_tab(workbench, Map.get(params, "tab"))
|
||||
|
||||
if workbench == socket.assigns.workbench do
|
||||
socket
|
||||
else
|
||||
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
||||
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> refresh_sidebar(workbench)
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_url_params(socket, _params), do: socket
|
||||
|
||||
defp apply_url_view(workbench, nil), do: workbench
|
||||
|
||||
defp apply_url_view(workbench, view_str) do
|
||||
view = BoundedAtoms.sidebar_view(view_str, nil)
|
||||
|
||||
if view && view != workbench.active_view do
|
||||
Workbench.click_activity(workbench, view)
|
||||
else
|
||||
workbench
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_url_tab(workbench, nil), do: workbench
|
||||
|
||||
defp apply_url_tab(workbench, tab_str) do
|
||||
case String.split(tab_str, ":", parts: 2) do
|
||||
[type_str, id] ->
|
||||
type = BoundedAtoms.editor_route(type_str, nil)
|
||||
|
||||
if type && workbench.active_tab != {type, id} do
|
||||
Workbench.open_tab(workbench, type, id, :preview)
|
||||
else
|
||||
workbench
|
||||
end
|
||||
|
||||
_ ->
|
||||
workbench
|
||||
end
|
||||
end
|
||||
|
||||
defp refresh_sidebar(socket, workbench) do
|
||||
project_id = (socket.assigns[:projects] || %{})[:active_project_id]
|
||||
active_view_id = Atom.to_string(workbench.active_view)
|
||||
|
||||
sidebar_data =
|
||||
case ShellData.sidebar_view(
|
||||
project_id,
|
||||
active_view_id,
|
||||
ShellSidebarState.current_filters(socket, active_view_id)
|
||||
) do
|
||||
{:ok, data} -> data
|
||||
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
|
||||
end
|
||||
|
||||
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
|
||||
|
||||
socket
|
||||
|> assign(:sidebar_data, sidebar_data)
|
||||
|> refresh_layout(workbench)
|
||||
end
|
||||
|
||||
defp refresh_content(socket, workbench) do
|
||||
projects =
|
||||
case ShellData.project_snapshot() do
|
||||
{:ok, data} -> data
|
||||
{:error, :not_ready} -> ShellData.default_project_snapshot()
|
||||
end
|
||||
|
||||
dashboard =
|
||||
case ShellData.dashboard(projects.active_project_id) do
|
||||
{:ok, data} -> data
|
||||
{:error, :not_ready} -> BDS.UI.Dashboard.empty_snapshot()
|
||||
end
|
||||
|
||||
git_badge_count =
|
||||
case ShellData.git_badge_count(projects.active_project_id) do
|
||||
{:ok, count} -> count
|
||||
{:error, :not_ready} -> 0
|
||||
end
|
||||
|
||||
active_view_id = Atom.to_string(workbench.active_view)
|
||||
|
||||
sidebar_data =
|
||||
case ShellData.sidebar_view(
|
||||
projects.active_project_id,
|
||||
active_view_id,
|
||||
ShellSidebarState.current_filters(socket, active_view_id)
|
||||
) do
|
||||
{:ok, data} -> data
|
||||
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
|
||||
end
|
||||
|
||||
sidebar_data = ShellSidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
|
||||
|
||||
socket
|
||||
|> assign(:projects, projects)
|
||||
|> assign(:current_project, ShellData.current_project(projects))
|
||||
|> assign(:dashboard, dashboard)
|
||||
|> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, []))
|
||||
|> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, []))
|
||||
|> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, []))
|
||||
|> assign(
|
||||
:dashboard_tag_cloud_items,
|
||||
ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))
|
||||
)
|
||||
|> assign(:git_badge_count, git_badge_count)
|
||||
|> assign(:sidebar_data, sidebar_data)
|
||||
|> refresh_layout(workbench)
|
||||
end
|
||||
|
||||
defp reload_shell(socket, workbench) do
|
||||
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
||||
raw_task_status = BDS.Tasks.status_snapshot()
|
||||
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
||||
|
||||
offline_mode =
|
||||
if connected?(socket) do
|
||||
Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true))
|
||||
else
|
||||
Map.get(socket.assigns, :offline_mode, true)
|
||||
end
|
||||
|
||||
task_status = localize_task_status(raw_task_status, page_language)
|
||||
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> assign(:task_status, task_status)
|
||||
|> assign(:offline_mode, offline_mode)
|
||||
|> assign(:assistant_cards, ShellData.assistant_cards())
|
||||
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|
||||
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|
||||
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|
||||
|> refresh_content(workbench)
|
||||
end
|
||||
defp reload_shell(socket, workbench), do: ContentState.reload_shell(socket, workbench)
|
||||
|
||||
defp encoded_shortcuts(shortcuts), do: Jason.encode!(shortcuts)
|
||||
|
||||
@@ -971,12 +739,6 @@ defmodule BDS.Desktop.ShellLive do
|
||||
defp activity_label("Source Control"), do: dgettext("ui", "Git")
|
||||
defp activity_label(label), do: label
|
||||
|
||||
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
|
||||
Enum.find_value(activity_buttons, Map.get(sidebar_data, :title, ""), fn button ->
|
||||
if button.id == active_view, do: activity_label(button.label), else: nil
|
||||
end)
|
||||
end
|
||||
|
||||
defp sidebar_header_label(label), do: label
|
||||
|
||||
defp timeline_height(entry, entries) do
|
||||
@@ -988,12 +750,6 @@ defmodule BDS.Desktop.ShellLive do
|
||||
max(4, (entry.count || 0) / max_count * 100)
|
||||
end
|
||||
|
||||
defp current_tab(%{active_tab: nil}), do: nil
|
||||
|
||||
defp current_tab(%{tabs: tabs, active_tab: {type, id}}) do
|
||||
Enum.find(tabs, &(&1.type == type and &1.id == id))
|
||||
end
|
||||
|
||||
defp create_sidebar_item(socket, kind),
|
||||
do: SidebarCreate.create(socket, kind, sidebar_create_callbacks())
|
||||
|
||||
@@ -1097,125 +853,11 @@ defmodule BDS.Desktop.ShellLive do
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> refresh_layout(workbench)
|
||||
|> push_url_state()
|
||||
end
|
||||
|
||||
defp run_git_action(socket, event) do
|
||||
project_id = current_project_id(socket)
|
||||
|
||||
{label, result} =
|
||||
case event do
|
||||
"git_fetch" -> {dgettext("ui", "Fetch"), git_call(project_id, &BDS.Git.fetch/1)}
|
||||
"git_pull" -> {dgettext("ui", "Pull"), git_call(project_id, &BDS.Git.pull/1)}
|
||||
"git_push" -> {dgettext("ui", "Push"), git_call(project_id, &BDS.Git.push/1)}
|
||||
"git_prune_lfs" -> {dgettext("ui", "Prune LFS"), prune_lfs(project_id)}
|
||||
end
|
||||
|
||||
socket
|
||||
|> append_git_result(label, result)
|
||||
|> refresh_sidebar(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
defp commit_git(socket, "") do
|
||||
socket
|
||||
|> append_output_entry(
|
||||
dgettext("ui", "Commit"),
|
||||
dgettext("ui", "Commit message is required"),
|
||||
nil,
|
||||
"error"
|
||||
)
|
||||
|> refresh_sidebar(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
defp commit_git(socket, message) do
|
||||
case git_call(current_project_id(socket), &BDS.Git.commit_all(&1, message)) do
|
||||
{:ok, _result} ->
|
||||
workbench = close_git_diff_tabs(socket.assigns.workbench)
|
||||
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
||||
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> append_output_entry(dgettext("ui", "Commit"), message)
|
||||
|> refresh_sidebar(workbench)
|
||||
|> push_url_state()
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output_entry(dgettext("ui", "Commit"), format_git_error(reason), nil, "error")
|
||||
|> refresh_sidebar(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
defp initialize_git(socket, remote_url) do
|
||||
project_id = current_project_id(socket)
|
||||
|
||||
case git_call(project_id, &BDS.Git.initialize_repo/1) do
|
||||
{:ok, _repo} ->
|
||||
_ = maybe_set_git_remote(project_id, remote_url)
|
||||
|
||||
socket
|
||||
|> append_output_entry(
|
||||
dgettext("ui", "Initialize Git"),
|
||||
dgettext("ui", "Repository initialized")
|
||||
)
|
||||
|> refresh_sidebar(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> append_output_entry(
|
||||
dgettext("ui", "Initialize Git"),
|
||||
format_git_error(reason),
|
||||
nil,
|
||||
"error"
|
||||
)
|
||||
|> refresh_sidebar(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
defp git_call(nil, _fun), do: {:error, :no_project}
|
||||
defp git_call("default", _fun), do: {:error, :no_project}
|
||||
defp git_call(project_id, fun) when is_binary(project_id), do: fun.(project_id)
|
||||
|
||||
defp prune_lfs(nil), do: {:error, :no_project}
|
||||
defp prune_lfs("default"), do: {:error, :no_project}
|
||||
|
||||
defp prune_lfs(project_id) when is_binary(project_id),
|
||||
do: BDS.Git.prune_lfs_cache(project_id, 10)
|
||||
|
||||
defp maybe_set_git_remote(_project_id, nil), do: :ok
|
||||
|
||||
defp maybe_set_git_remote(project_id, remote_url),
|
||||
do: BDS.Git.set_remote(project_id, remote_url)
|
||||
|
||||
defp append_git_result(socket, label, {:ok, _result}) do
|
||||
append_output_entry(socket, label, dgettext("ui", "Done"))
|
||||
end
|
||||
|
||||
defp append_git_result(socket, label, {:error, reason}) do
|
||||
append_output_entry(socket, label, format_git_error(reason), nil, "error")
|
||||
end
|
||||
|
||||
defp format_git_error(:no_project), do: dgettext("ui", "No active project")
|
||||
defp format_git_error(%{message: message}) when is_binary(message), do: message
|
||||
defp format_git_error(%{guidance: guidance}) when is_binary(guidance), do: guidance
|
||||
defp format_git_error({:git_failed, message}) when is_binary(message), do: message
|
||||
defp format_git_error(reason), do: inspect(reason)
|
||||
|
||||
defp close_git_diff_tabs(workbench) do
|
||||
workbench.tabs
|
||||
|> Enum.filter(&(&1.type == :git_diff))
|
||||
|> Enum.reduce(workbench, fn tab, wb -> Workbench.close_tab(wb, :git_diff, tab.id) end)
|
||||
|> UrlState.push()
|
||||
end
|
||||
|
||||
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
|
||||
|
||||
defp normalize_git_remote_url(value) do
|
||||
case value |> to_string() |> String.trim() do
|
||||
"" -> nil
|
||||
url -> url
|
||||
end
|
||||
end
|
||||
|
||||
defp sidebar_create_action(view), do: SidebarCreate.action(view)
|
||||
|
||||
defp set_page_language(socket, language) do
|
||||
@@ -1258,7 +900,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|> assign(:sidebar_filters_by_view, %{})
|
||||
|> append_output_entry(title, message_fun.(project))
|
||||
|> reload_shell(Workbench.new())
|
||||
|> push_url_state()
|
||||
|> UrlState.push()
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
@@ -1268,11 +910,8 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
end
|
||||
|
||||
defp append_output_entry(socket, title, message, details \\ nil, level \\ "info") do
|
||||
entry = %{title: title, message: message, details: details, level: level}
|
||||
entries = [entry | socket.assigns.output_entries] |> Enum.take(@output_entry_limit)
|
||||
assign(socket, :output_entries, entries)
|
||||
end
|
||||
defp append_output_entry(socket, title, message, details \\ nil, level \\ "info"),
|
||||
do: SocketState.append_output_entry(socket, title, message, details, level)
|
||||
|
||||
defp handle_native_menu_action(socket, action) do
|
||||
case BoundedAtoms.menu_action(action) do
|
||||
@@ -1293,7 +932,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> refresh_sidebar(workbench)
|
||||
|> push_url_state()
|
||||
|> UrlState.push()
|
||||
|
||||
MapSet.member?(@socket_menu_actions, action) ->
|
||||
handle_socket_menu_action(socket, action)
|
||||
@@ -1317,8 +956,10 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|
||||
defp handle_socket_menu_action(socket, :new_post), do: create_sidebar_item(socket, "post")
|
||||
defp handle_socket_menu_action(socket, :import_media), do: create_sidebar_item(socket, "media")
|
||||
defp handle_socket_menu_action(socket, :save), do: save_current_tab(socket)
|
||||
defp handle_socket_menu_action(socket, :publish_selected), do: publish_current_tab(socket)
|
||||
|
||||
defp handle_socket_menu_action(socket, :save), do: TabActions.save_current_tab(socket)
|
||||
|
||||
defp handle_socket_menu_action(socket, :publish_selected), do: TabActions.publish_current_tab(socket)
|
||||
|
||||
defp handle_socket_menu_action(socket, :quit) do
|
||||
Shutdown.request_quit()
|
||||
@@ -1347,62 +988,6 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|
||||
defp shell_command?(action), do: not is_nil(shell_command_atom(action))
|
||||
|
||||
defp auto_save_current_post(
|
||||
%{assigns: %{current_tab: %{type: :post, id: post_id}, workbench: workbench}} = socket
|
||||
) do
|
||||
if Workbench.dirty?(workbench, :post, post_id) do
|
||||
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
||||
end
|
||||
|
||||
socket
|
||||
end
|
||||
|
||||
defp auto_save_current_post(socket), do: socket
|
||||
|
||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
|
||||
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
||||
socket
|
||||
end
|
||||
|
||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :media, id: media_id}}} = socket) do
|
||||
send_update(MediaEditor, id: "media-editor-#{media_id}", action: :save)
|
||||
socket
|
||||
end
|
||||
|
||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :settings}}} = socket) do
|
||||
send_update(SettingsEditor, id: "settings-editor", action: :save_project)
|
||||
socket
|
||||
end
|
||||
|
||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :menu_editor}}} = socket) do
|
||||
send_update(MenuEditor, id: "menu-editor", action: :save)
|
||||
socket
|
||||
end
|
||||
|
||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :tags}}} = socket) do
|
||||
send_update(TagsEditor, id: "tags-editor", action: :save)
|
||||
socket
|
||||
end
|
||||
|
||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :scripts, id: script_id}}} = socket) do
|
||||
send_update(ScriptEditor, id: "script-editor-#{script_id}", action: :save)
|
||||
socket
|
||||
end
|
||||
|
||||
defp save_current_tab(%{assigns: %{current_tab: %{type: :templates, id: template_id}}} = socket) do
|
||||
send_update(TemplateEditor, id: "template-editor-#{template_id}", action: :save)
|
||||
socket
|
||||
end
|
||||
|
||||
defp save_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
|
||||
|
||||
defp publish_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
|
||||
send_update(PostEditor, id: "post-editor-#{post_id}", action: :publish)
|
||||
socket
|
||||
end
|
||||
|
||||
defp publish_current_tab(socket), do: refresh_layout(socket, socket.assigns.workbench)
|
||||
|
||||
defp apply_shell_command(socket, action, params \\ %{}),
|
||||
do: ShellCommandRunner.execute(socket, action, params, shell_command_callbacks())
|
||||
|
||||
|
||||
@@ -996,7 +996,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
try do
|
||||
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
|
||||
rescue
|
||||
_error -> :ok
|
||||
error ->
|
||||
Logger.debug("swallowed chat_editor allow_repo_sandbox error: #{inspect(error)}")
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
|
||||
83
lib/bds/desktop/shell_live/content_state.ex
Normal file
83
lib/bds/desktop/shell_live/content_state.ex
Normal file
@@ -0,0 +1,83 @@
|
||||
defmodule BDS.Desktop.ShellLive.ContentState do
|
||||
@moduledoc false
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
|
||||
alias BDS.AI
|
||||
alias BDS.Desktop.ShellData
|
||||
alias BDS.Desktop.ShellLive.{SidebarState, SocketState, TabHelpers, TaskLocalization, TitlebarMenu}
|
||||
|
||||
def refresh_content(socket, workbench) do
|
||||
projects =
|
||||
case ShellData.project_snapshot() do
|
||||
{:ok, data} -> data
|
||||
{:error, :not_ready} -> ShellData.default_project_snapshot()
|
||||
end
|
||||
|
||||
dashboard =
|
||||
case ShellData.dashboard(projects.active_project_id) do
|
||||
{:ok, data} -> data
|
||||
{:error, :not_ready} -> BDS.UI.Dashboard.empty_snapshot()
|
||||
end
|
||||
|
||||
git_badge_count =
|
||||
case ShellData.git_badge_count(projects.active_project_id) do
|
||||
{:ok, count} -> count
|
||||
{:error, :not_ready} -> 0
|
||||
end
|
||||
|
||||
active_view_id = Atom.to_string(workbench.active_view)
|
||||
|
||||
sidebar_data =
|
||||
case ShellData.sidebar_view(
|
||||
projects.active_project_id,
|
||||
active_view_id,
|
||||
SidebarState.current_filters(socket, active_view_id)
|
||||
) do
|
||||
{:ok, data} -> data
|
||||
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
|
||||
end
|
||||
|
||||
sidebar_data = SidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
|
||||
|
||||
socket
|
||||
|> assign(:projects, projects)
|
||||
|> assign(:current_project, ShellData.current_project(projects))
|
||||
|> assign(:dashboard, dashboard)
|
||||
|> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, []))
|
||||
|> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, []))
|
||||
|> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, []))
|
||||
|> assign(
|
||||
:dashboard_tag_cloud_items,
|
||||
ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))
|
||||
)
|
||||
|> assign(:git_badge_count, git_badge_count)
|
||||
|> assign(:sidebar_data, sidebar_data)
|
||||
|> SocketState.refresh_layout(workbench)
|
||||
end
|
||||
|
||||
def reload_shell(socket, workbench) do
|
||||
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
||||
raw_task_status = BDS.Tasks.status_snapshot()
|
||||
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
||||
|
||||
offline_mode =
|
||||
if Phoenix.LiveView.connected?(socket) do
|
||||
Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true))
|
||||
else
|
||||
Map.get(socket.assigns, :offline_mode, true)
|
||||
end
|
||||
|
||||
task_status = TaskLocalization.localize_task_status(raw_task_status, page_language)
|
||||
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> assign(:task_status, task_status)
|
||||
|> assign(:offline_mode, offline_mode)
|
||||
|> assign(:assistant_cards, ShellData.assistant_cards())
|
||||
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|
||||
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|
||||
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|
||||
|> refresh_content(workbench)
|
||||
end
|
||||
end
|
||||
126
lib/bds/desktop/shell_live/git_handler.ex
Normal file
126
lib/bds/desktop/shell_live/git_handler.ex
Normal file
@@ -0,0 +1,126 @@
|
||||
defmodule BDS.Desktop.ShellLive.GitHandler do
|
||||
@moduledoc false
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
|
||||
alias BDS.Desktop.ShellLive.{SocketState, TabHelpers, UrlState}
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
def run_action(socket, event) do
|
||||
project_id = current_project_id(socket)
|
||||
|
||||
{label, result} =
|
||||
case event do
|
||||
"git_fetch" -> {dgettext("ui", "Fetch"), git_call(project_id, &BDS.Git.fetch/1)}
|
||||
"git_pull" -> {dgettext("ui", "Pull"), git_call(project_id, &BDS.Git.pull/1)}
|
||||
"git_push" -> {dgettext("ui", "Push"), git_call(project_id, &BDS.Git.push/1)}
|
||||
"git_prune_lfs" -> {dgettext("ui", "Prune LFS"), prune_lfs(project_id)}
|
||||
end
|
||||
|
||||
socket
|
||||
|> append_git_result(label, result)
|
||||
|> SocketState.refresh_sidebar(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def commit(socket, "") do
|
||||
socket
|
||||
|> SocketState.append_output_entry(
|
||||
dgettext("ui", "Commit"),
|
||||
dgettext("ui", "Commit message is required"),
|
||||
nil,
|
||||
"error"
|
||||
)
|
||||
|> SocketState.refresh_sidebar(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
def commit(socket, message) do
|
||||
case git_call(current_project_id(socket), &BDS.Git.commit_all(&1, message)) do
|
||||
{:ok, _result} ->
|
||||
workbench = close_git_diff_tabs(socket.assigns.workbench)
|
||||
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
||||
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> SocketState.append_output_entry(dgettext("ui", "Commit"), message, nil, "info")
|
||||
|> SocketState.refresh_sidebar(workbench)
|
||||
|> UrlState.push()
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> SocketState.append_output_entry(dgettext("ui", "Commit"), format_git_error(reason), nil, "error")
|
||||
|> SocketState.refresh_sidebar(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(socket, remote_url) do
|
||||
project_id = current_project_id(socket)
|
||||
|
||||
case git_call(project_id, &BDS.Git.initialize_repo/1) do
|
||||
{:ok, _repo} ->
|
||||
_ = maybe_set_git_remote(project_id, remote_url)
|
||||
|
||||
socket
|
||||
|> SocketState.append_output_entry(
|
||||
dgettext("ui", "Initialize Git"),
|
||||
dgettext("ui", "Repository initialized"),
|
||||
nil,
|
||||
"info"
|
||||
)
|
||||
|> SocketState.refresh_sidebar(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> SocketState.append_output_entry(
|
||||
dgettext("ui", "Initialize Git"),
|
||||
format_git_error(reason),
|
||||
nil,
|
||||
"error"
|
||||
)
|
||||
|> SocketState.refresh_sidebar(socket.assigns.workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_remote_url(value) do
|
||||
case value |> to_string() |> String.trim() do
|
||||
"" -> nil
|
||||
url -> url
|
||||
end
|
||||
end
|
||||
|
||||
defp git_call(nil, _fun), do: {:error, :no_project}
|
||||
defp git_call("default", _fun), do: {:error, :no_project}
|
||||
defp git_call(project_id, fun) when is_binary(project_id), do: fun.(project_id)
|
||||
|
||||
defp prune_lfs(nil), do: {:error, :no_project}
|
||||
defp prune_lfs("default"), do: {:error, :no_project}
|
||||
|
||||
defp prune_lfs(project_id) when is_binary(project_id),
|
||||
do: BDS.Git.prune_lfs_cache(project_id, 10)
|
||||
|
||||
defp maybe_set_git_remote(_project_id, nil), do: :ok
|
||||
defp maybe_set_git_remote(project_id, remote_url), do: BDS.Git.set_remote(project_id, remote_url)
|
||||
|
||||
defp append_git_result(socket, label, {:ok, _result}) do
|
||||
SocketState.append_output_entry(socket, label, dgettext("ui", "Done"), nil, "info")
|
||||
end
|
||||
|
||||
defp append_git_result(socket, label, {:error, reason}) do
|
||||
SocketState.append_output_entry(socket, label, format_git_error(reason), nil, "error")
|
||||
end
|
||||
|
||||
defp format_git_error(:no_project), do: dgettext("ui", "No active project")
|
||||
defp format_git_error(%{message: message}) when is_binary(message), do: message
|
||||
defp format_git_error(%{guidance: guidance}) when is_binary(guidance), do: guidance
|
||||
defp format_git_error({:git_failed, message}) when is_binary(message), do: message
|
||||
defp format_git_error(reason), do: inspect(reason)
|
||||
|
||||
defp close_git_diff_tabs(workbench) do
|
||||
workbench.tabs
|
||||
|> Enum.filter(&(&1.type == :git_diff))
|
||||
|> Enum.reduce(workbench, fn tab, wb -> Workbench.close_tab(wb, :git_diff, tab.id) end)
|
||||
end
|
||||
|
||||
defp current_project_id(socket), do: (socket.assigns[:projects] || %{})[:active_project_id]
|
||||
end
|
||||
@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
alias BDS.{AI, ImportAnalysis, ImportDefinitions, ImportExecution}
|
||||
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData}
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
require Logger
|
||||
|
||||
alias BDS.Desktop.ShellLive.ImportEditor.{
|
||||
AnalysisState,
|
||||
@@ -1417,7 +1418,9 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
try do
|
||||
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
|
||||
rescue
|
||||
_error -> :ok
|
||||
error ->
|
||||
Logger.debug("swallowed import_editor allow_repo_sandbox error: #{inspect(error)}")
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
|
||||
@@ -3,6 +3,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
||||
|
||||
alias BDS.{ImportAnalysis, ImportDefinitions, Metadata}
|
||||
alias BDS.Desktop.{FilePicker, FolderPicker}
|
||||
require Logger
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
@spec change_definition(term(), term(), term()) :: term()
|
||||
@@ -280,7 +281,12 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
||||
try do
|
||||
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
|
||||
rescue
|
||||
_error -> :ok
|
||||
error ->
|
||||
Logger.debug(
|
||||
"swallowed import_editor analysis_state allow_repo_sandbox error: #{inspect(error)}"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
|
||||
@@ -2,6 +2,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.{Metadata, Tags}
|
||||
require Logger
|
||||
|
||||
@spec field_key(term()) :: term()
|
||||
def field_key(:tags), do: "tags"
|
||||
@@ -91,7 +92,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
|
||||
:ok
|
||||
end
|
||||
rescue
|
||||
_error -> :ok
|
||||
error ->
|
||||
Logger.warning("swallowed list_values ensure_list_value error: #{inspect(error)}")
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec csv_to_list(term()) :: term()
|
||||
|
||||
@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
||||
|
||||
alias BDS.AI
|
||||
alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings
|
||||
require Logger
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
@spec ai_form(term()) :: term()
|
||||
@@ -171,7 +172,9 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
||||
defp safe_endpoint(kind) do
|
||||
case AI.get_endpoint(kind) do
|
||||
{:ok, ep} -> ep
|
||||
_error -> nil
|
||||
error ->
|
||||
Logger.debug("swallowed AI settings safe_endpoint error: #{inspect(error)}")
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
123
lib/bds/desktop/shell_live/socket_state.ex
Normal file
123
lib/bds/desktop/shell_live/socket_state.ex
Normal file
@@ -0,0 +1,123 @@
|
||||
defmodule BDS.Desktop.ShellLive.SocketState do
|
||||
@moduledoc false
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
|
||||
alias BDS.Desktop.ShellData
|
||||
alias BDS.Desktop.ShellLive.{PanelRenderer, SidebarState}
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
@output_entry_limit 20
|
||||
|
||||
def refresh_layout(socket, workbench) do
|
||||
git_badge_count = socket.assigns[:git_badge_count] || 0
|
||||
activity_buttons = Workbench.activity_buttons(workbench, git_badge_count)
|
||||
|
||||
task_status =
|
||||
socket.assigns[:task_status] || %{running_task_message: nil, running_task_overflow: nil}
|
||||
|
||||
dashboard = socket.assigns[:dashboard] || BDS.UI.Dashboard.empty_snapshot()
|
||||
page_language = socket.assigns[:page_language] || ShellData.ui_language()
|
||||
offline_mode = Map.get(socket.assigns, :offline_mode, true)
|
||||
sidebar_data = socket.assigns[:sidebar_data] || %{}
|
||||
current_tab = current_tab(workbench)
|
||||
prev_tab = socket.assigns[:current_tab]
|
||||
|
||||
prev_panel_tab =
|
||||
case socket.assigns[:workbench] do
|
||||
%Workbench{panel: %{active_tab: tab}} -> tab
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:workbench, workbench)
|
||||
|> assign(:activity_buttons, activity_buttons)
|
||||
|> assign(:sidebar_header, active_sidebar_label(activity_buttons, workbench.active_view, sidebar_data))
|
||||
|> assign(:panel_tabs, ShellData.panel_tabs(workbench))
|
||||
|> assign(:current_tab, current_tab)
|
||||
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
||||
|> assign(
|
||||
:status,
|
||||
ShellData.status_bar(workbench, task_status, dashboard,
|
||||
ui_language: page_language,
|
||||
offline_mode: offline_mode
|
||||
)
|
||||
)
|
||||
|
||||
if panel_data_stale?(current_tab, prev_tab, workbench.panel.active_tab, prev_panel_tab) do
|
||||
refresh_panel_data(socket)
|
||||
else
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
def refresh_sidebar(socket, workbench) do
|
||||
project_id = (socket.assigns[:projects] || %{})[:active_project_id]
|
||||
active_view_id = Atom.to_string(workbench.active_view)
|
||||
|
||||
sidebar_data =
|
||||
case ShellData.sidebar_view(
|
||||
project_id,
|
||||
active_view_id,
|
||||
SidebarState.current_filters(socket, active_view_id)
|
||||
) do
|
||||
{:ok, data} -> data
|
||||
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
|
||||
end
|
||||
|
||||
sidebar_data = SidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
|
||||
|
||||
socket
|
||||
|> assign(:sidebar_data, sidebar_data)
|
||||
|> refresh_layout(workbench)
|
||||
end
|
||||
|
||||
def append_output_entry(socket, title, message, details \\ nil, level \\ "info") do
|
||||
entry = %{title: title, message: message, details: details, level: level}
|
||||
entries = [entry | socket.assigns.output_entries] |> Enum.take(@output_entry_limit)
|
||||
assign(socket, :output_entries, entries)
|
||||
end
|
||||
|
||||
defp panel_data_stale?(current_tab, prev_tab, panel_tab, prev_panel_tab) do
|
||||
current_tab != prev_tab or panel_tab != prev_panel_tab
|
||||
end
|
||||
|
||||
defp refresh_panel_data(socket) do
|
||||
panel_tab = socket.assigns.workbench.panel.active_tab
|
||||
|
||||
socket
|
||||
|> assign(
|
||||
:panel_post_links,
|
||||
if(panel_tab == :post_links,
|
||||
do: PanelRenderer.fetch_post_link_entries(socket.assigns),
|
||||
else: socket.assigns[:panel_post_links] || %{backlinks: [], outlinks: []}
|
||||
)
|
||||
)
|
||||
|> assign(
|
||||
:panel_git_entries,
|
||||
if(panel_tab == :git_log,
|
||||
do: PanelRenderer.fetch_git_log_entries(socket.assigns),
|
||||
else: socket.assigns[:panel_git_entries] || []
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
defp activity_label("AI Assistant"), do: dgettext("ui", "Chat")
|
||||
defp activity_label("Source Control"), do: dgettext("ui", "Git")
|
||||
defp activity_label(label), do: label
|
||||
|
||||
defp active_sidebar_label(activity_buttons, active_view, sidebar_data) do
|
||||
Enum.find_value(activity_buttons, Map.get(sidebar_data, :title, ""), fn button ->
|
||||
if button.id == active_view, do: activity_label(button.label), else: nil
|
||||
end)
|
||||
end
|
||||
|
||||
defp current_tab(%{active_tab: nil}), do: nil
|
||||
|
||||
defp current_tab(%{tabs: tabs, active_tab: {type, id}}) do
|
||||
Enum.find(tabs, &(&1.type == type and &1.id == id))
|
||||
end
|
||||
end
|
||||
72
lib/bds/desktop/shell_live/tab_actions.ex
Normal file
72
lib/bds/desktop/shell_live/tab_actions.ex
Normal file
@@ -0,0 +1,72 @@
|
||||
defmodule BDS.Desktop.ShellLive.TabActions do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.Desktop.ShellLive.{
|
||||
MediaEditor,
|
||||
MenuEditor,
|
||||
PostEditor,
|
||||
ScriptEditor,
|
||||
SocketState,
|
||||
SettingsEditor,
|
||||
TagsEditor,
|
||||
TemplateEditor
|
||||
}
|
||||
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
def auto_save_current_post(
|
||||
%{assigns: %{current_tab: %{type: :post, id: post_id}, workbench: workbench}} = socket
|
||||
) do
|
||||
if Workbench.dirty?(workbench, :post, post_id) do
|
||||
Phoenix.LiveView.send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
||||
end
|
||||
|
||||
socket
|
||||
end
|
||||
|
||||
def auto_save_current_post(socket), do: socket
|
||||
|
||||
def save_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
|
||||
Phoenix.LiveView.send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
||||
socket
|
||||
end
|
||||
|
||||
def save_current_tab(%{assigns: %{current_tab: %{type: :media, id: media_id}}} = socket) do
|
||||
Phoenix.LiveView.send_update(MediaEditor, id: "media-editor-#{media_id}", action: :save)
|
||||
socket
|
||||
end
|
||||
|
||||
def save_current_tab(%{assigns: %{current_tab: %{type: :settings}}} = socket) do
|
||||
Phoenix.LiveView.send_update(SettingsEditor, id: "settings-editor", action: :save_project)
|
||||
socket
|
||||
end
|
||||
|
||||
def save_current_tab(%{assigns: %{current_tab: %{type: :menu_editor}}} = socket) do
|
||||
Phoenix.LiveView.send_update(MenuEditor, id: "menu-editor", action: :save)
|
||||
socket
|
||||
end
|
||||
|
||||
def save_current_tab(%{assigns: %{current_tab: %{type: :tags}}} = socket) do
|
||||
Phoenix.LiveView.send_update(TagsEditor, id: "tags-editor", action: :save)
|
||||
socket
|
||||
end
|
||||
|
||||
def save_current_tab(%{assigns: %{current_tab: %{type: :scripts, id: script_id}}} = socket) do
|
||||
Phoenix.LiveView.send_update(ScriptEditor, id: "script-editor-#{script_id}", action: :save)
|
||||
socket
|
||||
end
|
||||
|
||||
def save_current_tab(%{assigns: %{current_tab: %{type: :templates, id: template_id}}} = socket) do
|
||||
Phoenix.LiveView.send_update(TemplateEditor, id: "template-editor-#{template_id}", action: :save)
|
||||
socket
|
||||
end
|
||||
|
||||
def save_current_tab(socket), do: SocketState.refresh_layout(socket, socket.assigns.workbench)
|
||||
|
||||
def publish_current_tab(%{assigns: %{current_tab: %{type: :post, id: post_id}}} = socket) do
|
||||
Phoenix.LiveView.send_update(PostEditor, id: "post-editor-#{post_id}", action: :publish)
|
||||
socket
|
||||
end
|
||||
|
||||
def publish_current_tab(socket), do: SocketState.refresh_layout(socket, socket.assigns.workbench)
|
||||
end
|
||||
78
lib/bds/desktop/shell_live/url_state.ex
Normal file
78
lib/bds/desktop/shell_live/url_state.ex
Normal file
@@ -0,0 +1,78 @@
|
||||
defmodule BDS.Desktop.ShellLive.UrlState do
|
||||
@moduledoc false
|
||||
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
|
||||
alias BDS.BoundedAtoms
|
||||
alias BDS.Desktop.ShellLive.{SocketState, TabHelpers}
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
def push(socket) do
|
||||
workbench = socket.assigns.workbench
|
||||
|
||||
params =
|
||||
%{}
|
||||
|> put_url_view(workbench.active_view)
|
||||
|> put_url_tab(workbench.active_tab)
|
||||
|
||||
query = URI.encode_query(params)
|
||||
path = if query == "", do: "/", else: "/?" <> query
|
||||
|
||||
Phoenix.LiveView.push_event(socket, "url-state", %{path: path})
|
||||
end
|
||||
|
||||
def apply_params(socket, params) when is_map(params) and map_size(params) > 0 do
|
||||
workbench = socket.assigns.workbench
|
||||
|
||||
workbench = apply_url_view(workbench, Map.get(params, "view"))
|
||||
workbench = apply_url_tab(workbench, Map.get(params, "tab"))
|
||||
|
||||
if workbench == socket.assigns.workbench do
|
||||
socket
|
||||
else
|
||||
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
|
||||
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> SocketState.refresh_sidebar(workbench)
|
||||
end
|
||||
end
|
||||
|
||||
def apply_params(socket, _params), do: socket
|
||||
|
||||
defp put_url_view(params, :posts), do: params
|
||||
defp put_url_view(params, view), do: Map.put(params, "view", Atom.to_string(view))
|
||||
|
||||
defp put_url_tab(params, nil), do: params
|
||||
defp put_url_tab(params, {type, id}), do: Map.put(params, "tab", Atom.to_string(type) <> ":" <> id)
|
||||
|
||||
defp apply_url_view(workbench, nil), do: workbench
|
||||
|
||||
defp apply_url_view(workbench, view_str) do
|
||||
view = BoundedAtoms.sidebar_view(view_str, nil)
|
||||
|
||||
if view && view != workbench.active_view do
|
||||
Workbench.click_activity(workbench, view)
|
||||
else
|
||||
workbench
|
||||
end
|
||||
end
|
||||
|
||||
defp apply_url_tab(workbench, nil), do: workbench
|
||||
|
||||
defp apply_url_tab(workbench, tab_str) do
|
||||
case String.split(tab_str, ":", parts: 2) do
|
||||
[type_str, id] ->
|
||||
type = BoundedAtoms.editor_route(type_str, nil)
|
||||
|
||||
if type && workbench.active_tab != {type, id} do
|
||||
Workbench.open_tab(workbench, type, id, :preview)
|
||||
else
|
||||
workbench
|
||||
end
|
||||
|
||||
_ ->
|
||||
workbench
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,7 @@ defmodule BDS.Desktop.Shutdown do
|
||||
alias Desktop.Wx
|
||||
alias Desktop.Window
|
||||
|
||||
require Logger
|
||||
require Record
|
||||
|
||||
Record.defrecordp(:wx, Record.extract(:wx, from_lib: "wx/include/wx.hrl"))
|
||||
@@ -25,9 +26,13 @@ defmodule BDS.Desktop.Shutdown do
|
||||
|
||||
:ok
|
||||
rescue
|
||||
_error -> :ok
|
||||
error ->
|
||||
Logger.debug("swallowed shutdown install_handlers error: #{inspect(error)}")
|
||||
:ok
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
:exit, reason ->
|
||||
Logger.debug("swallowed shutdown install_handlers exit: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec request_quit() :: :ok
|
||||
@@ -102,9 +107,13 @@ defmodule BDS.Desktop.Shutdown do
|
||||
fun.()
|
||||
:ok
|
||||
rescue
|
||||
_error -> :ok
|
||||
error ->
|
||||
Logger.debug("swallowed shutdown persist_step error: #{inspect(error)}")
|
||||
:ok
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
:exit, reason ->
|
||||
Logger.debug("swallowed shutdown persist_step exit: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
|
||||
# heart, when present, would relaunch the app after we kill the BEAM, so it
|
||||
@@ -120,9 +129,13 @@ defmodule BDS.Desktop.Shutdown do
|
||||
_ -> :ok
|
||||
end
|
||||
rescue
|
||||
_error -> :ok
|
||||
error ->
|
||||
Logger.debug("swallowed shutdown kill_heart error: #{inspect(error)}")
|
||||
:ok
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
:exit, reason ->
|
||||
Logger.debug("swallowed shutdown kill_heart exit: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp kill_beam do
|
||||
@@ -133,9 +146,13 @@ defmodule BDS.Desktop.Shutdown do
|
||||
os_kill_fun().(os_pid)
|
||||
:ok
|
||||
rescue
|
||||
_error -> :ok
|
||||
error ->
|
||||
Logger.debug("swallowed shutdown os_kill error: #{inspect(error)}")
|
||||
:ok
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
:exit, reason ->
|
||||
Logger.debug("swallowed shutdown os_kill exit: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp os_kill_fun do
|
||||
@@ -158,9 +175,13 @@ defmodule BDS.Desktop.Shutdown do
|
||||
|
||||
:ok
|
||||
rescue
|
||||
_error -> :ok
|
||||
error ->
|
||||
Logger.debug("swallowed shutdown maybe_hide_window error: #{inspect(error)}")
|
||||
:ok
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
:exit, reason ->
|
||||
Logger.debug("swallowed shutdown maybe_hide_window exit: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc false
|
||||
|
||||
@@ -4,6 +4,13 @@ defmodule BDS.Embeddings do
|
||||
import Ecto.Query
|
||||
require Logger
|
||||
|
||||
@type project_id :: String.t()
|
||||
@type post_id :: String.t()
|
||||
@type progress_opts :: keyword()
|
||||
@type similarity_map :: %{optional(post_id()) => float()}
|
||||
@type indexed_posts_result :: {:ok, [post_id()]} | {:error, term()}
|
||||
@type duplicate_pair_ids :: [{post_id(), post_id()}]
|
||||
|
||||
alias BDS.Persistence
|
||||
alias BDS.Embeddings.DismissedDuplicatePair
|
||||
alias BDS.Embeddings.Index
|
||||
@@ -18,11 +25,19 @@ defmodule BDS.Embeddings do
|
||||
@exact_match_score 0.999999
|
||||
@key_batch_size 199
|
||||
|
||||
@spec model_id() :: term()
|
||||
def model_id, do: configured_backend().model_info().model_id
|
||||
|
||||
@spec dimensions() :: pos_integer()
|
||||
def dimensions, do: configured_backend().model_info().dimensions
|
||||
|
||||
@spec index_path(project_id()) :: String.t()
|
||||
def index_path(project_id), do: Index.path(project_id)
|
||||
|
||||
@spec reindex_all(project_id()) :: indexed_posts_result()
|
||||
def reindex_all(project_id), do: rebuild_project(project_id)
|
||||
|
||||
@spec refresh_snapshot(project_id()) :: :ok
|
||||
def refresh_snapshot(project_id) when is_binary(project_id) do
|
||||
if enabled_for_project?(project_id) do
|
||||
:ok = rebuild_snapshot(project_id)
|
||||
@@ -31,6 +46,8 @@ defmodule BDS.Embeddings do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec get_indexing_progress(project_id()) ::
|
||||
{:ok, %{indexed: non_neg_integer(), total: non_neg_integer()}}
|
||||
def get_indexing_progress(project_id) when is_binary(project_id) do
|
||||
indexed =
|
||||
Repo.one(
|
||||
@@ -49,6 +66,7 @@ defmodule BDS.Embeddings do
|
||||
{:ok, %{indexed: indexed, total: total}}
|
||||
end
|
||||
|
||||
@spec sync_post(Post.t() | post_id()) :: :ok
|
||||
def sync_post(%Post{} = post) do
|
||||
if enabled_for_project?(post.project_id) do
|
||||
sync_post_if_enabled(post, refresh_index: true)
|
||||
@@ -64,6 +82,7 @@ defmodule BDS.Embeddings do
|
||||
end
|
||||
end
|
||||
|
||||
@spec repair_posts(project_id(), [post_id()]) :: indexed_posts_result()
|
||||
def repair_posts(project_id, post_ids) when is_binary(project_id) and is_list(post_ids) do
|
||||
if enabled_for_project?(project_id) do
|
||||
post_ids = Enum.uniq(post_ids)
|
||||
@@ -91,6 +110,8 @@ defmodule BDS.Embeddings do
|
||||
end
|
||||
end
|
||||
|
||||
@spec rebuild_project(project_id()) :: indexed_posts_result()
|
||||
@spec rebuild_project(project_id(), progress_opts()) :: indexed_posts_result()
|
||||
def rebuild_project(project_id, opts \\ [])
|
||||
|
||||
def rebuild_project(project_id, opts) when is_binary(project_id) and is_list(opts) do
|
||||
@@ -130,6 +151,7 @@ defmodule BDS.Embeddings do
|
||||
end
|
||||
end
|
||||
|
||||
@spec diff_reports(project_id()) :: [map()]
|
||||
def diff_reports(project_id) when is_binary(project_id) do
|
||||
if enabled_for_project?(project_id) do
|
||||
keys_by_post =
|
||||
@@ -347,6 +369,7 @@ defmodule BDS.Embeddings do
|
||||
end)
|
||||
end
|
||||
|
||||
@spec remove_post(post_id()) :: :ok
|
||||
def remove_post(post_id) when is_binary(post_id) do
|
||||
project_id =
|
||||
case Repo.get_by(Key, post_id: post_id) do
|
||||
@@ -369,6 +392,7 @@ defmodule BDS.Embeddings do
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec index_unindexed(project_id()) :: indexed_posts_result()
|
||||
def index_unindexed(project_id) when is_binary(project_id) do
|
||||
if enabled_for_project?(project_id) do
|
||||
posts =
|
||||
@@ -398,6 +422,8 @@ defmodule BDS.Embeddings do
|
||||
end
|
||||
end
|
||||
|
||||
@spec find_similar(post_id()) :: {:ok, [map()]}
|
||||
@spec find_similar(post_id(), pos_integer()) :: {:ok, [map()]}
|
||||
def find_similar(post_id, limit \\ 5) when is_binary(post_id) and is_integer(limit) do
|
||||
case source_post_and_vector(post_id) do
|
||||
{:disabled, _project_id} ->
|
||||
@@ -431,6 +457,7 @@ defmodule BDS.Embeddings do
|
||||
end
|
||||
end
|
||||
|
||||
@spec compute_similarities(post_id(), [post_id()]) :: {:ok, similarity_map()}
|
||||
def compute_similarities(source_post_id, target_post_ids)
|
||||
when is_binary(source_post_id) and is_list(target_post_ids) do
|
||||
case source_post_and_vector(source_post_id) do
|
||||
@@ -468,6 +495,7 @@ defmodule BDS.Embeddings do
|
||||
end
|
||||
end
|
||||
|
||||
@spec suggest_tags(post_id(), term()) :: {:ok, [String.t()]}
|
||||
def suggest_tags(post_id, _input_text) when is_binary(post_id) do
|
||||
with {:ok, _post} <- fetch_post(post_id),
|
||||
{:ok, similar} <- find_similar(post_id, 10) do
|
||||
@@ -497,6 +525,8 @@ defmodule BDS.Embeddings do
|
||||
end
|
||||
end
|
||||
|
||||
@spec find_duplicates(project_id()) :: {:ok, [map()]}
|
||||
@spec find_duplicates(project_id(), progress_opts()) :: {:ok, [map()]}
|
||||
def find_duplicates(project_id, opts \\ []) when is_binary(project_id) do
|
||||
if enabled_for_project?(project_id) do
|
||||
on_progress = progress_callback(opts)
|
||||
@@ -521,6 +551,8 @@ defmodule BDS.Embeddings do
|
||||
end
|
||||
end
|
||||
|
||||
@spec dismiss_duplicate_pair(post_id(), post_id()) ::
|
||||
{:ok, DismissedDuplicatePair.t()} | {:error, :not_found}
|
||||
def dismiss_duplicate_pair(post_id_a, post_id_b)
|
||||
when is_binary(post_id_a) and is_binary(post_id_b) do
|
||||
with {:ok, post_a} <- fetch_post(post_id_a),
|
||||
@@ -552,6 +584,8 @@ defmodule BDS.Embeddings do
|
||||
end
|
||||
end
|
||||
|
||||
@spec dismiss_duplicate_pairs(duplicate_pair_ids()) ::
|
||||
{:ok, [DismissedDuplicatePair.t()]} | {:error, term()}
|
||||
def dismiss_duplicate_pairs(pair_ids) when is_list(pair_ids) do
|
||||
pair_ids
|
||||
|> Enum.filter(fn
|
||||
|
||||
@@ -7,6 +7,15 @@ defmodule BDS.Embeddings.DismissedDuplicatePair do
|
||||
@primary_key {:id, :string, autogenerate: false}
|
||||
@foreign_key_type :string
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: String.t() | nil,
|
||||
project_id: String.t() | nil,
|
||||
project: term(),
|
||||
post_id_a: String.t() | nil,
|
||||
post_id_b: String.t() | nil,
|
||||
dismissed_at: integer() | nil
|
||||
}
|
||||
|
||||
schema "dismissed_duplicate_pairs" do
|
||||
belongs_to :project, BDS.Projects.Project, type: :string
|
||||
field :post_id_a, :string
|
||||
|
||||
@@ -23,6 +23,7 @@ defmodule BDS.Embeddings.Index do
|
||||
|
||||
alias BDS.Projects
|
||||
alias BDS.ProgressReporter
|
||||
require Logger
|
||||
|
||||
@neighbor_limit 21
|
||||
@debounce_ms 5_000
|
||||
@@ -345,7 +346,12 @@ defmodule BDS.Embeddings.Index do
|
||||
write_meta(index_path, dim, labels)
|
||||
:ok
|
||||
rescue
|
||||
_exception -> :ok
|
||||
exception ->
|
||||
Logger.debug(
|
||||
"swallowed embeddings index persist error for #{project_id}: #{inspect(exception)}"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp write_meta(index_path, dim, labels) do
|
||||
@@ -385,7 +391,12 @@ defmodule BDS.Embeddings.Index do
|
||||
_other -> :error
|
||||
end
|
||||
rescue
|
||||
_exception -> :error
|
||||
exception ->
|
||||
Logger.debug(
|
||||
"swallowed embeddings index load_from_disk error for #{project_id}: #{inspect(exception)}"
|
||||
)
|
||||
|
||||
:error
|
||||
end
|
||||
|
||||
defp read_meta(index_path) do
|
||||
|
||||
@@ -2,6 +2,7 @@ defmodule BDS.Git do
|
||||
@moduledoc false
|
||||
|
||||
alias BDS.Projects
|
||||
require Logger
|
||||
|
||||
@default_local_timeout_ms 15_000
|
||||
@default_network_timeout_ms 120_000
|
||||
@@ -562,7 +563,9 @@ defmodule BDS.Git do
|
||||
defp safe_port_close(port) do
|
||||
Port.close(port)
|
||||
catch
|
||||
:error, _reason -> :ok
|
||||
:error, reason ->
|
||||
Logger.debug("swallowed git safe_port_close error: #{inspect(reason)}")
|
||||
:ok
|
||||
end
|
||||
|
||||
defp port_os_pid(port) do
|
||||
|
||||
@@ -2,6 +2,7 @@ defmodule BDS.MCP.Server do
|
||||
@moduledoc false
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
@host "127.0.0.1"
|
||||
@server_name "Blogging Desktop Server"
|
||||
@@ -336,7 +337,9 @@ defmodule BDS.MCP.Server do
|
||||
try do
|
||||
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, owner_pid, self())
|
||||
rescue
|
||||
_error -> :ok
|
||||
error ->
|
||||
Logger.debug("swallowed MCP server maybe_allow_repo error: #{inspect(error)}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ defmodule BDS.Preview do
|
||||
@moduledoc false
|
||||
|
||||
use GenServer
|
||||
require Logger
|
||||
|
||||
alias BDS.Posts
|
||||
alias BDS.Posts.Translation
|
||||
@@ -446,7 +447,9 @@ defmodule BDS.Preview do
|
||||
try do
|
||||
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, owner_pid, self())
|
||||
rescue
|
||||
_error -> :ok
|
||||
error ->
|
||||
Logger.debug("swallowed preview maybe_allow_repo error: #{inspect(error)}")
|
||||
:ok
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ defmodule BDS.Scripting.Capabilities.AppShell do
|
||||
|
||||
alias BDS.Desktop.FolderPicker
|
||||
alias BDS.Desktop.MenuBar
|
||||
require Logger
|
||||
|
||||
@compiled_env Application.compile_env(:bds, :current_env, Mix.env())
|
||||
|
||||
@@ -139,7 +140,9 @@ defmodule BDS.Scripting.Capabilities.AppShell do
|
||||
|
||||
nil
|
||||
rescue
|
||||
_error -> nil
|
||||
error ->
|
||||
Logger.debug("swallowed app_shell trigger_menu_action error: #{inspect(error)}")
|
||||
nil
|
||||
end
|
||||
|
||||
def test_mode? do
|
||||
|
||||
@@ -116,24 +116,9 @@ defmodule BDS.Search do
|
||||
end
|
||||
|
||||
defp search_posts_blank(project_id, filters) do
|
||||
base = from(post in Post, where: post.project_id == ^project_id)
|
||||
filtered = apply_post_filters(base, filters)
|
||||
total = count_query(filtered)
|
||||
|
||||
posts =
|
||||
filtered
|
||||
|> order_by([p], desc: p.created_at)
|
||||
|> limit(^filters.limit)
|
||||
|> offset(^filters.offset)
|
||||
|> Repo.all()
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
posts: posts,
|
||||
total: total,
|
||||
offset: filters.offset,
|
||||
limit: filters.limit
|
||||
}}
|
||||
Post
|
||||
|> blank_search_query(project_id, filters, &apply_post_filters/2)
|
||||
|> paginate_search(filters, :posts, &order_by(&1, [p], desc: p.created_at))
|
||||
end
|
||||
|
||||
defp search_posts_fts(project_id, query_text, filters) do
|
||||
@@ -146,29 +131,9 @@ defmodule BDS.Search do
|
||||
select: %{post_id: f.post_id, fts_rowid: fragment("rowid")}
|
||||
)
|
||||
|
||||
base =
|
||||
Post
|
||||
|> with_cte("fts_results", as: ^fts_subquery)
|
||||
|> join(:inner, [p], fts in "fts_results", on: fts.post_id == p.id)
|
||||
|> where([p], p.project_id == ^project_id)
|
||||
|
||||
filtered = apply_post_filters(base, filters)
|
||||
total = count_query(filtered)
|
||||
|
||||
posts =
|
||||
filtered
|
||||
|> order_by([_, fts], fts.fts_rowid)
|
||||
|> limit(^filters.limit)
|
||||
|> offset(^filters.offset)
|
||||
|> Repo.all()
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
posts: posts,
|
||||
total: total,
|
||||
offset: filters.offset,
|
||||
limit: filters.limit
|
||||
}}
|
||||
Post
|
||||
|> fts_search_query(project_id, filters, fts_subquery, :post_id, &apply_post_filters/2)
|
||||
|> paginate_search(filters, :posts, &order_by(&1, [_, fts], fts.fts_rowid))
|
||||
end
|
||||
|
||||
@spec search_media(String.t(), String.t() | nil, search_filters()) ::
|
||||
@@ -190,24 +155,9 @@ defmodule BDS.Search do
|
||||
end
|
||||
|
||||
defp search_media_blank(project_id, filters) do
|
||||
base = from(media in Media, where: media.project_id == ^project_id)
|
||||
filtered = apply_media_filters(base, filters)
|
||||
total = count_query(filtered)
|
||||
|
||||
media_items =
|
||||
filtered
|
||||
|> order_by([m], desc: m.created_at)
|
||||
|> limit(^filters.limit)
|
||||
|> offset(^filters.offset)
|
||||
|> Repo.all()
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
media: media_items,
|
||||
total: total,
|
||||
offset: filters.offset,
|
||||
limit: filters.limit
|
||||
}}
|
||||
Media
|
||||
|> blank_search_query(project_id, filters, &apply_media_filters/2)
|
||||
|> paginate_search(filters, :media, &order_by(&1, [m], desc: m.created_at))
|
||||
end
|
||||
|
||||
defp search_media_fts(project_id, query_text, filters) do
|
||||
@@ -220,25 +170,38 @@ defmodule BDS.Search do
|
||||
select: %{media_id: f.media_id, fts_rowid: fragment("rowid")}
|
||||
)
|
||||
|
||||
base =
|
||||
Media
|
||||
|> with_cte("fts_results", as: ^fts_subquery)
|
||||
|> join(:inner, [m], fts in "fts_results", on: fts.media_id == m.id)
|
||||
|> where([m], m.project_id == ^project_id)
|
||||
Media
|
||||
|> fts_search_query(project_id, filters, fts_subquery, :media_id, &apply_media_filters/2)
|
||||
|> paginate_search(filters, :media, &order_by(&1, [_, fts], fts.fts_rowid))
|
||||
end
|
||||
|
||||
filtered = apply_media_filters(base, filters)
|
||||
total = count_query(filtered)
|
||||
defp blank_search_query(schema, project_id, filters, apply_filters) do
|
||||
schema
|
||||
|> where([entity], field(entity, :project_id) == ^project_id)
|
||||
|> apply_filters.(filters)
|
||||
end
|
||||
|
||||
media_items =
|
||||
filtered
|
||||
|> order_by([_, fts], fts.fts_rowid)
|
||||
defp fts_search_query(schema, project_id, filters, fts_subquery, join_key, apply_filters) do
|
||||
schema
|
||||
|> with_cte("fts_results", as: ^fts_subquery)
|
||||
|> join(:inner, [entity], fts in "fts_results", on: field(fts, ^join_key) == entity.id)
|
||||
|> where([entity], field(entity, :project_id) == ^project_id)
|
||||
|> apply_filters.(filters)
|
||||
end
|
||||
|
||||
defp paginate_search(query, filters, result_key, order_query) do
|
||||
total = count_query(query)
|
||||
|
||||
items =
|
||||
query
|
||||
|> order_query.()
|
||||
|> limit(^filters.limit)
|
||||
|> offset(^filters.offset)
|
||||
|> Repo.all()
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
media: media_items,
|
||||
result_key => items,
|
||||
total: total,
|
||||
offset: filters.offset,
|
||||
limit: filters.limit
|
||||
|
||||
@@ -3,67 +3,69 @@ defmodule BDS.Tasks do
|
||||
|
||||
use GenServer
|
||||
|
||||
@type task_id :: String.t()
|
||||
@type task_attrs :: map()
|
||||
@type task_info :: map()
|
||||
@type status_snapshot :: map()
|
||||
@type progress_reporter :: (number(), String.t() | nil -> any())
|
||||
@type task_work :: (progress_reporter() -> term())
|
||||
|
||||
@default_max_concurrent 3
|
||||
@default_progress_throttle_ms 250
|
||||
@default_recent_finished_limit 10
|
||||
@default_finished_task_ttl_ms :timer.hours(1)
|
||||
@topic "tasks"
|
||||
|
||||
@spec start_link(term()) :: GenServer.on_start()
|
||||
def start_link(_opts) do
|
||||
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
|
||||
end
|
||||
|
||||
@spec topic() :: String.t()
|
||||
def topic, do: @topic
|
||||
|
||||
@spec submit_task(String.t(), task_work()) :: {:ok, task_info()}
|
||||
@spec submit_task(String.t(), task_work(), task_attrs()) :: {:ok, task_info()}
|
||||
def submit_task(name, work, attrs \\ %{})
|
||||
when is_binary(name) and is_function(work, 1) and is_map(attrs) do
|
||||
GenServer.call(__MODULE__, {:submit_task, name, work, attrs})
|
||||
end
|
||||
|
||||
@spec get_task(task_id()) :: task_info() | nil
|
||||
def get_task(task_id) when is_binary(task_id) do
|
||||
GenServer.call(__MODULE__, {:get_task, task_id})
|
||||
end
|
||||
|
||||
@spec status_snapshot() :: status_snapshot()
|
||||
def status_snapshot do
|
||||
GenServer.call(__MODULE__, :status_snapshot)
|
||||
end
|
||||
|
||||
@spec list_tasks() :: [task_info()]
|
||||
def list_tasks do
|
||||
GenServer.call(__MODULE__, :list_tasks)
|
||||
end
|
||||
|
||||
@spec list_running_tasks() :: [task_info()]
|
||||
def list_running_tasks do
|
||||
GenServer.call(__MODULE__, :list_running_tasks)
|
||||
end
|
||||
|
||||
@spec clear_completed() :: :ok
|
||||
def clear_completed do
|
||||
GenServer.call(__MODULE__, :clear_completed)
|
||||
end
|
||||
|
||||
@spec clear_finished() :: :ok
|
||||
def clear_finished do
|
||||
GenServer.call(__MODULE__, :clear_finished)
|
||||
end
|
||||
|
||||
@spec cancel_task(task_id()) :: :ok | {:error, :not_found | :not_running}
|
||||
def cancel_task(task_id) when is_binary(task_id) do
|
||||
GenServer.call(__MODULE__, {:cancel_task, task_id})
|
||||
end
|
||||
|
||||
def register_external_task(name, attrs \\ %{}) when is_binary(name) and is_map(attrs) do
|
||||
GenServer.call(__MODULE__, {:register_external_task, name, attrs})
|
||||
end
|
||||
|
||||
def report_progress(task_id, value, message) when is_binary(task_id) do
|
||||
GenServer.call(__MODULE__, {:report_progress, task_id, value, message})
|
||||
end
|
||||
|
||||
def complete_task(task_id) when is_binary(task_id) do
|
||||
GenServer.call(__MODULE__, {:complete_task, task_id})
|
||||
end
|
||||
|
||||
def fail_task(task_id, error_message) when is_binary(task_id) do
|
||||
GenServer.call(__MODULE__, {:fail_task, task_id, error_message})
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_state) do
|
||||
{:ok,
|
||||
@@ -163,48 +165,6 @@ defmodule BDS.Tasks do
|
||||
end
|
||||
end
|
||||
|
||||
def handle_call({:register_external_task, name, attrs}, _from, state) do
|
||||
task = new_task(name, :running, attrs) |> Map.put(:started_at, DateTime.utc_now())
|
||||
next_state = put_in(state, [:tasks, task.id], task)
|
||||
{:reply, {:ok, public_task(task)}, next_state}
|
||||
end
|
||||
|
||||
def handle_call({:report_progress, task_id, value, message}, _from, state) do
|
||||
{:reply, :ok, maybe_report_progress(state, task_id, value, message)}
|
||||
end
|
||||
|
||||
def handle_call({:complete_task, task_id}, _from, state) do
|
||||
next_state =
|
||||
state
|
||||
|> update_task(task_id, %{
|
||||
status: :completed,
|
||||
progress: 1.0,
|
||||
finished_at: DateTime.utc_now()
|
||||
})
|
||||
|> start_queued_tasks()
|
||||
|> schedule_finished_task_eviction()
|
||||
|
||||
broadcast_terminal_task(next_state.tasks[task_id])
|
||||
|
||||
{:reply, :ok, next_state}
|
||||
end
|
||||
|
||||
def handle_call({:fail_task, task_id, error_message}, _from, state) do
|
||||
next_state =
|
||||
state
|
||||
|> update_task(task_id, %{
|
||||
status: :failed,
|
||||
message: error_message,
|
||||
finished_at: DateTime.utc_now()
|
||||
})
|
||||
|> start_queued_tasks()
|
||||
|> schedule_finished_task_eviction()
|
||||
|
||||
broadcast_terminal_task(next_state.tasks[task_id])
|
||||
|
||||
{:reply, :ok, next_state}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:task_progress, task_id, value, message}, state) do
|
||||
{:noreply, maybe_report_progress(state, task_id, value, message)}
|
||||
|
||||
@@ -426,35 +426,12 @@ defmodule BDS.UI.Sidebar do
|
||||
|
||||
defp maybe_where_all_tags(query, []), do: query
|
||||
|
||||
defp maybe_where_all_tags(query, tags) do
|
||||
Enum.reduce(tags, query, fn tag, q ->
|
||||
where(
|
||||
q,
|
||||
[p],
|
||||
fragment(
|
||||
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?))",
|
||||
p.tags,
|
||||
^tag
|
||||
)
|
||||
)
|
||||
end)
|
||||
end
|
||||
defp maybe_where_all_tags(query, tags), do: where_all_in_json_array(query, tags, :tags)
|
||||
|
||||
defp maybe_where_all_categories(query, []), do: query
|
||||
|
||||
defp maybe_where_all_categories(query, categories) do
|
||||
Enum.reduce(categories, query, fn category, q ->
|
||||
where(
|
||||
q,
|
||||
[p],
|
||||
fragment(
|
||||
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?) AND lower(value) != 'page')",
|
||||
p.categories,
|
||||
^category
|
||||
)
|
||||
)
|
||||
end)
|
||||
end
|
||||
defp maybe_where_all_categories(query, categories),
|
||||
do: where_all_in_json_array(query, categories, :categories, true)
|
||||
|
||||
defp base_year_month_counts(project_id, pages?) do
|
||||
is_page = if pages?, do: 1, else: 0
|
||||
@@ -692,17 +669,32 @@ defmodule BDS.UI.Sidebar do
|
||||
|
||||
defp maybe_where_all_media_tags(query, []), do: query
|
||||
|
||||
defp maybe_where_all_media_tags(query, tags) do
|
||||
Enum.reduce(tags, query, fn tag, q ->
|
||||
where(
|
||||
q,
|
||||
[m],
|
||||
fragment(
|
||||
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?))",
|
||||
m.tags,
|
||||
^tag
|
||||
defp maybe_where_all_media_tags(query, tags),
|
||||
do: where_all_in_json_array(query, tags, :tags)
|
||||
|
||||
defp where_all_in_json_array(query, items, field_atom, exclude_page? \\ false) do
|
||||
Enum.reduce(items, query, fn item, q ->
|
||||
if exclude_page? do
|
||||
where(
|
||||
q,
|
||||
[x],
|
||||
fragment(
|
||||
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?) AND lower(value) != 'page')",
|
||||
field(x, ^field_atom),
|
||||
^item
|
||||
)
|
||||
)
|
||||
)
|
||||
else
|
||||
where(
|
||||
q,
|
||||
[x],
|
||||
fragment(
|
||||
"EXISTS (SELECT 1 FROM json_each(?) WHERE lower(value) = lower(?))",
|
||||
field(x, ^field_atom),
|
||||
^item
|
||||
)
|
||||
)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
58
test/bds/csm036_file_split_test.exs
Normal file
58
test/bds/csm036_file_split_test.exs
Normal file
@@ -0,0 +1,58 @@
|
||||
defmodule BDS.CSM036FileSplitTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
@shell_live_path "lib/bds/desktop/shell_live.ex"
|
||||
@shell_live_content_state_path "lib/bds/desktop/shell_live/content_state.ex"
|
||||
@chat_path "lib/bds/ai/chat.ex"
|
||||
@chat_title_generator_path "lib/bds/ai/chat_title_generator.ex"
|
||||
@chat_tools_path "lib/bds/ai/chat_tools.ex"
|
||||
@chat_tool_schemas_path "lib/bds/ai/chat_tool_schemas.ex"
|
||||
|
||||
describe "source-level: shell_live refresh orchestration split" do
|
||||
test "shell_live delegates refresh_content and reload_shell to a helper module" do
|
||||
shell_live_source = File.read!(@shell_live_path)
|
||||
content_state_source = File.read!(@shell_live_content_state_path)
|
||||
|
||||
refute shell_live_source =~ "defp refresh_content(socket, workbench) do"
|
||||
refute shell_live_source =~ "defp reload_shell(socket, workbench) do"
|
||||
|
||||
assert shell_live_source =~ "defp refresh_content(socket, workbench), do: ContentState.refresh_content(socket, workbench)"
|
||||
assert shell_live_source =~ "defp reload_shell(socket, workbench), do: ContentState.reload_shell(socket, workbench)"
|
||||
|
||||
assert content_state_source =~ "def refresh_content(socket, workbench) do"
|
||||
assert content_state_source =~ "def reload_shell(socket, workbench) do"
|
||||
end
|
||||
end
|
||||
|
||||
describe "source-level: ai chat title split" do
|
||||
test "chat title generation logic lives in a dedicated helper module" do
|
||||
chat_source = File.read!(@chat_path)
|
||||
title_generator_source = File.read!(@chat_title_generator_path)
|
||||
|
||||
refute chat_source =~ "defp generate_chat_title(user_content, opts)"
|
||||
refute chat_source =~ "defp build_chat_title_request(user_content, model)"
|
||||
refute chat_source =~ "defp sanitize_chat_title(title) when is_binary(title)"
|
||||
|
||||
assert title_generator_source =~ "def generate_chat_title(user_content, opts)"
|
||||
assert title_generator_source =~ "def build_chat_title_request(user_content, model)"
|
||||
assert title_generator_source =~ "def sanitize_chat_title(title) when is_binary(title)"
|
||||
end
|
||||
end
|
||||
|
||||
describe "source-level: ai chat tool schema split" do
|
||||
test "chat tool JSON schema builders live in a dedicated helper module" do
|
||||
chat_tools_source = File.read!(@chat_tools_path)
|
||||
chat_tool_schemas_source = File.read!(@chat_tool_schemas_path)
|
||||
|
||||
refute chat_tools_source =~ "defp tool_spec(name, description, parameters) do"
|
||||
refute chat_tools_source =~ "defp render_chart_schema do"
|
||||
refute chat_tools_source =~ "defp render_form_schema do"
|
||||
refute chat_tools_source =~ "defp post_search_schema(require_query) do"
|
||||
|
||||
assert chat_tool_schemas_source =~ "def tool_spec(name, description, parameters) do"
|
||||
assert chat_tool_schemas_source =~ "def render_chart_schema do"
|
||||
assert chat_tool_schemas_source =~ "def render_form_schema do"
|
||||
assert chat_tool_schemas_source =~ "def post_search_schema(require_query) do"
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -310,14 +310,26 @@ defmodule BDS.Scripting.ApiTest do
|
||||
|
||||
assert {:ok, _published_template} = BDS.Templates.publish_template(template.id)
|
||||
|
||||
assert {:ok, running_task} =
|
||||
BDS.Tasks.register_external_task("preview build", %{
|
||||
group_id: "generation",
|
||||
group_name: "Generation"
|
||||
})
|
||||
runner = self()
|
||||
|
||||
assert {:ok, _running_task} =
|
||||
BDS.Tasks.submit_task(
|
||||
"preview build",
|
||||
fn report ->
|
||||
report.(0.5, "halfway")
|
||||
send(runner, {:task_started, self()})
|
||||
|
||||
receive do
|
||||
:release_task -> {:ok, :done}
|
||||
end
|
||||
end,
|
||||
%{group_id: "generation", group_name: "Generation"}
|
||||
)
|
||||
|
||||
assert_receive {:task_started, task_pid}, 1_000
|
||||
|
||||
on_exit(fn ->
|
||||
_ = BDS.Tasks.complete_task(running_task.id)
|
||||
send(task_pid, :release_task)
|
||||
end)
|
||||
|
||||
source =
|
||||
|
||||
@@ -2,11 +2,14 @@ defmodule BDS.TasksTest do
|
||||
use ExUnit.Case, async: false
|
||||
|
||||
setup do
|
||||
cleanup_task_server()
|
||||
|
||||
original = Application.get_env(:bds, :tasks, [])
|
||||
Application.put_env(:bds, :tasks, max_concurrent: 3, progress_throttle_ms: 250)
|
||||
:ok = BDS.Tasks.clear_finished()
|
||||
|
||||
on_exit(fn ->
|
||||
cleanup_task_server()
|
||||
Application.put_env(:bds, :tasks, original)
|
||||
_ = BDS.Tasks.clear_finished()
|
||||
end)
|
||||
@@ -131,74 +134,129 @@ defmodule BDS.TasksTest do
|
||||
end
|
||||
|
||||
test "progress reports within 250ms throttle window are silently dropped" do
|
||||
assert {:ok, task} = BDS.Tasks.register_external_task("fast progress")
|
||||
runner = self()
|
||||
|
||||
assert {:ok, task} =
|
||||
BDS.Tasks.submit_task("fast progress", fn report ->
|
||||
send(runner, {:started, "fast progress", self()})
|
||||
report.(0.25, "quarter")
|
||||
report.(0.5, "half")
|
||||
|
||||
receive do
|
||||
:release -> {:ok, :done}
|
||||
end
|
||||
end)
|
||||
|
||||
{"fast progress", worker_pid} = receive_started()
|
||||
|
||||
assert :ok = BDS.Tasks.report_progress(task.id, 0.25, "quarter")
|
||||
assert wait_for_task(task.id, &(&1.progress == 0.25)).progress == 0.25
|
||||
|
||||
assert :ok = BDS.Tasks.report_progress(task.id, 0.5, "half")
|
||||
assert task_id = task.id
|
||||
# The 250ms throttle has not elapsed, so progress stays at 0.25.
|
||||
assert wait_for_task(task_id, & &1.progress == 0.25).progress == 0.25
|
||||
assert wait_for_task(task.id, &(&1.progress == 0.25)).progress == 0.25
|
||||
|
||||
on_exit(fn -> BDS.Tasks.complete_task(task.id) end)
|
||||
send(worker_pid, :release)
|
||||
assert wait_for_task(task.id, &(&1.status == :completed)).status == :completed
|
||||
end
|
||||
|
||||
test "progress report with value 1.0 bypasses the throttle" do
|
||||
assert {:ok, task} = BDS.Tasks.register_external_task("completion progress")
|
||||
runner = self()
|
||||
|
||||
assert :ok = BDS.Tasks.report_progress(task.id, 0.25, "quarter")
|
||||
assert {:ok, task} =
|
||||
BDS.Tasks.submit_task("completion progress", fn report ->
|
||||
send(runner, {:started, "completion progress", self()})
|
||||
report.(0.25, "quarter")
|
||||
report.(1.0, "done")
|
||||
|
||||
receive do
|
||||
:release -> {:ok, :done}
|
||||
end
|
||||
end)
|
||||
|
||||
{"completion progress", worker_pid} = receive_started()
|
||||
|
||||
# A completion report (1.0) must go through even if throttled.
|
||||
assert :ok = BDS.Tasks.report_progress(task.id, 1.0, "done")
|
||||
|
||||
assert wait_for_task(task.id, &(&1.progress == 1.0)).progress == 1.0
|
||||
assert wait_for_task(task.id, &(&1.message == "done")).message == "done"
|
||||
|
||||
on_exit(fn -> BDS.Tasks.complete_task(task.id) end)
|
||||
send(worker_pid, :release)
|
||||
assert wait_for_task(task.id, &(&1.status == :completed)).status == :completed
|
||||
end
|
||||
|
||||
test "external tasks are registered as running and can report progress and complete" do
|
||||
test "submitted tasks are registered as running and can report progress and complete" do
|
||||
assert {:ok, task} =
|
||||
BDS.Tasks.register_external_task("preview build", %{
|
||||
BDS.Tasks.submit_task(
|
||||
"preview build",
|
||||
fn report ->
|
||||
report.(0.5, "halfway")
|
||||
|
||||
receive do
|
||||
:release -> {:ok, :done}
|
||||
end
|
||||
end,
|
||||
%{
|
||||
group_id: "generation",
|
||||
group_name: "Generation"
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
assert task.status == :running
|
||||
assert task.status == :pending
|
||||
assert task.group_id == "generation"
|
||||
assert task.group_name == "Generation"
|
||||
|
||||
assert :ok = BDS.Tasks.report_progress(task.id, 0.5, "halfway")
|
||||
|
||||
progressed = wait_for_task(task.id, &(&1.progress == 0.5 and &1.message == "halfway"))
|
||||
assert progressed.status == :running
|
||||
|
||||
assert :ok = BDS.Tasks.complete_task(task.id)
|
||||
task_state = :sys.get_state(BDS.Tasks)
|
||||
%{pid: worker_pid} = task_state.running[task.id]
|
||||
send(worker_pid, :release)
|
||||
|
||||
assert wait_for_task(task.id, &(&1.status == :completed and &1.progress == 1.0)).status ==
|
||||
:completed
|
||||
end
|
||||
|
||||
test "status_snapshot exposes active task details for the desktop shell" do
|
||||
runner = self()
|
||||
|
||||
assert {:ok, first} =
|
||||
BDS.Tasks.register_external_task("preview build", %{
|
||||
group_id: "generation",
|
||||
group_name: "Generation"
|
||||
})
|
||||
BDS.Tasks.submit_task(
|
||||
"preview build",
|
||||
fn report ->
|
||||
send(runner, {:started, "preview build", self()})
|
||||
report.(0.5, "halfway")
|
||||
|
||||
receive do
|
||||
:release -> {:ok, :done}
|
||||
end
|
||||
end,
|
||||
%{group_id: "generation", group_name: "Generation"}
|
||||
)
|
||||
|
||||
assert {:ok, second} =
|
||||
BDS.Tasks.register_external_task("reindex text", %{
|
||||
group_id: "search",
|
||||
group_name: "Search"
|
||||
})
|
||||
BDS.Tasks.submit_task(
|
||||
"reindex text",
|
||||
fn _report ->
|
||||
send(runner, {:started, "reindex text", self()})
|
||||
|
||||
receive do
|
||||
:release -> {:ok, :done}
|
||||
end
|
||||
end,
|
||||
%{group_id: "search", group_name: "Search"}
|
||||
)
|
||||
|
||||
on_exit(fn ->
|
||||
_ = BDS.Tasks.complete_task(first.id)
|
||||
_ = BDS.Tasks.complete_task(second.id)
|
||||
task_state = :sys.get_state(BDS.Tasks)
|
||||
|
||||
Enum.each([first.id, second.id], fn task_id ->
|
||||
case task_state.running[task_id] do
|
||||
%{pid: pid} -> send(pid, :release)
|
||||
nil -> :ok
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
assert :ok = BDS.Tasks.report_progress(first.id, 0.5, "halfway")
|
||||
_ = receive_started()
|
||||
_ = receive_started()
|
||||
|
||||
snapshot = BDS.Tasks.status_snapshot()
|
||||
|
||||
@@ -250,12 +308,23 @@ defmodule BDS.TasksTest do
|
||||
finished_task_ttl_ms: 1
|
||||
)
|
||||
|
||||
assert {:ok, task} = BDS.Tasks.register_external_task("short lived")
|
||||
assert {:ok, running} = BDS.Tasks.register_external_task("still running")
|
||||
runner = self()
|
||||
|
||||
on_exit(fn -> _ = BDS.Tasks.complete_task(running.id) end)
|
||||
assert {:ok, task} = BDS.Tasks.submit_task("short lived", fn _report -> {:ok, :done} end)
|
||||
|
||||
assert {:ok, running} =
|
||||
BDS.Tasks.submit_task("still running", fn _report ->
|
||||
send(runner, {:started, "still running", self()})
|
||||
|
||||
receive do
|
||||
:release -> {:ok, :done}
|
||||
end
|
||||
end)
|
||||
|
||||
{"still running", worker_pid} = receive_started()
|
||||
|
||||
on_exit(fn -> send(worker_pid, :release) end)
|
||||
|
||||
assert :ok = BDS.Tasks.complete_task(task.id)
|
||||
assert wait_for_task(task.id, &(&1.status == :completed)).status == :completed
|
||||
|
||||
Process.sleep(20)
|
||||
@@ -273,15 +342,15 @@ defmodule BDS.TasksTest do
|
||||
finished_task_ttl_ms: 50
|
||||
)
|
||||
|
||||
assert {:ok, first} = BDS.Tasks.register_external_task("first finished")
|
||||
assert {:ok, second} = BDS.Tasks.register_external_task("second finished")
|
||||
assert {:ok, first} = BDS.Tasks.submit_task("first finished", fn _report -> {:ok, :done} end)
|
||||
assert {:ok, second} = BDS.Tasks.submit_task("second finished", fn _report -> {:ok, :done} end)
|
||||
|
||||
assert :ok = BDS.Tasks.complete_task(first.id)
|
||||
assert wait_for_task(first.id, &(&1.status == :completed)).status == :completed
|
||||
first_timer = :sys.get_state(BDS.Tasks).finished_task_eviction_timer
|
||||
assert is_reference(first_timer)
|
||||
assert is_integer(Process.read_timer(first_timer))
|
||||
|
||||
assert :ok = BDS.Tasks.complete_task(second.id)
|
||||
assert wait_for_task(second.id, &(&1.status == :completed)).status == :completed
|
||||
second_timer = :sys.get_state(BDS.Tasks).finished_task_eviction_timer
|
||||
|
||||
assert second_timer == first_timer
|
||||
@@ -336,4 +405,34 @@ defmodule BDS.TasksTest do
|
||||
flunk("task did not reach expected state")
|
||||
end
|
||||
|
||||
defp cleanup_task_server do
|
||||
BDS.Tasks.list_tasks()
|
||||
|> Enum.filter(&(&1.status in [:pending, :running]))
|
||||
|> Enum.each(fn task ->
|
||||
_ = BDS.Tasks.cancel_task(task.id)
|
||||
end)
|
||||
|
||||
wait_until(fn ->
|
||||
BDS.Tasks.list_tasks()
|
||||
|> Enum.all?(&(&1.status not in [:pending, :running]))
|
||||
end)
|
||||
|
||||
:ok = BDS.Tasks.clear_finished()
|
||||
end
|
||||
|
||||
defp wait_until(predicate, attempts \\ 100)
|
||||
|
||||
defp wait_until(predicate, attempts) when attempts > 0 do
|
||||
if predicate.() do
|
||||
:ok
|
||||
else
|
||||
Process.sleep(20)
|
||||
wait_until(predicate, attempts - 1)
|
||||
end
|
||||
end
|
||||
|
||||
defp wait_until(_predicate, 0) do
|
||||
flunk("condition was not met")
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user