Compare commits
154 Commits
e4452ca504
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f073bbebd | |||
| 4dd4781c5a | |||
| a00e4b85ac | |||
| caaec98225 | |||
| 4b1557cf6a | |||
| 985d8b53c2 | |||
| 941db4c6f4 | |||
| a73af6b44d | |||
| e2054c9c12 | |||
| 28e08451e4 | |||
| abbcef594a | |||
| 8224b3d59f | |||
| f7e1662bca | |||
| ae66775cb7 | |||
| 741979fc39 | |||
| 4859c9708a | |||
| cd72998a13 | |||
| f088cfb77b | |||
| bad656924b | |||
| 8ee2b9a7f7 | |||
| 8d245b3492 | |||
| 66938c23f2 | |||
| 2e633922f9 | |||
| e3a1010ae9 | |||
| eac6d543d2 | |||
| 8546080a3d | |||
| d8b24c9b72 | |||
| 63e35d19e3 | |||
| 9325de2db4 | |||
| a5391e8e25 | |||
| 284637970f | |||
| 21b11ef87e | |||
| e6a2055e18 | |||
|
|
bf9340352c | ||
| db98944d10 | |||
| 5e99cb7a09 | |||
| 040b5db37b | |||
| a33131ddea | |||
| ef6f8a54b2 | |||
| 70d2342274 | |||
| c1b7ceae6c | |||
| 1d17b6e884 | |||
| 360a8d971a | |||
| c30757b3b7 | |||
| f6e1b679f0 | |||
| 63d6c9f215 | |||
| 2ba8be2fc6 | |||
| 4731bc0cd2 | |||
| 8bc371eb3f | |||
| b65c2be29b | |||
| ee4d0dd33f | |||
| 8c71ece887 | |||
| b1438d5222 | |||
| 87f2f22241 | |||
| 60acda3fee | |||
| ab6a03dc54 | |||
| 0afb017e43 | |||
| cf553e2f78 | |||
| cb658aba1a | |||
| 544ff65e3b | |||
| 08eb9e4ea1 | |||
| 723a7ec1f7 | |||
| dfb2f8870b | |||
| f0919f24a5 | |||
| 72f2c829ca | |||
| 7c7f629dd2 | |||
| dd760d0f2b | |||
| fb794ae833 | |||
| df0ae6a41b | |||
| e515cfacc6 | |||
| 7e9cc72e1f | |||
| 257a06e5d1 | |||
| 1b37f1fcec | |||
| 56caa653bb | |||
| 925fe97007 | |||
| d688c61b0e | |||
| 8db7bcf357 | |||
| 2bed225133 | |||
| 7045b10738 | |||
| ebf6136d2f | |||
| ae6659bcf3 | |||
| 8bfc509472 | |||
| e89a061d8f | |||
| d606d9b26b | |||
| a9740207cc | |||
| 535ab81082 | |||
| 0ce90e96e5 | |||
| 8cb6d238b9 | |||
| cf8b0af15f | |||
| 9d5764b251 | |||
| 3a77761f96 | |||
| aff4b63188 | |||
| 91b0ffe4c5 | |||
| 84b91750fb | |||
| d03d033548 | |||
| 74ceaeb971 | |||
| 61ff2a77c0 | |||
| 744f7543d7 | |||
| a1004d72bf | |||
| 489d787306 | |||
| babae1838d | |||
| 5b619f492a | |||
| b3434b3054 | |||
| 5b21dcb17d | |||
| 1f645f6e5e | |||
| 99d36e6e2f | |||
| d7e30b94cb | |||
| f1265ee326 | |||
| c5e09e7316 | |||
| 1ae6152da7 | |||
| 0305d80051 | |||
| a021fc45cd | |||
| fceb995c7c | |||
| e58d68e73e | |||
| 0f30221907 | |||
| d423b6db98 | |||
| 3adb4407a0 | |||
| 05923f255b | |||
| ff89d78ab4 | |||
| e2c92cb90d | |||
| 82ce445c44 | |||
| f99e139fa5 | |||
| 1914b05f39 | |||
| b09b14cc03 | |||
| 721b1ae626 | |||
| f7a4a9512c | |||
| 141c2bfc89 | |||
| a5ac74db91 | |||
| beca4d992f | |||
| 9e6d93a4b3 | |||
| e29dfb490a | |||
| f2b340ba86 | |||
| d18e0ef7f2 | |||
| 2d796cee83 | |||
| b052d59376 | |||
| 4a089b0856 | |||
| 2632649cdc | |||
| 782511d523 | |||
| 1cb59d7a78 | |||
| 9844f3555a | |||
| 99dc1c2216 | |||
| 71fb99af16 | |||
| 0808b27057 | |||
| ac4f5a3580 | |||
| 43a435f35d | |||
| 7b383d31ab | |||
| 09df925e9b | |||
| a4ecbabc21 | |||
| 2be43ca06d | |||
| d231f42363 | |||
| 7c00279b9d | |||
| b6f9cf58e1 | |||
| 3f77488e33 | |||
| 5c17751d55 |
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "phoenix",
|
||||
"runtimeExecutable": "mix",
|
||||
"runtimeArgs": ["phx.server"],
|
||||
"port": 4000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
- [Fix all test failures](feedback_fix_all_failures.md) — Never dismiss failures as pre-existing; fix everything
|
||||
- [Fix all test failures](feedback_fix_all_failures.md) — Never dismiss failures as pre-existing or flaky; investigate and fix
|
||||
- [Debug targeted](feedback_targeted_debugging.md) — Analyze the code and fix; don't brute-force with repeated suite runs
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
---
|
||||
name: Fix all test failures
|
||||
description: Never dismiss test failures as pre-existing — if tests fail after changes, fix them
|
||||
name: Fix all test failures including flaky ones
|
||||
description: Never dismiss test failures as pre-existing or flaky — investigate root cause and stabilize
|
||||
type: feedback
|
||||
---
|
||||
|
||||
All test failures after changes must be fixed, even if they appear unrelated. The test suite was clean before, so any failure is the responsibility of the current change.
|
||||
All test failures must be fixed, even if they appear unrelated to current changes. The test suite was clean before, so any failure is my responsibility.
|
||||
|
||||
**Why:** The user confirmed the suite was green before. Dismissing failures as "pre-existing" is wrong and wastes time.
|
||||
Flaky tests are deeper problems waiting to surface. Running a test in isolation and seeing it pass is never enough — must find out why it was flaky in the full suite run and make it stable.
|
||||
|
||||
**How to apply:** After making changes, if any test fails, investigate and fix it before reporting the task as done. Never stash/skip/ignore failures.
|
||||
**Why:** Dismissing failures as "pre-existing" or "flaky" is wrong. Flaky tests indicate real issues (race conditions, test pollution, shared state) that will bite harder later.
|
||||
|
||||
**How to apply:** After making changes, if any test fails: investigate the root cause, fix it, and verify it passes reliably in the full suite. Never stash, never skip, never re-run and hope. Never dismiss ordering-dependent failures — find and fix the shared state or race condition.
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
name: Debug targeted, don't brute-force
|
||||
description: When investigating flaky tests, analyze the code and fix — don't brute-force with repeated full suite runs
|
||||
type: feedback
|
||||
---
|
||||
|
||||
If you already know which test is failing and why, fix it. Don't waste time running the full suite 20 times hoping to capture output.
|
||||
|
||||
**Why:** It's slow, wasteful, and avoids thinking. Analyze the code, understand the race, fix it.
|
||||
|
||||
**How to apply:** When a test fails, read the test, understand what it does, identify the root cause, and fix it. Only re-run to verify the fix, not to gather more data you already have.
|
||||
@@ -4,7 +4,28 @@
|
||||
"Bash(mix compile *)",
|
||||
"Bash(mix test *)",
|
||||
"Bash(mix dialyzer *)",
|
||||
"Bash(mix ecto.migrate)"
|
||||
"Bash(mix ecto.migrate)",
|
||||
"Bash(git add *)",
|
||||
"Bash(git push *)",
|
||||
"Bash(git -C /Users/gb/Projects/bDS2 status)",
|
||||
"Bash(git status *)",
|
||||
"Bash(mix assets.deploy)",
|
||||
"Bash(mix phx.server)",
|
||||
"mcp__Claude_Preview__preview_start",
|
||||
"mcp__Claude_in_Chrome__navigate",
|
||||
"mcp__Claude_in_Chrome__computer",
|
||||
"mcp__Claude_in_Chrome__browser_batch",
|
||||
"mcp__Claude_in_Chrome__javascript_tool",
|
||||
"Bash(allium check *)",
|
||||
"Bash(mix deps.get)",
|
||||
"Bash(allium --help)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:raw.githubusercontent.com)",
|
||||
"Bash(echo \"EXIT: $?\")",
|
||||
"Bash(echo \"exit: $?\")",
|
||||
"Bash(MIX_ENV=prod mix release bds --overwrite)",
|
||||
"Bash(MIX_ENV=prod mix bds.bundle.macos)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
53
.credo.exs
Normal file
53
.credo.exs
Normal file
@@ -0,0 +1,53 @@
|
||||
%{
|
||||
configs: [
|
||||
%{
|
||||
name: "default",
|
||||
files: %{
|
||||
included: ["lib/", "test/", "config/", "mix.exs"],
|
||||
excluded: [~r"/deps/", ~r"/_build/", ~r"/priv/static/"]
|
||||
},
|
||||
strict: true,
|
||||
parse_timeout: 10_000,
|
||||
color: true,
|
||||
checks: [
|
||||
{Credo.Check.Consistency.ExceptionNames},
|
||||
{Credo.Check.Consistency.LineEndings},
|
||||
{Credo.Check.Consistency.SpaceAroundOperators},
|
||||
{Credo.Check.Consistency.SpaceInParentheses},
|
||||
{Credo.Check.Consistency.TabsOrSpaces},
|
||||
{Credo.Check.Design.AliasUsage, false},
|
||||
{Credo.Check.Readability.BlockPipe, false},
|
||||
{Credo.Check.Readability.AliasOrder, false},
|
||||
{Credo.Check.Readability.LargeNumbers, false},
|
||||
{Credo.Check.Readability.MaxLineLength, false},
|
||||
{Credo.Check.Readability.ModuleDoc, false},
|
||||
{Credo.Check.Readability.PreferImplicitTry, false},
|
||||
{Credo.Check.Readability.Semicolons, false},
|
||||
{Credo.Check.Readability.StringSigils, false},
|
||||
{Credo.Check.Readability.TrailingBlankLine, false},
|
||||
{Credo.Check.Readability.UnnecessaryAliasExpansion, false},
|
||||
{Credo.Check.Readability.WithSingleClause, false},
|
||||
{Credo.Check.Refactor.Apply, false},
|
||||
{Credo.Check.Refactor.CondStatements, false},
|
||||
{Credo.Check.Refactor.CyclomaticComplexity, false},
|
||||
{Credo.Check.Refactor.FilterFilter, false},
|
||||
{Credo.Check.Refactor.FilterReject, false},
|
||||
{Credo.Check.Refactor.FunctionArity, false},
|
||||
{Credo.Check.Refactor.MapJoin, false},
|
||||
{Credo.Check.Refactor.Nesting, false},
|
||||
{Credo.Check.Refactor.NegatedConditionsWithElse, false},
|
||||
{Credo.Check.Refactor.RejectFilter, false},
|
||||
{Credo.Check.Refactor.RejectReject, false},
|
||||
{Credo.Check.Refactor.RedundantWithClauseResult, false},
|
||||
{Credo.Check.Warning.ApplicationConfigInModuleAttribute},
|
||||
{Credo.Check.Warning.BoolOperationOnSameValues},
|
||||
{Credo.Check.Warning.ExpensiveEmptyEnumCheck},
|
||||
{Credo.Check.Warning.IExPry},
|
||||
{Credo.Check.Warning.LazyLogging},
|
||||
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, false},
|
||||
{Credo.Check.Warning.OperationOnSameValues},
|
||||
{Credo.Check.Warning.RaiseInsideRescue}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
@@ -3,10 +3,17 @@
|
||||
/deps/
|
||||
/dist/
|
||||
/doc/
|
||||
/tmp/
|
||||
/.elixir_ls/
|
||||
/erl_crash.dump
|
||||
/node_modules/
|
||||
/priv/data/*.db
|
||||
/priv/data/*.db-shm
|
||||
/priv/data/*.db-wal
|
||||
*.ez
|
||||
# Project public content (posts, media, templates, generated html) lives in the
|
||||
# per-user default content folder, never the repo. See PublicContentLivesInProjectFolder.
|
||||
/priv/data/projects/
|
||||
# Embeddings index artifacts are per-project runtime caches, never committed.
|
||||
*.usearch
|
||||
*.usearch.meta.json
|
||||
*.eztmp/
|
||||
|
||||
4
.mix_audit.ignore
Normal file
4
.mix_audit.ignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# GHSA-rhv4-8758-jx7v is pinned transitively through bumblebee -> progress_bar.
|
||||
# ecto_sqlite3 0.24.x can move to decimal 3.x, but that line is currently
|
||||
# unsatisfiable alongside the app's Bumblebee dependency.
|
||||
GHSA-rhv4-8758-jx7v
|
||||
@@ -28,7 +28,8 @@ This document provides context and best practices for GitHub Copilot when workin
|
||||
- we have an allium spec in the specs/ folder. you must weed the specs against built code to make sure you follow the spec.
|
||||
- when changing the spec, validate the spec with the available command line tool.
|
||||
- you MUST run tests with command line tools at least once to capture compile errors in tests, do not use the integrated testing of vscode, as that blocks on compile errors
|
||||
- you MUST run build, test and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests and clean dialyzer results
|
||||
- you MUST run build, test, credo, deps.audit and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests, clean credo, clean dependency audits and clean dialyzer results
|
||||
- on a headless Linux machine, you have to run tests with this command (if mix test complains about DISPLAX): xvfb-run mix test
|
||||
|
||||
---
|
||||
|
||||
@@ -54,7 +55,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
||||
|
||||
- Never leave tests failing, even if they appear unrelated to your changes
|
||||
- If a test failure is pre-existing, fix it as part of your current work
|
||||
- Run the full test suite (`npm test`) before considering any task complete
|
||||
- Run the full test suite (`mix test`) before considering any task complete
|
||||
- If you cannot fix a test, explain why and propose a solution
|
||||
|
||||
> **Zero failing tests. No exceptions.**
|
||||
@@ -105,7 +106,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
||||
|
||||
**All user-facing text MUST follow proper i18n patterns.**
|
||||
|
||||
- Do not hardcode UI strings directly in React components, menu templates, dialogs, or toasts
|
||||
- Do not hardcode UI strings directly in LiveView/HEEx components, menu templates, dialogs, or toasts
|
||||
- Store UI copy in language resources and resolve text through i18n helpers/hooks
|
||||
- UI language MUST come from the operating system locale
|
||||
- Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale
|
||||
@@ -113,6 +114,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
||||
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
|
||||
- English fallback is allowed only when the requested locale is unsupported by available locale files
|
||||
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
|
||||
- When adding new `msgid` entries, you MUST provide translations for ALL supported locales (de, fr, it, es) — empty `msgstr` values are not acceptable
|
||||
|
||||
> **No hardcoded user-facing text. No exceptions.**
|
||||
|
||||
|
||||
467
CODESMELL.md
467
CODESMELL.md
@@ -1,467 +0,0 @@
|
||||
# bDS2 Elixir Anti-Pattern & Best-Practice Audit
|
||||
|
||||
> Audited: 2026-05-06
|
||||
> Scope: Elixir application, Phoenix LiveView UI, Ecto DB layer, Desktop (wx) integration, Rendering/Generation pipelines
|
||||
|
||||
---
|
||||
|
||||
## How to use this file
|
||||
|
||||
1. Pick a section.
|
||||
2. Search the codebase for the file/line references.
|
||||
3. Write a failing test that reproduces the issue.
|
||||
4. Fix the code.
|
||||
5. Run the full test suite and `mix dialyzer`.
|
||||
6. Delete the item from this file.
|
||||
|
||||
---
|
||||
|
||||
## Critical (Fix Immediately)
|
||||
|
||||
### ~~CSM-001 — Atom Table Exhaustion Vulnerability~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-06
|
||||
- **What was done:**
|
||||
- Added `BDS.MapUtils.safe_atomize_key/1` and `BDS.MapUtils.safe_atomize_keys/1` — uses `String.to_existing_atom/1` with rescue fallback to keep unknown keys as strings.
|
||||
- Replaced all 6 affected `String.to_atom` call sites:
|
||||
- `lib/bds/import_definitions.ex` — `atomize_keys/1` → `MapUtils.safe_atomize_keys/1`
|
||||
- `lib/bds/import_execution.ex` — `normalize_report/1` → `MapUtils.safe_atomize_keys/1`
|
||||
- `lib/bds/ai/catalog.ex` — `atomize_map_keys/1` → `MapUtils.safe_atomize_keys/1`, `parse_modality/1` → `MapUtils.safe_atomize_key/1`
|
||||
- `lib/bds/ai/chat_tools.ex` — `metadata_attrs/2` → `MapUtils.safe_atomize_key/1`
|
||||
- `lib/bds/desktop/automation.ex` — `atomize_map/1` → `MapUtils.safe_atomize_keys/1`
|
||||
- Replaced lower-risk `String.to_atom` with `String.to_existing_atom/1`:
|
||||
- `lib/bds/ui/menu_bar.ex` — sidebar view and singleton editor command IDs
|
||||
- `lib/bds/ui/workbench.ex` — `normalize_type/1`
|
||||
- `lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex` — `map_value/3`
|
||||
- `lib/bds/release_packaging.ex` — `normalize_platform/1`
|
||||
- Updated `test/bds/bounded_atoms_test.exs` to enforce no `String.to_atom` on dynamic data (replaced old `String.to_existing_atom` ban).
|
||||
|
||||
---
|
||||
|
||||
### ~~CSM-002 — Search Loads Entire Tables into Memory~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-07
|
||||
- **What was done:**
|
||||
- Replaced `search_posts/3` and `search_media/3` with SQL-level filtering and pagination.
|
||||
- Blank queries now use pure Ecto queries with `where` clauses for status, language, year/month, date range, tags, categories, and missing translations.
|
||||
- Non-blank (FTS) queries use a CTE (`WITH fts_results AS (...)`) to preserve `bm25` ordering, joined with the posts/media table, with all filters applied in SQL.
|
||||
- Tag and category overlap filtering uses `json_each` in `EXISTS` subqueries.
|
||||
- Missing-translation filtering uses a `NOT EXISTS` correlated subquery.
|
||||
- Count uses `select count` + `Repo.one` instead of `length(all_records)`.
|
||||
- Pagination uses SQL `LIMIT`/`OFFSET` instead of `Enum.drop`/`Enum.take`.
|
||||
- Removed all old Elixir-side filter helpers: `candidate_post_ids`, `load_posts_in_order`, `filter_posts`, `paginate`, `matches_status?`, `matches_overlap?`, etc.
|
||||
- Added comprehensive tests for blank-query and non-blank-query filtering across all filter dimensions.
|
||||
|
||||
---
|
||||
|
||||
### ~~CSM-003 — Non-Atomic Side Effects in Post CRUD~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-07
|
||||
- **What was done:**
|
||||
- Replaced all 11 `Repo.delete!` call sites with `Repo.delete` + `{:error, _}` handling:
|
||||
- `lib/bds/posts.ex` — `delete_post/1`
|
||||
- `lib/bds/scripts.ex` — `delete_script/1`
|
||||
- `lib/bds/media.ex` — `delete_media/1`, `delete_media_translation/3`
|
||||
- `lib/bds/templates.ex` — `delete_template/2`, `remove_orphan_templates/2`
|
||||
- `lib/bds/tags.ex` — `delete_tag/1`, `merge_tags/2`
|
||||
- `lib/bds/projects.ex` — `delete_project/1`
|
||||
- `lib/bds/posts/translations.ex` — `delete_post_translation/1`
|
||||
- `lib/bds/posts/translation_validation.ex` — `fix_invalid_database_row/1`
|
||||
- Reordered `delete_post/1` to perform `Repo.delete` first, then clean up files/embeddings/search/links. Side effects now only run after DB commit succeeds.
|
||||
- Same reordering applied to `delete_script/1`, `delete_media/1`, `delete_template/2`, and `delete_post_translation/1`.
|
||||
- `delete_media/1` now wraps translation + media deletes in a `Repo.transaction` for atomicity.
|
||||
- Tags and projects already used `Repo.transaction`; replaced inner `Repo.delete!` with `Repo.delete` + `Repo.rollback` on error.
|
||||
- Added tests for delete atomicity and not-found handling.
|
||||
|
||||
---
|
||||
|
||||
### ~~CSM-004 — Blocking `init/1` + Missing `terminate/2` in Job Runner~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-08
|
||||
- **What was done:**
|
||||
- Moved `JobStore.attach_runner/2` from `init/1` to a new `handle_continue(:attach_and_start)` callback, so supervisor startup is no longer blocked by the synchronous call.
|
||||
- Added `terminate/2` callback that calls `JobStore.detach_runner/2` (with `try/catch` for shutdown safety), centralizing cleanup that was previously scattered across individual exit paths.
|
||||
- Added `handle_info({:EXIT, _pid, _reason})` clause to handle trapped exit signals from linked processes.
|
||||
- Removed redundant inline `detach_runner` calls from `handle_call(:cancel)`, task result handler, and `:DOWN` handler — `terminate/2` now handles all detach cleanup.
|
||||
- Changed `restart: :temporary` since job runners are one-shot processes that should not auto-restart on failure.
|
||||
- Added `@impl true` to all `handle_info` clauses.
|
||||
- Fixed pre-existing bug in `JobStore.detach_runner` handler where `update_in/2` macro result was incorrectly double-wrapped, corrupting state.
|
||||
- Added test: start a runner, kill it externally (not via cancel), assert `JobStore` no longer contains the dead PID.
|
||||
|
||||
---
|
||||
|
||||
### ~~CSM-005 — Client-Side Filtering of Entire Tables~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-08
|
||||
- **What was done:**
|
||||
- **Sidebar** (`lib/bds/ui/sidebar.ex`):
|
||||
- Removed `list_posts/1` and `list_media/1` that loaded all records into memory.
|
||||
- Replaced `apply_post_filters/1` and `apply_media_filters/1` (Elixir-side filtering) with SQL `WHERE` clauses using Ecto dynamic queries and SQLite `json_each` fragments.
|
||||
- Page/non-page split now uses `EXISTS (SELECT 1 FROM json_each(categories) WHERE lower(value) = 'page')` in SQL.
|
||||
- Search, year/month, tag, and category filters all push to SQL via `maybe_where_search`, `maybe_where_year`, `maybe_where_month`, `maybe_where_all_tags`, `maybe_where_all_categories`.
|
||||
- Aggregate queries (`year_month_counts`, `available_tags`, `available_categories`) use `Ecto.Adapters.SQL.query!` with `json_each` cross-joins, `GROUP BY`, and `DISTINCT`.
|
||||
- Pagination uses SQL `LIMIT` instead of `Enum.take`.
|
||||
- `tag_count/1` replaces `list_tags/1` + `length/1` with `Repo.one(select: count(tag.id))`.
|
||||
- Fixed `group_posts/1` O(n²) `acc.draft ++ [post]` pattern — now uses `Enum.group_by/2` (also fixes CSM-024).
|
||||
- **Tags** (`lib/bds/tags.ex`):
|
||||
- `posts_with_tag/2` now uses `EXISTS (SELECT 1 FROM json_each(?) WHERE value = ?)` instead of loading all posts.
|
||||
- `posts_with_any_tag/2` now uses `json_each` cross-join with a JSON parameter for the tag name list.
|
||||
- `post_tag_names/1` now selects only the `tags` column instead of loading full post records.
|
||||
- **Dashboard** (`lib/bds/ui/dashboard.ex`):
|
||||
- `post_stats` uses `GROUP BY post.status, SELECT {status, count(id)}` — no longer loads all posts.
|
||||
- `media_stats` uses `SELECT count(id), coalesce(sum(size), 0)` and a separate image count query with `LIKE 'image/%'`.
|
||||
- `tag_cloud_items` and `category_counts` use raw SQL with `json_each` cross-joins and `GROUP BY`.
|
||||
- `timeline_entries` uses SQL `strftime` + `GROUP BY` for year/month aggregation.
|
||||
- `recent_posts` uses SQL `ORDER BY updated_at DESC LIMIT 5`.
|
||||
- **Posts** (`lib/bds/posts.ex`):
|
||||
- `dashboard_stats/1` uses `GROUP BY post.status, SELECT {status, count(id)}` instead of loading all statuses.
|
||||
- **Capabilities** (`lib/bds/scripting/capabilities/`):
|
||||
- `tag_post_ids/2` uses `json_each` fragment + `SELECT post.id` instead of loading all posts.
|
||||
- `names_with_counts/2` uses raw SQL with `json_each` + `GROUP BY` instead of loading all posts.
|
||||
- `posts_by_status/2` filters at SQL level instead of loading all posts and filtering in Elixir.
|
||||
- Added 20 tests in `test/bds/csm005_sql_filtering_test.exs` covering dashboard stats, tag cloud, sidebar page/post separation, tag/search/year-month filters, available aggregates, and media filtering.
|
||||
|
||||
---
|
||||
|
||||
## High Severity
|
||||
|
||||
### ~~CSM-006 — N+1 Queries in Reindexing & Rendering~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-08
|
||||
- **What was done:**
|
||||
- **Batch INSERT for reindexing:** Replaced per-row `Repo.query!` INSERT in `reindex_posts/2` and `reindex_media/2` with multi-row batch INSERTs. Rows are chunked at 166 per batch (SQLite 999-parameter limit ÷ 6 columns). Translations were already preloaded in batch; fixed O(n²) `acc ++ [translation]` pattern in `preload_post_translations` and `preload_media_translations` by replacing with `Enum.group_by`.
|
||||
- **Rendering — preloaded post records:** `PostRendering.post_assigns/2` now accepts an optional `:_post_record` key in assigns, skipping the `Repo.get(Post, id)` re-query when the record is already available.
|
||||
- **Generation outputs pass records:** `build_page_outputs` and `build_post_outputs` in `outputs.ex` now pass the already-loaded post/translation records via `:_post_record`, eliminating per-post DB queries during generation.
|
||||
- **ListArchive** already used `load_post_records_batch` (batch query) — no change needed.
|
||||
- Added telemetry-based query counting tests: reindex 100 posts/media and assert total query count <10.
|
||||
|
||||
---
|
||||
|
||||
### ~~CSM-007 — Monolithic State Rebuild ("God Function")~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-09
|
||||
- **What was done:**
|
||||
- Decomposed `reload_shell/2` into four focused updaters:
|
||||
- `refresh_layout/2` — No DB queries. Recomputes workbench-derived assigns (activity_buttons, panel_tabs, current_tab, status_bar, sidebar_header, editor_meta) from existing socket.assigns.
|
||||
- `refresh_sidebar/2` — Queries sidebar data only, then calls `refresh_layout`.
|
||||
- `refresh_content/2` — Queries projects, dashboard, git badge, and sidebar data, then calls `refresh_layout`.
|
||||
- `reload_shell/2` — Full refresh: tab_meta sync, task status, static data, then calls `refresh_content`. Kept for mount, project switch, session restore, and settings changes.
|
||||
- Replaced all call sites with the minimal refresh needed:
|
||||
- **Layout-only** (`refresh_layout`): toggle_sidebar, toggle_panel, toggle_assistant_sidebar, select_panel_tab, sync_layout, resize_panel, open_tasks_panel, select_tab, close_tab, toggle_offline_mode, layout menu actions (toggle, close_tab).
|
||||
- **Sidebar** (`refresh_sidebar`): select_view, all sidebar filter events, sidebar menu actions (view_posts, view_media, edit_preferences, etc.), chat/import editor tab_meta updates.
|
||||
- **Content** (`refresh_content`): entity_changed (CLI sync), tags_changed, sidebar create/delete.
|
||||
- **Full reload** (`reload_shell`): mount, activate_project, restore_workbench_session, set_page_language, settings_changed.
|
||||
- Updated Bridges callbacks to use focused refreshers: `refresh_layout` for toggle events and close_tab, `refresh_sidebar` for view switches and tab meta updates, `refresh_content` for entity/tag changes.
|
||||
- Split `@local_menu_actions` into `@layout_menu_actions` and `@sidebar_menu_actions` for correct dispatch.
|
||||
- Fixed `false || true` bug in `refresh_layout` where `offline_mode = assigns[:offline_mode] || true` incorrectly defaulted `false` to `true`.
|
||||
- Added 7 tests in `test/bds/csm007_reload_shell_test.exs` using telemetry-based query counting: toggle_sidebar (0 queries), toggle_panel (0 queries), sync_layout (0 queries), select_panel_tab (0 queries), toggle_offline_mode (1 query — settings write only), select_view (sidebar queries but no dashboard/projects), sidebar_search (no dashboard queries).
|
||||
|
||||
---
|
||||
|
||||
### ~~CSM-008 — DB Queries During Render Path~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-09
|
||||
- **What was done:**
|
||||
- **Panel renderer** (`lib/bds/desktop/shell_live/panel_renderer.ex`):
|
||||
- `render_post_links` and `render_git_log` no longer call DB functions during render. Instead they read from pre-computed assigns (`panel_post_links`, `panel_git_entries`).
|
||||
- Renamed `post_link_entries/1` → `fetch_post_link_entries/1` and `git_log_entries/1` → `fetch_git_log_entries/1`, made them public for use by event handlers.
|
||||
- **Shell LiveView** (`lib/bds/desktop/shell_live.ex`):
|
||||
- Added `refresh_panel_data/1` that fetches panel data (post links or git log) based on the active panel tab and stores results in assigns.
|
||||
- `refresh_layout/2` detects when `current_tab` or `panel.active_tab` changed and calls `refresh_panel_data/1` only when stale — no DB queries on re-renders.
|
||||
- Initialized `panel_post_links` and `panel_git_entries` assigns in mount.
|
||||
- **Tab meta** (`lib/bds/desktop/shell_live/tab_helpers.ex`):
|
||||
- `sync_tab_meta` now skips `derived_tab_meta` DB queries when existing meta already has both title and subtitle populated (`meta_complete?/1` guard).
|
||||
- Added 5 tests in `test/bds/csm008_render_path_test.exs`: post_links re-render (0 queries), git_log re-render (0 queries), output panel switch (0 queries), tasks panel switch (0 queries), tab meta skip for complete meta (0 queries).
|
||||
|
||||
---
|
||||
|
||||
### ~~CSM-009 — Thumbnail Generation: Missing Error Handling~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-09
|
||||
- **What was done:**
|
||||
- Replaced all bang variants with non-bang error-tuple handling:
|
||||
- `Image.autorotate!` → `Image.autorotate` with `{:ok, {image, rotation_info}}` destructuring.
|
||||
- `Image.thumbnail!` → `Image.thumbnail` returning `{:ok, image}` / `{:error, reason}`.
|
||||
- `Image.embed!` → `Image.embed` with `with` chain.
|
||||
- `Image.flatten!` → `Image.flatten` with `with` chain.
|
||||
- `Image.write!` → `Image.write` with `{:ok, _}` / `{:error, reason}` handling.
|
||||
- `File.mkdir_p` result is now checked — errors halt thumbnail generation with `{:error, reason}`.
|
||||
- `write_all_thumbnails` uses `Enum.reduce_while` to stop on first error and return `{:error, reason}`.
|
||||
- `ensure_thumbnails` spec updated to `:ok | {:error, term()}`.
|
||||
- `regenerate_thumbnails` propagates `{:error, reason}` from `ensure_thumbnails`.
|
||||
- `regenerate_missing_thumbnails` replaced `try/rescue` with `case` on the new error tuples.
|
||||
- Call sites in `BDS.Media` (`import_media`, `replace_media_binary`) use `log_thumbnail_error/2` — media operations succeed even if thumbnails fail, with a warning logged.
|
||||
- Added 6 tests in `test/bds/csm009_thumbnail_error_handling_test.exs`: corrupt image returns `{:error, _}`, non-image returns `:ok`, missing source returns `{:error, _}`, regenerate corrupt returns `{:error, _}`, regenerate_missing counts failures, import succeeds despite thumbnail failure.
|
||||
|
||||
---
|
||||
|
||||
### ~~CSM-010 — `rescue` for Control Flow in Data Layer~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-09
|
||||
- **What was done:**
|
||||
- Added `BDS.Repo.ready?/0` — a lightweight probe that queries `sqlite_master` (parameterized) to check if core tables exist, without raising exceptions.
|
||||
- Replaced all 4 `rescue` blocks in `ShellData` (`project_snapshot/0`, `dashboard/1`, `sidebar_view/3`, `git_badge_count/2`) with upfront `Repo.ready?()` checks.
|
||||
- All four functions now return `{:ok, result}` / `{:error, :not_ready}` tuples instead of silently returning defaults via rescue.
|
||||
- Updated callers in `ShellLive.refresh_content/2` and `ShellLive.refresh_sidebar/2` to pattern-match the new tuples and fall back to empty defaults only on `{:error, :not_ready}`.
|
||||
- Made `default_project_snapshot/0` public for use by callers handling the not-ready case.
|
||||
- Added 10 tests in `test/bds/csm010_rescue_control_flow_test.exs`: `Repo.ready?` returns true when DB is available, each of the 4 functions returns `{:ok, _}` when DB is ready and `{:error, :not_ready}` when the Repo is stopped.
|
||||
|
||||
---
|
||||
|
||||
## Medium Severity
|
||||
|
||||
### ~~CSM-011 — No URL State / Deep Linking~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-09
|
||||
- **What was done:**
|
||||
- `mount/3` now reads `?view=` and `?tab=<type>:<id>` query params and applies them to the initial workbench state, enabling deep linking on page load.
|
||||
- Added `push_url_state/1` — after state-changing events (`select_view`, `select_tab`, `close_tab`, `open_sidebar_item`, sidebar menu actions, project switch), pushes a `url-state` event to the client with the serialized URL.
|
||||
- Added JS handler in the `AppShell` hook that calls `history.replaceState` to update the browser URL without triggering navigation.
|
||||
- URL encoding: `?view=<sidebar_view>` (omitted when `posts`, the default) and `?tab=<type>:<id>` (omitted when no tab is active). Invalid or unknown params are silently ignored.
|
||||
- Used `push_event` + `history.replaceState` instead of `push_patch`/`handle_params` to maintain compatibility with existing `live_isolated` tests.
|
||||
- Added 10 tests in `test/bds/csm011_url_state_test.exs`: mount with `?view=media`, mount with default, mount with invalid view, mount with `?tab=post:<id>`, mount with both params, `select_view` pushes url-state, `select_view` posts pushes clean URL, `select_tab` pushes url-state, `close_tab` removes tab from URL, `open_sidebar_item` pushes url-state.
|
||||
|
||||
---
|
||||
|
||||
### ~~CSM-012 — Desktop File Dialog Blocks Event Handler~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-09
|
||||
- **What was done:**
|
||||
- Replaced synchronous `FilePicker.choose_file/1` call in `SidebarCreate.create/4` for the "media" kind with `Task.async`, storing the task ref in a new `file_picker_task` socket assign.
|
||||
- Added `handle_file_picker_result/2` private function in `ShellLive` with clauses for `{:ok, _media}`, `:cancel`, `{:error, %{message: _}}`, and `{:error, reason}`.
|
||||
- Extended the existing `handle_info({ref, result}, socket)` and `handle_info({:DOWN, ref, ...}, socket)` handlers to match on `file_picker_task` ref.
|
||||
- Added `BDS_DESKTOP_AUTOMATION` guard to `FilePicker.choose_file/1` — returns `:cancel` immediately in automation/test mode, preventing native dialogs from opening during tests.
|
||||
- Initialized `file_picker_task: nil` assign in mount.
|
||||
- Added 5 tests in `test/bds/csm012_file_picker_async_test.exs`: event handler returns within 100ms, LiveView handles other events while task is pending, task completion doesn't crash LiveView, cancel is handled gracefully, error results don't crash LiveView.
|
||||
|
||||
---
|
||||
|
||||
### ~~CSM-013 — Bang Functions in Rendering Pipelines~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-09
|
||||
- **What was done:**
|
||||
- **`lib/bds/rendering/filters.ex`** — `render_macro_template`:
|
||||
- Replaced `Liquex.parse!` with `Liquex.parse` (non-bang) and `case` match on `{:ok, ast}` / `{:error, reason, line}`.
|
||||
- Wrapped `Liquex.render!` in `try/rescue` catching `Liquex.Error` specifically (no non-bang `render` exists in Liquex).
|
||||
- Removed broad `rescue _error -> ""` — errors now log via `Logger.warning` with template path and reason before returning `""`.
|
||||
- **`lib/bds/rendering/template_selection.ex`** — `render_template`:
|
||||
- `Liquex.parse` was already non-bang; added `else` clause to normalize the 3-tuple `{:error, reason, line}` into `{:error, "reason at line N"}`.
|
||||
- Wrapped `Liquex.render!` in `try/rescue` catching `Liquex.Error` specifically, returning `{:error, message}`.
|
||||
- Removed broad `rescue error -> {:error, error}`.
|
||||
- **`lib/bds/rendering/post_rendering.ex`** — `post_data_json_value`:
|
||||
- Replaced `Jason.encode!` with `Jason.encode` and `case` match — returns `"{}"` on encode failure instead of crashing.
|
||||
- Added 5 tests in `test/bds/csm013_bang_rendering_test.exs`: template syntax error returns `{:error, _}` from `render_template`, broken template in `render_post_page` returns `{:error, _}`, `{% break %}` render error returns `{:error, _}`, normal post context produces valid JSON, non-encodable data returns `"{}"` fallback.
|
||||
|
||||
---
|
||||
|
||||
### ~~CSM-014 — O(n²) Loops from `length/1` Inside Iteration~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-09
|
||||
- **What was done:**
|
||||
- **`lib/bds/generation/outputs.ex`** — `build_category_outputs`:
|
||||
- Bound `total_pages = length(paginated_posts)` and `total_items = length(posts)` before the nested loop. Previously called `length/1` 4 times per page × language iteration.
|
||||
- **`lib/bds/generation/outputs.ex`** — `build_root_outputs`:
|
||||
- Bound `total_items = length(posts)` before the loop, reused by `pagination_for_page`. Previously called `length(posts)` on every page iteration.
|
||||
- **`lib/bds/generation/outputs.ex`** — `build_paginated_archive_outputs`:
|
||||
- Bound `total_items = length(posts)` before the loop. Previously called `length(posts)` inside the nested page × language loop.
|
||||
- **`lib/bds/rendering/list_archive.ex`** — `build_day_blocks`:
|
||||
- Bound `last_index = length(grouped_blocks) - 1` before the `Enum.map`. Previously called `length(grouped_blocks)` on every iteration.
|
||||
- **`lib/bds/publishing.ex`** — `run_upload`:
|
||||
- Bound `target_count = max(length(targets), 1)` before the `Enum.reduce_while`. Negligible impact (3 targets) but fixed for consistency.
|
||||
- `lib/bds/ui/sidebar.ex` `acc.draft ++ [post]` was already fixed by CSM-005 (replaced with `Enum.group_by`).
|
||||
- Added 3 tests in `test/bds/csm014_length_in_loop_test.exs`: multi-page pagination correctness, single-page pagination correctness, 1000-post linear time completion.
|
||||
|
||||
---
|
||||
|
||||
### ~~CSM-015 — Missing DB Indexes on Foreign Keys~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-09
|
||||
- **What was done:**
|
||||
- Added migration `20260509145208_add_missing_indexes.exs` with indexes for all missing foreign keys and frequently filtered columns:
|
||||
- FK indexes: `media.project_id`, `post_media.post_id`, `post_media.media_id`, `chat_messages.conversation_id`, `embedding_keys.post_id`, `embedding_keys.project_id`, `dismissed_duplicate_pairs.project_id`, `import_definitions.project_id`, `publish_jobs.project_id`.
|
||||
- Filter indexes: `posts.status`, `posts.published_at`, `posts.language`.
|
||||
- Composite index: `db_notifications(entity_type, entity_id)`.
|
||||
- Added 12 tests in `test/bds/csm015_missing_indexes_test.exs` verifying via `EXPLAIN QUERY PLAN` that all indexed columns use index lookups.
|
||||
|
||||
---
|
||||
|
||||
### ~~CSM-016 — String Concatenation for Paths~~ ✅ FIXED
|
||||
- **Fixed:** 2026-05-09
|
||||
- **What was done:**
|
||||
- **`lib/bds/rendering/file_system.ex`** — Extracted `ensure_liquid_ext/1` using `Path.extname/1` to check before appending `.liquid`, preventing double-extension bugs (e.g. `"header.liquid.liquid"`).
|
||||
- **`lib/bds/rendering/metadata.ex`** — `menu_item_href` for `:page` kind now applies `URI.encode/1` to the slug (matching the existing `:category_archive` pattern). `href_for_language/1` now uses `String.trim_trailing(prefix, "/")` before appending `/` to prevent double trailing slashes.
|
||||
- **`lib/bds/rendering/metadata.ex`** — Added `menu_items_from_raw/1` public function for testability.
|
||||
- **`lib/bds/rendering/links_and_languages.ex`** — `post_path/2` for `nil` language now uses `Path.join(["/", year, month, day, slug]) <> "/"` instead of building with `index.html` then stripping it. Language-prefix clause uses `String.trim_trailing/2` to prevent double slashes. `canonical_media_path_by_source_path/1` uses `Path.join("/", media.file_path)` instead of `"/" <> file_path`.
|
||||
- **`lib/bds/publishing.ex`** — `ensure_trailing_slash/1` made public for testability (implementation already correct).
|
||||
- Added 17 tests in `test/bds/csm016_path_concatenation_test.exs`: FileSystem extension handling (bare name, double extension, nested paths), `href_for_language` (empty, with/without trailing slash), menu item href encoding (special chars, plain slugs, category slugs), post_path construction (leading/trailing slashes, no double slashes, language prefix), `language_prefix` (same/nil/different language), `ensure_trailing_slash` (without/with trailing slash, empty string).
|
||||
|
||||
---
|
||||
|
||||
### CSM-017 — `send(self(), ...)` Component Chatter
|
||||
- **Files:** 25+ call sites across editor components:
|
||||
- `lib/bds/desktop/shell_live/script_editor.ex` (3 sends)
|
||||
- `lib/bds/desktop/shell_live/post_editor.ex` (2 sends)
|
||||
- `lib/bds/desktop/shell_live/template_editor.ex` (3 sends)
|
||||
- `lib/bds/desktop/shell_live/media_editor.ex` (2 sends)
|
||||
- `lib/bds/desktop/shell_live/chat_editor.ex` (1 send)
|
||||
- `lib/bds/desktop/shell_live/menu_editor.ex` (1 send)
|
||||
- `lib/bds/desktop/shell_live/settings_editor.ex` (2 sends)
|
||||
- `lib/bds/desktop/shell_live/misc_editor.ex` (4 sends)
|
||||
- `lib/bds/desktop/shell_live/tags_editor.ex` (2 sends)
|
||||
- `lib/bds/desktop/shell_live/import_editor.ex` (1 send)
|
||||
- `lib/bds/desktop/shell_live/overlay_manager.ex` (3 sends)
|
||||
- `lib/bds/desktop/main_window.ex` (1 send)
|
||||
- **What:** Components send messages to the parent via `send(self(), ...)`, forcing a broad `handle_info` in `ShellLive`. Each message type must be handled in the parent, creating tight coupling.
|
||||
- **Fix:** Prefer `Phoenix.LiveView.send_update/2` for targeted component updates, or delegate through a single dispatch module that translates actions into specific state changes.
|
||||
- **Test:** Refactor one component; assert it no longer uses `send(self(), ...)`.
|
||||
|
||||
---
|
||||
|
||||
## Low Severity / Code Quality
|
||||
|
||||
### CSM-018 — `@moduledoc false` Epidemic
|
||||
- **Files:** `lib/bds/i18n.ex`, `lib/bds/map_utils.ex`, `lib/bds/bounded_atoms.ex`, `lib/bds/document_fields.ex`, `lib/bds/import_definitions.ex`, `lib/bds/publishing.ex`, `lib/bds/settings.ex`, `lib/bds/templates.ex`, `lib/bds/ai.ex`, `lib/bds/mcp.ex`, `lib/bds/scripting/capabilities.ex`, `lib/bds/scripting/api_docs.ex`
|
||||
- **Fix:** Write `@moduledoc` descriptions for all public modules. Keep internal helpers documented or mark them `@moduledoc false` only if truly private.
|
||||
|
||||
---
|
||||
|
||||
### CSM-019 — Missing `@spec` on Public Functions
|
||||
- **Files:** Widespread across rendering, generation, publishing, UI, and scripting modules.
|
||||
- **Fix:** Add `@spec` to every public function. This is a Dialyzer prerequisite (the project already runs Dialyzer; the report notes it should be clean).
|
||||
|
||||
---
|
||||
|
||||
### CSM-020 — Deeply Nested `case` Instead of `with`
|
||||
- **Files:** `lib/bds/import_definitions.ex:54-66`, `lib/bds/publishing.ex:47-58`, `lib/bds/templates.ex:86-163`
|
||||
- **Fix:** Flatten with `with`:
|
||||
```elixir
|
||||
with {:ok, record} <- Repo.get(Model, id),
|
||||
{:ok, updated} <- Repo.update(changeset) do
|
||||
{:ok, updated}
|
||||
else
|
||||
nil -> {:error, :not_found}
|
||||
{:error, changeset} -> {:error, changeset}
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CSM-021 — `cond` Where Pattern Matching Suffices
|
||||
- **Files:** `lib/bds/ai.ex:62-70`, `lib/bds/scripting/api_docs.ex:1345-1398`, `lib/bds/scripting/api_docs.ex:1433-1447`
|
||||
- **Fix:** Replace `cond do x == nil -> ...; true -> ... end` with multiple function-head clauses.
|
||||
|
||||
---
|
||||
|
||||
### CSM-022 — Silent Error Swallowing
|
||||
- **File:** `lib/bds/scripting.ex:64-66`
|
||||
- **What:** `execute_macro/4` returns `{:ok, ""}` on `{:error, _reason}` with no logging. The caller cannot distinguish success from failure.
|
||||
- **Fix:** Return the actual error tuple or at least log the failure with `Logger.error/1`.
|
||||
|
||||
---
|
||||
|
||||
### CSM-023 — SRP Violations
|
||||
- **Files:**
|
||||
- `lib/bds/templates.ex:86-163` — `update_template/2` does slug changes, content changes, status transitions, file paths, transactions, cascades, and filesystem sync.
|
||||
- `lib/bds/scripting/capabilities.ex:22-248` — `for_project/2` returns a 200+ line map literal.
|
||||
- **Fix:** Decompose into smaller private pipelines or domain-specific builder functions.
|
||||
|
||||
---
|
||||
|
||||
### CSM-024 — `Enum.reduce` with `acc.draft ++ [post]` (O(n²))
|
||||
- **File:** `lib/bds/ui/sidebar.ex:556-565`
|
||||
- **Fix:** Use `Enum.group_by/3` or reverse-accumulate and `Enum.reverse`.
|
||||
|
||||
---
|
||||
|
||||
### CSM-025 — Hardcoded Language Prefixes
|
||||
- **File:** `lib/bds/generation/pagefind.ex:48-54`
|
||||
- **What:** `["de/", "fr/", "it/", "es/"]` hardcoded instead of derived from project settings.
|
||||
- **Fix:** Derive from project settings (`mainLanguage` and supported languages).
|
||||
|
||||
---
|
||||
|
||||
### CSM-026 — TOCTOU Race Condition in Template File System
|
||||
- **File:** `lib/bds/rendering/file_system.ex:28-37`
|
||||
- **What:** `Enum.find(&File.regular?/1)` checks existence, then the file is read later (in the `Liquex.FileSystem` impl, Z. 43-49). Between check and read the file can vanish.
|
||||
- **Fix:** Just try to read and handle `{:error, :enoent}`. Remove the `Enum.find` existence check and attempt reads directly.
|
||||
|
||||
---
|
||||
|
||||
### CSM-027 — `if result == :ok` Instead of Pattern Matching
|
||||
- **File:** `lib/bds/templates.ex:445`
|
||||
- **Fix:** Use `case result do :ok -> ...; _ -> ... end`.
|
||||
|
||||
---
|
||||
|
||||
### CSM-028 — Broad `rescue` Swallowing Template Errors
|
||||
- **File:** `lib/bds/rendering/filters.ex:130-132`
|
||||
- **What:** `rescue _error -> ""` swallows all macro template failures silently.
|
||||
- **Fix:** Rescue only specific exceptions, or return `{:error, exception}` and let the caller decide.
|
||||
|
||||
---
|
||||
|
||||
### CSM-029 — `length/1` in Guards or Comparisons
|
||||
- **Files:** `lib/bds/generation/outputs.ex`, `lib/bds/ui/sidebar.ex`
|
||||
- **What:** `length(list)` is O(n). Using it inside a loop makes the whole loop O(n²).
|
||||
- **Fix:** Bind the length before the loop.
|
||||
|
||||
---
|
||||
|
||||
### CSM-030 — Unchecked `File.mkdir_p` / `File.mkdir_p!`
|
||||
- **Files:** `lib/bds/media/thumbnails.ex:133`, `lib/bds/media/sidecars.ex:24,56`, `lib/bds/release_packaging.ex:80,85`
|
||||
- **What:** Result of `File.mkdir_p/1` is discarded. `File.mkdir_p!/1` in `release_packaging` can crash on permission errors.
|
||||
- **Fix:** Pattern-match `File.mkdir_p/1` or use `with`; replace bang variants with non-bang and handle errors.
|
||||
|
||||
---
|
||||
|
||||
### CSM-031 — `try/rescue` Instead of `with` and Error Tuples
|
||||
- **Files:** `lib/bds/rendering/filters.ex`, `lib/bds/rendering/template_selection.ex`, `lib/bds/desktop/shell_data.ex`
|
||||
- **Fix:** Replace `try/rescue` around expected failures with non-bang functions and `with` chains.
|
||||
|
||||
---
|
||||
|
||||
### CSM-032 — `Map.get` with Default Instead of Pattern Matching
|
||||
- **Files:** Widespread
|
||||
- **What:** `Map.get(map, key, default)` when the key is expected to exist.
|
||||
- **Fix:** Use pattern matching (`%{key: value} = map`) or `Map.fetch!/2` if the key is required.
|
||||
|
||||
---
|
||||
|
||||
### CSM-033 — `Enum.each` with Side Effects That Should Be Batch Inserts
|
||||
- **Files:** `lib/bds/search.ex:174-177`, `lib/bds/embeddings.ex`
|
||||
- **What:** `Enum.each` used for inserting records. The side-effect pattern is fine, but `Enum.map` + `Repo.insert_all` would be much faster for bulk inserts.
|
||||
- **Fix:** Use `Repo.insert_all` for batch inserts instead of `Enum.each` + `Repo.insert`.
|
||||
|
||||
---
|
||||
|
||||
### CSM-034 — `File.read!` / `File.write!` Without Error Handling
|
||||
- **Files:** `lib/bds/preview_assets.ex:32`, `lib/bds/release_packaging.ex:105`, `lib/bds/templates.ex:488-489`
|
||||
- **Fix:** Use `File.read/1`, `File.write/2`, and handle `{:error, reason}`.
|
||||
|
||||
---
|
||||
|
||||
### CSM-035 — Process Dictionary (`Process.get/put`) Usage
|
||||
- **File:** `lib/bds/desktop/ui_locale.ex:32,49,65`
|
||||
- **What:** `UILocale.put/1` sets process dictionary (`Process.put(@key, locale)`) for UI locale. Used in `ShellLive.render` (Z. 550) and `MenuBar`.
|
||||
- **Fix:** This is isolated to the LiveView/MenuBar process so it's low-risk, but document the invariant explicitly: the process dict key `:bds_ui_locale` is set before each render call.
|
||||
|
||||
---
|
||||
|
||||
### CSM-036 — Missing `@impl true` on GenServer Callbacks
|
||||
- **File:** `lib/bds/publishing.ex:46,61,71,75`
|
||||
- **What:** Only `init/1` (Z. 36) and the first `handle_call` (Z. 41) have `@impl true`. The remaining `handle_call` clauses at Z. 46, 61, 71, 75 lack it.
|
||||
- **Fix:** Add `@impl true` before every `handle_call`, `handle_cast`, `handle_info`, and `terminate`.
|
||||
|
||||
---
|
||||
|
||||
## Checklist for Agents Picking Up This File
|
||||
|
||||
- [x] All critical items (CSM-001 to CSM-005) have been addressed or explicitly deferred with justification.
|
||||
- CSM-001: Fixed. All `String.to_atom` on dynamic data replaced with `MapUtils.safe_atomize_key/keys` or `String.to_existing_atom`.
|
||||
- CSM-002: Fixed. Search now pushes all filtering and pagination into SQL via Ecto queries and CTEs.
|
||||
- CSM-004: Fixed. `attach_runner` moved to `handle_continue`, `terminate/2` added for cleanup, `restart: :temporary` set, JobStore `detach_runner` bug fixed.
|
||||
- [x] All high-severity items (CSM-006 to CSM-010) have been addressed.
|
||||
- CSM-006: Fixed. Batch INSERT for reindexing, preloaded post records for rendering.
|
||||
- CSM-007: Fixed. Decomposed into refresh_layout, refresh_sidebar, refresh_content, reload_shell.
|
||||
- CSM-008: Fixed. Panel data pre-computed in event handlers, tab meta skips DB for complete entries.
|
||||
- CSM-009: Fixed. All bang Image/File variants replaced with error-tuple handling, `ensure_thumbnails` returns `{:error, _}` instead of crashing.
|
||||
- CSM-010: Fixed. Replaced rescue blocks with `Repo.ready?/0` probe and `{:ok, _}`/`{:error, :not_ready}` tuples.
|
||||
- [x] CSM-001 fix covers ALL 6 affected files, not just `import_definitions.ex`.
|
||||
- [x] CSM-003 fix covers ALL `Repo.delete!` call sites (posts, tags, scripts, media, projects, templates, translations).
|
||||
- [x] CSM-007 decomposition is the prerequisite for fixing CSM-008 (render-path queries).
|
||||
- [x] Tests were written **before** implementation changes (Red → Green → Refactor).
|
||||
- [x] Full test suite passes: `mix test`.
|
||||
- [x] Dialyzer passes cleanly: `mix dialyzer` (zero warnings).
|
||||
- [x] Build succeeds: `mix compile`.
|
||||
- [x] No external JS/CSS referenced in preview/generated HTML (per AGENTS.md).
|
||||
- [x] All UI strings use gettext / i18n, no hardcoded text.
|
||||
- [x] API docs (`API.md`) updated if any API changes were made.
|
||||
- [x] Metadata diff tool and rebuild-from-database updated if metadata changed.
|
||||
- [x] Specs in `specs/` folder updated and validated if behavior changed.
|
||||
- [x] Unused code (including tests for removed features) has been deleted.
|
||||
- [x] This `CODESMELL.md` updated: fixed items removed, new ones added.
|
||||
53
README.md
53
README.md
@@ -162,3 +162,56 @@ Notes for developers:
|
||||
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md) is for end users, not implementation details.
|
||||
- [API.md](/Users/gb/Projects/bDS2/API.md) is generated from the live scripting capability map and should stay in sync with runtime changes.
|
||||
- When changing persistence or localization behavior, check both the database side and the filesystem/render side before assuming the change is complete.
|
||||
|
||||
## Packaging The macOS App
|
||||
|
||||
`mix bds.bundle.macos` produces a self-contained, ad-hoc-signed `BDS2.app`. It
|
||||
bundles the `:bds` release (ERTS included) plus the wxWidgets dylibs relocated
|
||||
via `@loader_path`, so it runs on a clean Mac with nothing installed. The bundle
|
||||
registers the `bds2://` URL scheme (for blogmark deep links) and ships the red-pen
|
||||
`AppIcon`. Output: `dist/macos/BDS2.app`. (arm64 / Apple Silicon only.)
|
||||
|
||||
### 1. Build the release, then the bundle
|
||||
|
||||
```bash
|
||||
MIX_ENV=prod mix release bds --overwrite
|
||||
mix bds.bundle.macos --app-release _build/prod/rel/bds
|
||||
```
|
||||
|
||||
The icon `.icns` is generated on first run from
|
||||
[priv/desktop/macos/icon-source.svg](/Users/gb/Projects/bDS2/priv/desktop/macos/icon-source.svg)
|
||||
and committed under `priv/desktop/macos/`. Pass `--regen-icon` to rebuild it.
|
||||
|
||||
### 2. Register the `bds2://` scheme (optional)
|
||||
|
||||
`--register` tells LaunchServices about the scheme without moving the app to
|
||||
`/Applications` — useful for testing blogmark deep links locally:
|
||||
|
||||
```bash
|
||||
mix bds.bundle.macos --app-release _build/prod/rel/bds --register
|
||||
```
|
||||
|
||||
### Run / smoke-test
|
||||
|
||||
```bash
|
||||
open dist/macos/BDS2.app
|
||||
open "bds2://new-post?title=Hello&url=https://example.com" # opens a draft
|
||||
```
|
||||
|
||||
### Useful flags
|
||||
|
||||
- `--app-release PATH` — release dir to wrap (default `_build/<env>/rel/bds`).
|
||||
- `--output DIR` — output directory (default `dist/macos`).
|
||||
- `--version V` — version string for `Info.plist` (default from `mix.exs`).
|
||||
- `--regen-icon` — regenerate `AppIcon.icns` from the source SVG.
|
||||
- `--register` — register the `bds2://` scheme via LaunchServices.
|
||||
- `--no-codesign` — skip the ad-hoc codesign step.
|
||||
|
||||
### Verifying the bundle
|
||||
|
||||
```bash
|
||||
plutil -lint dist/macos/BDS2.app/Contents/Info.plist
|
||||
codesign --verify --deep --strict dist/macos/BDS2.app
|
||||
# no /opt/homebrew or /usr/local references should appear:
|
||||
otool -L dist/macos/BDS2.app/Contents/Resources/rel/lib/wx-*/priv/wxe_driver.so
|
||||
```
|
||||
|
||||
191
SPECAUDIT.md
Normal file
191
SPECAUDIT.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Spec Audit Process
|
||||
|
||||
This document describes the repeatable process for auditing the Allium specifications against the bDS2 codebase and test suite. Run it whenever specs or code change materially.
|
||||
|
||||
## Overview
|
||||
|
||||
The audit produces three categories of findings:
|
||||
|
||||
1. **Spec-claims-not-in-code** — spec describes behavior the code does not implement
|
||||
2. **Code-not-in-spec** — code implements behavior the spec does not describe
|
||||
3. **Spec-claims-not-in-tests** — spec invariants/rules/behaviors lack test coverage
|
||||
|
||||
## Step 1: Map the Territory
|
||||
|
||||
```bash
|
||||
# List all spec files
|
||||
ls specs/*.allium
|
||||
|
||||
# List all source modules
|
||||
ls lib/bds/ lib/bds/**/
|
||||
|
||||
# List all test files
|
||||
ls test/bds/ test/bds/**/
|
||||
```
|
||||
|
||||
Record the mapping between specs and code/test files. Use `specs/bds.allium` as the index — it lists every `use` directive with its domain label.
|
||||
|
||||
## Step 2: Extract Spec Claims
|
||||
|
||||
For each `.allium` file, extract:
|
||||
|
||||
| Claim Type | Pattern | Example |
|
||||
|---|---|---|
|
||||
| **invariant** | `invariant Name:` or lines describing always-true properties | `UniqueSlugPerProject: slugs unique within project` |
|
||||
| **rule** | `rule Name { requires: ... ensures: ... }` | `CreatePost: creates with slug, status=draft` |
|
||||
| **guarantee** | `guarantee Name:` | `SandboxedExecution: no filesystem/process loading` |
|
||||
| **config** | `config { key = value }` | `macro_timeout = 10.seconds` |
|
||||
| **behavior** | Explicit claims in comments or entity descriptions | `"HomeAlwaysPresent: menu always has Home entry"` |
|
||||
|
||||
Record the spec file name, claim name, claim type, and line number for each.
|
||||
|
||||
## Step 3: Compare Spec Claims Against Code
|
||||
|
||||
For each claim, find the corresponding code and verify:
|
||||
|
||||
### 3a. Entity/field existence
|
||||
- Does the Ecto schema have the fields the spec declares?
|
||||
- Are relationships (has_many, belongs_to) present?
|
||||
- Are enum/status values complete?
|
||||
|
||||
```bash
|
||||
# Check schema fields
|
||||
grep -n "field :" lib/bds/posts/post.ex
|
||||
grep -n "has_many\|belongs_to" lib/bds/posts/post.ex
|
||||
```
|
||||
|
||||
### 3b. Rule implementation
|
||||
- Does the code enforce the `requires` preconditions?
|
||||
- Does the code produce the `ensures` postconditions?
|
||||
- Are side-effects (FTS, embeddings, file writes) triggered?
|
||||
|
||||
```bash
|
||||
# Check function implementation
|
||||
grep -n "def create_post" lib/bds/posts.ex
|
||||
grep -n "def publish_post" lib/bds/posts.ex
|
||||
```
|
||||
|
||||
### 3c. Invariant enforcement
|
||||
- Are constraints enforced at the schema level (unique_index, check_constraint)?
|
||||
- Are constraints enforced in changeset validations?
|
||||
- Are constraints enforced in business logic?
|
||||
|
||||
```bash
|
||||
# Check database constraints
|
||||
grep -n "unique_index\|check_constraint" priv/repo/migrations/*.ex
|
||||
grep -n "unique_constraint\|validate_" lib/bds/posts/post.ex
|
||||
```
|
||||
|
||||
### 3d. File format compliance
|
||||
- Does the serialization format match the spec's frontmatter values?
|
||||
- Are conditional fields omitted when falsy?
|
||||
- Are required fields always present?
|
||||
|
||||
```bash
|
||||
# Check serialization
|
||||
grep -n "serialize\|write_file\|Frontmatter" lib/bds/frontmatter.ex lib/bds/posts/file_sync.ex
|
||||
```
|
||||
|
||||
## Step 4: Compare Code Against Spec Claims
|
||||
|
||||
Search for code that implements behavior NOT described in any spec:
|
||||
|
||||
### 4a. Public API functions not in any spec rule
|
||||
```bash
|
||||
# List public functions in a module
|
||||
grep -n "def " lib/bds/posts.ex | grep -v "defp"
|
||||
```
|
||||
|
||||
### 4b. Schema fields not in any spec entity
|
||||
```bash
|
||||
# List all fields
|
||||
grep -n "field :" lib/bds/posts/post.ex
|
||||
```
|
||||
|
||||
### 4c. Side effects not in engine_side_effects.allium
|
||||
```bash
|
||||
# Check what happens after CRUD operations
|
||||
grep -n "sync_post\|sync_media\|Search\.\|Embeddings\.\|AutoTranslation" lib/bds/posts.ex lib/bds/media.ex
|
||||
```
|
||||
|
||||
### 4d. UI features not in any editor spec
|
||||
```bash
|
||||
# Check HEEx templates for UI elements
|
||||
grep -n "phx-click\|data-phx-" lib/bds/desktop/post_editor_html/post_editor.html.heex
|
||||
```
|
||||
|
||||
## Step 5: Compare Spec Claims Against Tests
|
||||
|
||||
For each invariant, rule, and guarantee, search for a test that verifies it:
|
||||
|
||||
### 5a. Direct test search
|
||||
```bash
|
||||
# Search test names and bodies
|
||||
grep -rn "test \"" test/bds/posts_test.exs | head -30
|
||||
grep -rn "test \"" test/bds/media_test.exs | head -30
|
||||
```
|
||||
|
||||
### 5b. Invariant coverage check
|
||||
For each invariant, determine:
|
||||
- **YES**: Test explicitly verifies the invariant (creates violation, expects rejection)
|
||||
- **PARTIAL**: Test verifies the happy path but not violation scenarios
|
||||
- **NO**: No test exists
|
||||
|
||||
### 5c. Rule coverage check
|
||||
For each rule, determine:
|
||||
- **YES**: Test exercises `requires` precondition and `ensures` postcondition
|
||||
- **PARTIAL**: Test exercises the happy path but not preconditions or all postconditions
|
||||
- **NO**: No test exists
|
||||
|
||||
### 5d. Side-effect chain coverage
|
||||
For each side-effect rule in `engine_side_effects.allium`, check whether a test verifies ALL `ensures` clauses fire together (not just individually).
|
||||
|
||||
## Step 6: Classify Findings
|
||||
|
||||
Each gap falls into one of these categories with a recommended action:
|
||||
|
||||
| Category | Direction | Action |
|
||||
|---|---|---|
|
||||
| **Spec correct, code wrong** | Spec → Code | Fix the code |
|
||||
| **Code correct, spec drifted** | Code → Spec | Update the spec |
|
||||
| **Code behavior, no spec** | Code → Spec | Distill into spec |
|
||||
| **Spec claim, no test** | Spec → Test | Write test |
|
||||
| **Internal spec inconsistency** | Spec → Spec | Align specs |
|
||||
| **Decision needed** | Both | Resolve with stakeholder |
|
||||
|
||||
## Step 7: Produce SPECGAPS.md
|
||||
|
||||
Consolidate all findings into `SPECGAPS.md` with:
|
||||
- Gap ID for tracking
|
||||
- Clear description of the gap
|
||||
- Which spec file and line
|
||||
- Which code file and line
|
||||
- Recommended path (fix code / update spec / write test / decide)
|
||||
- Priority (HIGH/MEDIUM/LOW)
|
||||
|
||||
## Step 8: Validate
|
||||
|
||||
After making changes:
|
||||
```bash
|
||||
# Run full test suite
|
||||
mix test
|
||||
|
||||
# Run dialyzer
|
||||
mix dialyzer
|
||||
|
||||
# Validate allium specs (if tool available)
|
||||
# Use the allium CLI to validate spec files
|
||||
```
|
||||
|
||||
## Re-running the Audit
|
||||
|
||||
1. Start from Step 2 — re-extract claims from updated specs
|
||||
2. Run Steps 3-5 against current code and tests
|
||||
3. Compare against previous SPECGAPS.md to identify resolved and new gaps
|
||||
4. Update SPECGAPS.md
|
||||
|
||||
The audit should be re-run after:
|
||||
- Adding new spec files or significant spec changes
|
||||
- Adding new features or refactoring code
|
||||
- Adding new test files
|
||||
- Before any release milestone
|
||||
87
TESTAUDIT.md
Normal file
87
TESTAUDIT.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Test Audit Procedure
|
||||
|
||||
Periodic review of the unit test suite to ensure every test exercises production
|
||||
code against real assumptions and behavior.
|
||||
|
||||
## Scope
|
||||
|
||||
All `*_test.exs` files under `test/`.
|
||||
|
||||
## What counts as a valid unit test
|
||||
|
||||
A valid unit test **calls at least one production function** from `lib/bds/` and
|
||||
**asserts on its return value, side effects, or observable behavior**.
|
||||
|
||||
Acceptable patterns:
|
||||
|
||||
- Calling a production function and asserting its return value.
|
||||
- Calling a production function with injected test doubles (fake HTTP clients,
|
||||
fake runtimes) and asserting the production code's orchestration logic.
|
||||
- Mounting a LiveView or rendering a LiveComponent and asserting HTML output
|
||||
or database state after interactions.
|
||||
- Sending events to a GenServer and asserting state transitions.
|
||||
|
||||
### Source-property tests (acceptable, not flagged)
|
||||
|
||||
Tests that verify structural properties of source code are acceptable and should
|
||||
not be flagged during this audit. Examples:
|
||||
|
||||
- Checking that all public functions have `@spec` annotations (AST parsing).
|
||||
- Asserting absence of `String.to_atom` or `cond do` in specific files.
|
||||
- Verifying CSS/JS/template assets contain expected class names or imports.
|
||||
- Checking that `API.md` matches the output of a documentation generator.
|
||||
- Verifying database indexes exist via `EXPLAIN QUERY PLAN`.
|
||||
- Asserting `.allium` spec files have consistent parameter signatures.
|
||||
- Checking config files for expected values.
|
||||
- Verifying function decomposition patterns in source.
|
||||
|
||||
These are linting/contract/consistency checks. They serve a purpose but are
|
||||
distinct from behavioral tests.
|
||||
|
||||
## What gets flagged
|
||||
|
||||
1. **Export-existence-only tests** — tests that call `function_exported?/3` or
|
||||
`Code.ensure_loaded?/1` without ever invoking the function. These verify
|
||||
compilation, not behavior. They are redundant when the same module is already
|
||||
tested via rendering or direct calls in another test file.
|
||||
|
||||
2. **Mock-only tests** — tests that define a fake/stub module and only assert
|
||||
on that fake's behavior without routing through any production code path.
|
||||
|
||||
3. **Trivially-passing tests** — tests whose assertions succeed regardless of
|
||||
whether the production code is correct (e.g., asserting on a hardcoded value
|
||||
that never touches production logic).
|
||||
|
||||
## How to run the audit
|
||||
|
||||
Ask Claude Code to:
|
||||
|
||||
> Analyse the unit tests of the project and check if all of them actually call
|
||||
> proper production code or if there are tests that essentially only test
|
||||
> scaffolds, mocks and helper functions. Every unit test must test proper
|
||||
> production code against assumptions and behaviour. Source-property tests
|
||||
> (structure, @spec, asset presence, schema verification, doc staleness) are
|
||||
> acceptable and should not be flagged.
|
||||
|
||||
The audit should:
|
||||
|
||||
1. Read every `*_test.exs` file under `test/` in full.
|
||||
2. For each test block, identify which production function (if any) is called.
|
||||
3. Flag any test that falls into the categories above.
|
||||
4. Report flagged tests with file path, line number, and explanation.
|
||||
|
||||
## Audit log
|
||||
|
||||
### 2026-05-11
|
||||
|
||||
Reviewed all 71 test files (69 after cleanup). Found 2 redundant files:
|
||||
|
||||
- `test/bds/desktop/shell_live/chat_editor_test.exs` — single test only called
|
||||
`function_exported?` for `ChatEditor`. The component was already fully tested
|
||||
via `render_component` in `shell_live_test.exs`. **Deleted.**
|
||||
|
||||
- `test/bds/desktop/shell_live/import_editor_test.exs` — single test only called
|
||||
`Code.ensure_loaded?` + `function_exported?` for `ImportEditor`. The component
|
||||
was already exercised in `import_shell_live_test.exs`. **Deleted.**
|
||||
|
||||
Result after cleanup: 646 tests, 0 failures, 4 skipped.
|
||||
@@ -7,6 +7,7 @@
|
||||
@import "./tokens.css";
|
||||
@import "./shell.css";
|
||||
@import "./sidebar.css";
|
||||
@import "./git_sidebar.css";
|
||||
@import "./tabs.css";
|
||||
@import "./editor.css";
|
||||
@import "./forms.css";
|
||||
@@ -16,4 +17,5 @@
|
||||
@import "./menu_editor.css";
|
||||
@import "./media_editor.css";
|
||||
@import "./import_editor.css";
|
||||
@import "./misc_editor.css";
|
||||
@import "./utilities.css";
|
||||
@@ -86,10 +86,11 @@
|
||||
.chat-message {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chat-message.user {
|
||||
justify-content: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.chat-message-content {
|
||||
@@ -102,10 +103,11 @@
|
||||
}
|
||||
|
||||
.chat-panel .chat-message.user .chat-message-content {
|
||||
background: transparent;
|
||||
color: var(--vscode-list-activeSelectionForeground);
|
||||
border: 0;
|
||||
padding: 6px 12px;
|
||||
background: var(--vscode-button-background, var(--accent-color, #007acc));
|
||||
color: var(--vscode-button-foreground, var(--vscode-list-activeSelectionForeground, #ffffff));
|
||||
border: 1px solid var(--vscode-button-background, var(--accent-color, #007acc));
|
||||
border-radius: 6px;
|
||||
padding: 12px 14px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@@ -129,19 +131,482 @@
|
||||
background: var(--vscode-textCodeBlock-background);
|
||||
}
|
||||
|
||||
/* ── Inline surfaces (<details> wrappers) ──────────────────────────── */
|
||||
|
||||
.chat-inline-surface {
|
||||
margin: 10px 0;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
background: var(--vscode-sideBar-background);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-inline-surface-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
list-style: none;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.chat-inline-surface-header::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-inline-surface-header::marker {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.chat-inline-surface-icon {
|
||||
flex: 0 0 auto;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.chat-inline-surface-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-weight: 500;
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.chat-inline-surface-dismiss {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.chat-inline-surface:hover .chat-inline-surface-dismiss {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.chat-inline-surface-dismiss:hover {
|
||||
background: var(--vscode-toolbar-hoverBackground);
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.chat-inline-surface-body {
|
||||
padding: 0 12px 12px;
|
||||
}
|
||||
|
||||
.chat-inline-surface-body h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
/* ── Chart surface ─────────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-chart-type {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.chat-surface-chart-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-surface-chart-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chat-surface-chart-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chat-surface-chart-meta span:first-child {
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.chat-surface-chart-meta span:last-child {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.chat-surface-chart-bar {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-surface-chart-bar span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--accent-color);
|
||||
min-width: 0;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Stacked bars: segments sit side by side inside the track. */
|
||||
.chat-surface-chart-bar-stacked {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chat-surface-chart-bar-segment {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Shared legend (pie/donut/stacked-bar). */
|
||||
.chat-surface-chart-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chat-surface-chart-legend-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.chat-surface-chart-legend-swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Pie / donut. */
|
||||
.chat-surface-chart-pie {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-surface-chart-pie-svg {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.chat-surface-chart-pie-slice {
|
||||
stroke: var(--vscode-editor-background, #1e1e1e);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.chat-surface-chart-donut-hole {
|
||||
fill: var(--vscode-editor-background, #1e1e1e);
|
||||
}
|
||||
|
||||
.chat-surface-chart-donut-total {
|
||||
fill: var(--vscode-editor-foreground);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Line / area. */
|
||||
.chat-surface-chart-line-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.chat-surface-chart-line-grid {
|
||||
stroke: rgba(255, 255, 255, 0.08);
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.chat-surface-chart-line-y-label,
|
||||
.chat-surface-chart-line-x-label {
|
||||
fill: var(--vscode-descriptionForeground);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.chat-surface-chart-line-path {
|
||||
stroke: var(--accent-color);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.chat-surface-chart-area-fill {
|
||||
fill: var(--accent-color);
|
||||
opacity: 0.18;
|
||||
}
|
||||
|
||||
.chat-surface-chart-line-dot {
|
||||
fill: var(--accent-color);
|
||||
}
|
||||
|
||||
/* Heatmap. */
|
||||
.chat-surface-chart-heatmap {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.chat-surface-chart-heatmap-corner {
|
||||
/* empty top-left cell */
|
||||
}
|
||||
|
||||
.chat-surface-chart-heatmap-col-label {
|
||||
text-align: center;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chat-surface-chart-heatmap-row-label {
|
||||
text-align: right;
|
||||
padding-right: 4px;
|
||||
opacity: 0.7;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 80px;
|
||||
}
|
||||
|
||||
.chat-surface-chart-heatmap-cell {
|
||||
aspect-ratio: 1;
|
||||
min-width: 14px;
|
||||
min-height: 14px;
|
||||
border-radius: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Card surface ──────────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chat-surface-subtitle {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.chat-surface-body {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chat-surface-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.chat-surface-action-button {
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-editor-foreground);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-surface-action-button:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
/* ── Metric surface ────────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chat-surface-metric-label {
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.chat-surface-metric-value {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
/* ── List surface ──────────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-list {
|
||||
margin: 0;
|
||||
padding: 0 0 0 18px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Mindmap surface ───────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-mindmap {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.chat-surface-mindmap li {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.chat-surface-mindmap li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.chat-surface-mindmap strong {
|
||||
display: block;
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.chat-surface-mindmap-children {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* ── Tabs surface ──────────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-tabs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.chat-surface-tab-list {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.chat-surface-tab-button {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-surface-tab-button.active {
|
||||
color: var(--vscode-editor-foreground);
|
||||
border-bottom-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.chat-surface-tab-button:hover:not(.active) {
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.chat-surface-tab-panel {
|
||||
padding: 10px 0 0;
|
||||
}
|
||||
|
||||
/* ── Form surface ──────────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.chat-surface-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.chat-surface-form-field input,
|
||||
.chat-surface-form-field textarea,
|
||||
.chat-surface-form-field select {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 4px;
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.chat-surface-form-field textarea {
|
||||
min-height: 60px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.chat-surface-form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* ── Text surface ──────────────────────────────────────────────────── */
|
||||
|
||||
.chat-surface-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* ── Table surface wrapper ─────────────────────────────────────────── */
|
||||
|
||||
.chat-tool-surface-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.chat-panel .chat-input-container {
|
||||
--chat-input-line-height: 20px;
|
||||
--chat-input-min-height: 20px;
|
||||
--chat-input-line-height: 22px;
|
||||
--chat-input-min-height: 24px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
padding: 8px 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--vscode-sideBar-background);
|
||||
}
|
||||
|
||||
.chat-panel .chat-input-wrapper {
|
||||
min-height: 30px;
|
||||
min-height: 40px;
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 6px;
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
background: var(--vscode-input-background);
|
||||
}
|
||||
|
||||
@@ -160,11 +625,16 @@
|
||||
max-height: 160px;
|
||||
resize: vertical;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--vscode-input-foreground);
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.chat-panel .chat-input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.chat-panel .chat-input::placeholder {
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
}
|
||||
@@ -221,3 +691,88 @@
|
||||
padding: 8px 12px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Colour picker popover */
|
||||
.colour-picker-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.colour-picker-trigger {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.colour-picker-trigger:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.colour-picker-popover {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
|
||||
border-radius: 6px;
|
||||
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
width: 196px;
|
||||
}
|
||||
|
||||
.colour-picker-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.colour-picker-swatch {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: border-color 0.1s;
|
||||
}
|
||||
|
||||
.colour-picker-swatch:hover {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.colour-picker-swatch.selected {
|
||||
border-color: var(--vscode-focusBorder);
|
||||
box-shadow: 0 0 0 1px var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.colour-picker-custom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.colour-picker-custom label {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.colour-picker-custom input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
border-radius: 3px;
|
||||
background: var(--vscode-input-background);
|
||||
color: var(--vscode-input-foreground);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
294
assets/css/git_sidebar.css
Normal file
294
assets/css/git_sidebar.css
Normal file
@@ -0,0 +1,294 @@
|
||||
/* ── Git sidebar ─────────────────────────────────────────────────────── */
|
||||
|
||||
.git-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Header ──────────────────────────────────────────────────────────── */
|
||||
|
||||
.git-header {
|
||||
padding: 8px 12px 12px;
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.git-branch {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-sideBar-foreground);
|
||||
}
|
||||
|
||||
.git-branch-icon {
|
||||
font-size: 14px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.git-upstream {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--vscode-badge-background);
|
||||
color: var(--vscode-badge-foreground);
|
||||
}
|
||||
|
||||
.git-ahead {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-testing-iconPassed);
|
||||
}
|
||||
|
||||
.git-behind {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-notificationsInfoIcon-foreground);
|
||||
}
|
||||
|
||||
/* ── Sync legend ─────────────────────────────────────────────────────── */
|
||||
|
||||
.git-legend-item {
|
||||
font-size: 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.git-sync-dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.git-sync-synced {
|
||||
background-color: var(--vscode-testing-iconPassed);
|
||||
}
|
||||
|
||||
.git-sync-local_only {
|
||||
background-color: var(--vscode-editorWarning-foreground);
|
||||
}
|
||||
|
||||
.git-sync-remote_only {
|
||||
background-color: var(--vscode-notificationsInfoIcon-foreground);
|
||||
}
|
||||
|
||||
/* ── Actions ─────────────────────────────────────────────────────────── */
|
||||
|
||||
.git-actions {
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.git-action-button {
|
||||
flex: 1;
|
||||
padding: 4px 8px;
|
||||
font-size: 11px;
|
||||
background-color: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.git-action-button:hover:not(:disabled) {
|
||||
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||
}
|
||||
|
||||
.git-action-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Sections ────────────────────────────────────────────────────────── */
|
||||
|
||||
.git-section {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.git-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--vscode-sideBar-foreground);
|
||||
}
|
||||
|
||||
.git-section-count {
|
||||
font-size: 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
/* ── Forms ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.git-commit-form,
|
||||
.git-init-form {
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
.git-commit-form input,
|
||||
.git-init-form input {
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
background-color: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-input-border);
|
||||
color: var(--vscode-input-foreground);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.git-commit-form input::placeholder,
|
||||
.git-init-form input::placeholder {
|
||||
color: var(--vscode-input-placeholderForeground);
|
||||
}
|
||||
|
||||
.git-commit-form input:focus,
|
||||
.git-init-form input:focus {
|
||||
outline: none;
|
||||
border-color: var(--vscode-focusBorder);
|
||||
}
|
||||
|
||||
.git-commit-form .git-action-button,
|
||||
.git-init-form .git-action-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Status file list ────────────────────────────────────────────────── */
|
||||
|
||||
.git-status-file {
|
||||
width: 100%;
|
||||
padding: 5px 12px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--vscode-sideBar-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.git-status-file:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.git-status-path {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.git-status-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.git-status-added {
|
||||
color: var(--vscode-testing-iconPassed);
|
||||
background: color-mix(in srgb, var(--vscode-testing-iconPassed) 15%, transparent);
|
||||
}
|
||||
|
||||
.git-status-modified {
|
||||
color: var(--vscode-editorWarning-foreground);
|
||||
background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 15%, transparent);
|
||||
}
|
||||
|
||||
.git-status-deleted {
|
||||
color: var(--vscode-errorForeground);
|
||||
background: color-mix(in srgb, var(--vscode-errorForeground) 15%, transparent);
|
||||
}
|
||||
|
||||
.git-status-renamed {
|
||||
color: var(--vscode-notificationsInfoIcon-foreground);
|
||||
background: color-mix(in srgb, var(--vscode-notificationsInfoIcon-foreground) 15%, transparent);
|
||||
}
|
||||
|
||||
.git-status-untracked {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
background: color-mix(in srgb, var(--vscode-descriptionForeground) 15%, transparent);
|
||||
}
|
||||
|
||||
/* ── Commit history list ─────────────────────────────────────────────── */
|
||||
|
||||
.git-history-entry {
|
||||
width: 100%;
|
||||
padding: 5px 12px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--vscode-sideBar-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.git-history-entry:hover {
|
||||
background-color: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.git-history-subject {
|
||||
font-size: 12px;
|
||||
color: var(--vscode-sideBar-foreground);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.git-history-meta {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.git-history-hash,
|
||||
.git-history-author,
|
||||
.git-history-date {
|
||||
font-size: 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.git-history-hash {
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
}
|
||||
|
||||
.git-history-more {
|
||||
padding: 8px 12px;
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Empty / Not-a-repo state ────────────────────────────────────────── */
|
||||
|
||||
.git-not-a-repo {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.git-empty-hint {
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
/* ── Divider between sections ────────────────────────────────────────── */
|
||||
|
||||
.git-section + .git-section {
|
||||
border-top: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
|
||||
/* ── Section header that matches sidebar-section-header ───────────────── */
|
||||
|
||||
.git-header + .git-actions {
|
||||
border-top: 1px solid var(--vscode-sideBar-border);
|
||||
}
|
||||
539
assets/css/misc_editor.css
Normal file
539
assets/css/misc_editor.css
Normal file
@@ -0,0 +1,539 @@
|
||||
/* ── Misc-editor shell (shared by all misc tabs) ──────────────────────── */
|
||||
|
||||
.misc-editor-shell {
|
||||
background: var(--vscode-editor-background);
|
||||
}
|
||||
|
||||
.misc-editor-header {
|
||||
padding: 12px 16px 8px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
background: var(--vscode-tab-activeBackground);
|
||||
}
|
||||
|
||||
.misc-editor-header h2 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.misc-editor-header p {
|
||||
margin: 2px 0 0;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.misc-editor-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.misc-editor-summary {
|
||||
padding: 8px 16px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.misc-editor-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ── Summary pills ───────────────────────────────────────────────────── */
|
||||
|
||||
.misc-summary-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.misc-summary-pill span {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.misc-summary-pill strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Misc card (used by site-validation, empty states) ───────────────── */
|
||||
|
||||
.misc-card {
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||
}
|
||||
|
||||
.misc-card h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.misc-card p {
|
||||
margin: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.misc-card ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Misc columns (site-validation 3-column layout) ──────────────────── */
|
||||
|
||||
.misc-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ── Misc list (find-duplicates) ─────────────────────────────────────── */
|
||||
|
||||
.misc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.misc-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.misc-list-item:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.duplicate-pair-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.duplicate-pair-row .linkish {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--vscode-textLink-foreground, #3794ff);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
text-decoration-thickness: 1px;
|
||||
text-underline-offset: 0.14em;
|
||||
}
|
||||
|
||||
.duplicate-pair-row .linkish:hover {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
/* ── Metadata-diff: tab bar ──────────────────────────────────────────── */
|
||||
|
||||
.metadata-diff-tool {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metadata-diff-tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.metadata-diff-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--vscode-tab-inactiveForeground, var(--vscode-descriptionForeground));
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: color 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.metadata-diff-tab:hover {
|
||||
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
|
||||
}
|
||||
|
||||
.metadata-diff-tab.active {
|
||||
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
|
||||
border-bottom-color: var(--vscode-focusBorder, #007fd4);
|
||||
}
|
||||
|
||||
.tab-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: var(--vscode-activityBarBadge-background, #007acc);
|
||||
color: var(--vscode-activityBarBadge-foreground, #ffffff);
|
||||
}
|
||||
|
||||
/* ── Metadata-diff: field pills ──────────────────────────────────────── */
|
||||
|
||||
.metadata-diff-field-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.metadata-diff-field-pill {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
background: var(--vscode-input-background);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
|
||||
.metadata-diff-field-pill.active {
|
||||
border-color: var(--vscode-focusBorder, #007fd4);
|
||||
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 12%, transparent);
|
||||
}
|
||||
|
||||
.metadata-diff-field-pill-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--vscode-foreground);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.metadata-diff-field-pill-toggle:hover {
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
|
||||
.field-pill-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-pill-count {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.metadata-diff-field-pill-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 2px 4px;
|
||||
border-left: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.metadata-diff-action-button {
|
||||
font-size: 11px !important;
|
||||
padding: 2px 8px !important;
|
||||
min-height: 22px !important;
|
||||
}
|
||||
|
||||
/* ── Metadata-diff: results area ─────────────────────────────────────── */
|
||||
|
||||
.metadata-diff-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.metadata-diff-empty p {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* ── Diff item cards (shared by metadata-diff and orphan sections) ──── */
|
||||
|
||||
.diff-item-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.diff-item-card {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.diff-item-card.orphan-file {
|
||||
border-left: 3px solid var(--vscode-editorWarning-foreground, #cca700);
|
||||
}
|
||||
|
||||
.diff-item-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, transparent);
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.diff-item-header strong {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.diff-item-meta {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.diff-item-fields {
|
||||
padding: 8px 14px;
|
||||
}
|
||||
|
||||
.diff-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 120px 1fr;
|
||||
gap: 8px;
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--vscode-panel-border) 50%, transparent);
|
||||
}
|
||||
|
||||
.diff-field-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.diff-field-name {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.diff-field-values {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.diff-field-value {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.diff-field-value.db-value {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.diff-field-value.file-value {
|
||||
color: var(--vscode-foreground);
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.diff-source-label {
|
||||
flex-shrink: 0;
|
||||
min-width: 28px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.db-value .diff-source-label {
|
||||
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 22%, transparent);
|
||||
color: var(--vscode-focusBorder, #007fd4);
|
||||
}
|
||||
|
||||
.file-value .diff-source-label {
|
||||
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 22%, transparent);
|
||||
color: var(--vscode-testing-iconPassed, #73c991);
|
||||
}
|
||||
|
||||
/* ── Orphan files section ────────────────────────────────────────────── */
|
||||
|
||||
.orphan-files-section {
|
||||
border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 35%, transparent);
|
||||
border-radius: 8px;
|
||||
padding: 14px 16px;
|
||||
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 5%, var(--vscode-editor-background));
|
||||
}
|
||||
|
||||
.orphan-files-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.orphan-files-header h3 {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.orphan-files-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.orphan-path span {
|
||||
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
/* ── Translation validation ──────────────────────────────────────────── */
|
||||
|
||||
.translation-validation-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.translation-validation-summary {
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
font-size: 12px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.translation-validation-summary p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.translation-validation-section h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.translation-validation-empty {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.translation-validation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.translation-validation-card {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||
}
|
||||
|
||||
.translation-validation-card-db {
|
||||
border-left: 3px solid var(--vscode-focusBorder, #007fd4);
|
||||
}
|
||||
|
||||
.translation-validation-card-file {
|
||||
border-left: 3px solid var(--vscode-testing-iconPassed, #73c991);
|
||||
}
|
||||
|
||||
.translation-validation-card-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.translation-validation-card-meta {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 3px 12px;
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.translation-validation-card-meta dt {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.translation-validation-card-meta dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.translation-validation-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
/* ── Git diff ────────────────────────────────────────────────────────── */
|
||||
|
||||
.git-diff-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.git-diff-empty {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
text-align: center;
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.git-diff-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.git-diff-toolbar label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.git-diff-toolbar select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.git-diff-editor {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -36,6 +36,8 @@
|
||||
.confirm-delete-modal,
|
||||
.confirm-dialog,
|
||||
.gallery-overlay-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 8px;
|
||||
|
||||
@@ -579,6 +579,13 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.task-message-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.status-bar-item.theme-badge {
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 3px;
|
||||
@@ -595,14 +602,16 @@
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.4;
|
||||
font-size: 13px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.status-bar-item.offline-badge.active {
|
||||
background-color: rgba(255, 196, 0, 0.28);
|
||||
background-color: #e6a800;
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
opacity: 1;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.project-selector {
|
||||
|
||||
@@ -3,6 +3,9 @@ export const ChatSurface = {
|
||||
this.stickToBottom = true;
|
||||
this.scrollContainer = null;
|
||||
|
||||
this._enterKeyHandled = false;
|
||||
this._prevInputValue = "";
|
||||
|
||||
this.autoResize = () => {
|
||||
const textarea = this.el.querySelector(".chat-input");
|
||||
|
||||
@@ -85,11 +88,34 @@ export const ChatSurface = {
|
||||
this.stickToBottom = distanceFromBottom < 48;
|
||||
};
|
||||
|
||||
this._submitChat = () => {
|
||||
const form = this.el.querySelector(".chat-input-wrapper");
|
||||
if (form && typeof form.requestSubmit === "function") {
|
||||
form.requestSubmit();
|
||||
} else {
|
||||
const sendButton = this.el.querySelector("[data-testid='chat-send-button']");
|
||||
if (sendButton) sendButton.click();
|
||||
}
|
||||
};
|
||||
|
||||
this.handleInput = (event) => {
|
||||
if (!event.target.closest(".chat-input")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = event.target;
|
||||
|
||||
if (!this._enterKeyHandled && textarea.value.includes("\n") && !this._prevInputValue.includes("\n")) {
|
||||
textarea.value = textarea.value.replace(/\n/g, "");
|
||||
this._prevInputValue = textarea.value;
|
||||
this.stickToBottom = true;
|
||||
this.autoResize();
|
||||
this._submitChat();
|
||||
return;
|
||||
}
|
||||
|
||||
this._enterKeyHandled = false;
|
||||
this._prevInputValue = textarea.value;
|
||||
this.stickToBottom = true;
|
||||
this.autoResize();
|
||||
};
|
||||
@@ -101,12 +127,8 @@ export const ChatSurface = {
|
||||
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||
event.preventDefault();
|
||||
|
||||
const sendButton = this.el.querySelector("[data-testid='chat-send-button']");
|
||||
|
||||
if (sendButton && !sendButton.disabled) {
|
||||
sendButton.click();
|
||||
}
|
||||
this._enterKeyHandled = true;
|
||||
this._submitChat();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
45
assets/js/hooks/colour_picker.js
Normal file
45
assets/js/hooks/colour_picker.js
Normal file
@@ -0,0 +1,45 @@
|
||||
export const ColourPicker = {
|
||||
mounted() {
|
||||
this._onClickAway = (e) => {
|
||||
if (!this.el.contains(e.target)) {
|
||||
this.el.querySelector(".colour-picker-popover")?.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", this._onClickAway);
|
||||
|
||||
this._setupCustomInput();
|
||||
},
|
||||
|
||||
updated() {
|
||||
this._setupCustomInput();
|
||||
},
|
||||
|
||||
destroyed() {
|
||||
document.removeEventListener("mousedown", this._onClickAway);
|
||||
},
|
||||
|
||||
_setupCustomInput() {
|
||||
const input = this.el.querySelector(".colour-picker-custom input");
|
||||
if (!input || input._cpBound) return;
|
||||
input._cpBound = true;
|
||||
|
||||
const pushColor = () => {
|
||||
let val = input.value.trim();
|
||||
if (val && !val.startsWith("#")) val = "#" + val;
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
|
||||
const event = this.el.dataset.pickEvent;
|
||||
this.pushEventTo(this.el.dataset.target, event, { color: val });
|
||||
this.el.querySelector(".colour-picker-popover")?.classList.add("hidden");
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
pushColor();
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener("blur", pushColor);
|
||||
}
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { AppShell } from "./app_shell.js";
|
||||
import { SidebarInteractions } from "./sidebar_interactions.js";
|
||||
import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js";
|
||||
import { ChatSurface } from "./chat_surface.js";
|
||||
import { ColourPicker } from "./colour_picker.js";
|
||||
import { MenuEditorTree } from "./menu_editor_tree.js";
|
||||
import { MonacoEditor } from "./monaco_editor.js";
|
||||
import { MonacoDiffEditor } from "./monaco_diff_editor.js";
|
||||
@@ -12,6 +13,7 @@ export const Hooks = {
|
||||
SettingsSectionScroll,
|
||||
TagsSectionScroll,
|
||||
ChatSurface,
|
||||
ColourPicker,
|
||||
MenuEditorTree,
|
||||
MonacoEditor,
|
||||
MonacoDiffEditor
|
||||
|
||||
@@ -118,6 +118,36 @@ export const MonacoEditor = {
|
||||
}, 120);
|
||||
};
|
||||
|
||||
this.dropEvent = this.el.dataset.monacoDropEvent || "";
|
||||
this.dropPostId = this.el.dataset.monacoDropPostId || "";
|
||||
|
||||
this.handleDragOver = (event) => {
|
||||
if (event.dataTransfer && Array.from(event.dataTransfer.types || []).includes("Files")) {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "copy";
|
||||
}
|
||||
};
|
||||
|
||||
this.handleDrop = (event) => {
|
||||
if (!this.dropEvent || !event.dataTransfer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const files = Array.from(event.dataTransfer.files || []);
|
||||
const images = files.filter((file) => (file.type || "").startsWith("image/") && file.path);
|
||||
|
||||
if (images.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
images.forEach((file) => {
|
||||
this.pushEvent(this.dropEvent, { "post-id": this.dropPostId, path: file.path });
|
||||
});
|
||||
};
|
||||
|
||||
this.handleInsert = ({ id, content }) => {
|
||||
if (!this.editor || !content || String(id) !== String(this.editorId)) {
|
||||
return;
|
||||
@@ -197,6 +227,11 @@ export const MonacoEditor = {
|
||||
if (this.insertEvent) {
|
||||
this.handleEvent(this.insertEvent, this.handleInsert);
|
||||
}
|
||||
|
||||
if (this.dropEvent) {
|
||||
this.el.addEventListener("dragover", this.handleDragOver);
|
||||
this.el.addEventListener("drop", this.handleDrop);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load Monaco editor", error);
|
||||
@@ -232,6 +267,12 @@ export const MonacoEditor = {
|
||||
window.clearTimeout(this.syncTimer);
|
||||
this.visibleSizeObserver?.disconnect();
|
||||
this.changeSubscription?.dispose();
|
||||
|
||||
if (this.dropEvent) {
|
||||
this.el.removeEventListener("dragover", this.handleDragOver);
|
||||
this.el.removeEventListener("drop", this.handleDrop);
|
||||
}
|
||||
|
||||
unregisterMonacoEditor(this.editorId || this.el.id);
|
||||
this.editor?.dispose();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ config :bds,
|
||||
ecto_repos: [BDS.Repo]
|
||||
|
||||
config :bds, BDS.Repo,
|
||||
database: Path.expand("../priv/data/bds_dev.db", __DIR__),
|
||||
database:
|
||||
System.get_env("BDS_DATABASE_PATH") ||
|
||||
Path.expand("~/Library/Application Support/BDS2/bds_dev.db"),
|
||||
pool_size: 5,
|
||||
journal_mode: :wal,
|
||||
busy_timeout: 15_000,
|
||||
@@ -14,12 +16,14 @@ config :bds, BDS.Repo,
|
||||
|
||||
config :bds, BDS.Application, desktop_adapter: :desktop
|
||||
|
||||
# No secrets live in this file: the endpoint signing secret is generated per
|
||||
# boot (BDS.Application) and the AI secret master key comes from the OS
|
||||
# keyring (BDS.AI.SecretKey).
|
||||
config :bds, :desktop,
|
||||
port: 4010,
|
||||
window_size: {1280, 780},
|
||||
window_min_size: {800, 600},
|
||||
title: "Blogging Desktop Server",
|
||||
secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001"
|
||||
title: "Blogging Desktop Server"
|
||||
|
||||
config :bds, BDS.Desktop.Endpoint,
|
||||
url: [host: "127.0.0.1"],
|
||||
@@ -58,12 +62,44 @@ config :bds, :scripting,
|
||||
timeout: 300_000,
|
||||
max_reductions: 5_000_000,
|
||||
job_timeout: :infinity,
|
||||
job_max_reductions: :none
|
||||
job_max_reductions: :none,
|
||||
transform_max_toasts_per_script: 5,
|
||||
transform_max_toasts_total: 20,
|
||||
transform_max_toast_length: 300
|
||||
|
||||
# streaming: chat completions use SSE when the provider supports it (set to
|
||||
# false for OpenAI-compatible servers that reject the "stream" flag).
|
||||
# stream_emit_interval_ms throttles how often streamed content reaches the UI.
|
||||
# await_timeout_margin_ms is added on top of the per-request HTTP budget across
|
||||
# the bounded tool-call loop, so the caller never waits forever.
|
||||
config :bds, :chat,
|
||||
max_tool_rounds: 10,
|
||||
streaming: true,
|
||||
stream_emit_interval_ms: 100,
|
||||
await_timeout_margin_ms: 5_000
|
||||
|
||||
config :bds, :git,
|
||||
local_timeout_ms: 15_000,
|
||||
network_timeout_ms: 120_000
|
||||
|
||||
config :bds, :embeddings,
|
||||
backend: BDS.Embeddings.Backends.InApp,
|
||||
backend: BDS.Embeddings.Backends.Neural,
|
||||
model_id: "Xenova/multilingual-e5-small",
|
||||
dimensions: 384
|
||||
model_repo: "intfloat/multilingual-e5-small",
|
||||
dimensions: 384,
|
||||
# Inference is batched: batch_size texts per compiled run, truncated to
|
||||
# sequence_length tokens. Tuning these trades throughput against memory.
|
||||
batch_size: 16,
|
||||
sequence_length: 256,
|
||||
# Hardware acceleration: :auto prefers the Apple GPU (EMLX/Metal) on Apple
|
||||
# Silicon and falls back to EXLA-CPU elsewhere. Force with :emlx or :exla.
|
||||
accelerator: :auto
|
||||
|
||||
# Cache downloaded model files under the app data directory so they persist
|
||||
# across sessions (ModelCaching invariant). Overridden at runtime in prod.
|
||||
config :bumblebee, :cache_dir,
|
||||
System.get_env("BDS_MODEL_CACHE_DIR") ||
|
||||
Path.expand("~/Library/Application Support/BDS2/models")
|
||||
|
||||
config :logger, :console,
|
||||
format: "$time $metadata[$level] $message\n",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import Config
|
||||
|
||||
config :bds, BDS.Repo,
|
||||
database: Path.expand("../priv/data/bds_prod.db", __DIR__),
|
||||
pool_size: 5,
|
||||
stacktrace: false,
|
||||
show_sensitive_data_on_connection_error: false
|
||||
|
||||
@@ -3,9 +3,19 @@ import Config
|
||||
if config_env() == :prod do
|
||||
database_path =
|
||||
System.get_env("BDS_DATABASE_PATH") ||
|
||||
Path.expand("../priv/data/bds_prod.db", __DIR__)
|
||||
Path.expand("~/Library/Application Support/BDS2/bds.db")
|
||||
|
||||
File.mkdir_p!(Path.dirname(database_path))
|
||||
|
||||
# Keep prod on the same modest SQLite pool as dev so WAL + busy_timeout see
|
||||
# the same concurrency behavior in both environments unless explicitly tuned.
|
||||
config :bds, BDS.Repo,
|
||||
database: database_path,
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "1")
|
||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
|
||||
|
||||
# Persist downloaded embedding model files alongside the database data dir.
|
||||
config :bumblebee,
|
||||
:cache_dir,
|
||||
System.get_env("BDS_MODEL_CACHE_DIR") ||
|
||||
Path.join(Path.dirname(Path.expand(database_path)), "models")
|
||||
end
|
||||
|
||||
@@ -8,3 +8,17 @@ config :bds, BDS.Repo,
|
||||
busy_timeout: 15_000
|
||||
|
||||
config :logger, level: :warning
|
||||
|
||||
# Deterministic, test-only master key so secret round-trips never touch the
|
||||
# OS keyring (Keychain) or write key files on developer machines.
|
||||
config :bds, :ai_secret_key, "bds-test-only-ai-secret-key-not-used-outside-the-test-env"
|
||||
|
||||
# Tests use the deterministic lexical stub backend so the suite stays offline
|
||||
# and never downloads the ~100 MB neural model.
|
||||
config :bds, :embeddings,
|
||||
backend: BDS.Embeddings.Backends.InApp,
|
||||
model_id: "Xenova/multilingual-e5-small",
|
||||
model_repo: "intfloat/multilingual-e5-small",
|
||||
dimensions: 384,
|
||||
batch_size: 16,
|
||||
sequence_length: 256
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
defmodule BDS.AI do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Public interface for AI features — endpoint configuration, secret management,
|
||||
model catalog access, and dispatching chat and one-shot inference requests.
|
||||
"""
|
||||
|
||||
alias BDS.AI.Catalog
|
||||
alias BDS.AI.Chat
|
||||
@@ -59,11 +62,9 @@ defmodule BDS.AI do
|
||||
model = get_setting("ai.#{kind_key}.model")
|
||||
encrypted_api_key = get_setting(encrypted_key("ai.#{kind_key}.api_key"))
|
||||
|
||||
cond do
|
||||
is_nil(url) and is_nil(model) and is_nil(encrypted_api_key) ->
|
||||
if is_nil(url) and is_nil(model) and is_nil(encrypted_api_key) do
|
||||
{:ok, nil}
|
||||
|
||||
true ->
|
||||
else
|
||||
with {:ok, api_key} <- get_secret(encrypted_api_key, backend) do
|
||||
{:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
|
||||
end
|
||||
@@ -110,6 +111,19 @@ defmodule BDS.AI do
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
True when the airplane (local) endpoint has both a URL and a model
|
||||
configured, so gated AI features can run against the local model.
|
||||
"""
|
||||
@spec airplane_endpoint_configured?() :: boolean()
|
||||
def airplane_endpoint_configured? do
|
||||
present_setting?(get_setting("ai.airplane.url")) and
|
||||
present_setting?(get_setting("ai.airplane.model"))
|
||||
end
|
||||
|
||||
defp present_setting?(value) when is_binary(value), do: String.trim(value) != ""
|
||||
defp present_setting?(_value), do: false
|
||||
|
||||
@spec put_model_preference(atom(), String.t()) ::
|
||||
:ok | {:error, :unknown_model_preference | term()}
|
||||
def put_model_preference(key, model) when is_atom(key) and is_binary(model) do
|
||||
@@ -185,4 +199,12 @@ defmodule BDS.AI do
|
||||
|
||||
@spec cancel_chat(String.t()) :: :ok
|
||||
defdelegate cancel_chat(conversation_id), to: Chat
|
||||
|
||||
@spec get_surface_state(String.t()) :: map()
|
||||
defdelegate get_surface_state(conversation_id), to: Chat
|
||||
|
||||
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
|
||||
{:ok, map()} | {:error, term()}
|
||||
defdelegate put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces),
|
||||
to: Chat
|
||||
end
|
||||
|
||||
@@ -27,6 +27,7 @@ defmodule BDS.AI.Chat do
|
||||
@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
|
||||
|
||||
@spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
|
||||
@@ -62,6 +63,42 @@ defmodule BDS.AI.Chat do
|
||||
Repo.get(ChatConversation, conversation_id)
|
||||
end
|
||||
|
||||
@spec get_surface_state(String.t()) :: map()
|
||||
def get_surface_state(conversation_id) when is_binary(conversation_id) do
|
||||
case Repo.get(ChatConversation, conversation_id) do
|
||||
%ChatConversation{surface_state: state} when is_map(state) -> state
|
||||
_other -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
|
||||
{:ok, map()} | {:error, term()}
|
||||
def put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces)
|
||||
when is_binary(conversation_id) do
|
||||
case Repo.get(ChatConversation, conversation_id) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
%ChatConversation{} = conversation ->
|
||||
state = %{
|
||||
"surface_data" => surface_data,
|
||||
"surface_tabs" => surface_tabs,
|
||||
"dismissed_surfaces" => MapSet.to_list(dismissed_surfaces)
|
||||
}
|
||||
|
||||
conversation
|
||||
|> ChatConversation.changeset(%{
|
||||
surface_state: state,
|
||||
updated_at: Persistence.now_ms()
|
||||
})
|
||||
|> Repo.update()
|
||||
|> case do
|
||||
{:ok, _updated} -> {:ok, state}
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
|
||||
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
|
||||
case Repo.get(ChatConversation, conversation_id) do
|
||||
@@ -69,12 +106,24 @@ defmodule BDS.AI.Chat do
|
||||
{:error, :not_found}
|
||||
|
||||
%ChatConversation{} = conversation ->
|
||||
Repo.transaction(fn ->
|
||||
Repo.delete_all(
|
||||
from message in ChatMessage, where: message.conversation_id == ^conversation_id
|
||||
)
|
||||
|
||||
case delete_chat_conversation_test_hook(conversation_id) do
|
||||
:ok ->
|
||||
case Repo.delete(conversation) do
|
||||
{:ok, _conversation} -> {:ok, :deleted}
|
||||
{:ok, _conversation} -> :ok
|
||||
{:error, reason} -> Repo.rollback(reason)
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Repo.rollback(reason)
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
{:ok, :ok} -> {:ok, :deleted}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
@@ -160,19 +209,13 @@ defmodule BDS.AI.Chat do
|
||||
}) do
|
||||
task =
|
||||
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
||||
receive do
|
||||
:sandbox_ready -> :ok
|
||||
end
|
||||
|
||||
do_send_chat_message(conversation, user_message, opts)
|
||||
end)
|
||||
|
||||
InFlight.register(conversation.id, task.pid)
|
||||
:ok = allow_repo_sandbox(task.pid)
|
||||
send(task.pid, :sandbox_ready)
|
||||
|
||||
try do
|
||||
await_chat_task(task)
|
||||
await_chat_task(task, chat_await_timeout_ms())
|
||||
after
|
||||
InFlight.unregister(conversation.id)
|
||||
end
|
||||
@@ -375,7 +418,7 @@ defmodule BDS.AI.Chat do
|
||||
tools,
|
||||
runtime,
|
||||
opts,
|
||||
@chat_max_tool_rounds
|
||||
chat_max_tool_rounds()
|
||||
),
|
||||
{:ok, reply} <-
|
||||
maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do
|
||||
@@ -529,9 +572,10 @@ defmodule BDS.AI.Chat do
|
||||
rounds_left
|
||||
) do
|
||||
request = build_chat_request(conversation, messages, model, project_id, tools)
|
||||
generate_opts = put_stream_callback(opts, conversation.id)
|
||||
|
||||
with {:ok, response} <-
|
||||
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
|
||||
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, generate_opts),
|
||||
{:ok, assistant_message} <- persist_assistant_response(conversation.id, response),
|
||||
:ok <- touch_conversation(conversation.id) do
|
||||
if is_binary(Map.get(response, :content)) and String.trim(Map.get(response, :content)) != "" do
|
||||
@@ -578,6 +622,13 @@ defmodule BDS.AI.Chat do
|
||||
end
|
||||
end
|
||||
|
||||
defp delete_chat_conversation_test_hook(conversation_id) do
|
||||
case Application.get_env(:bds, :chat_delete_conversation_test_hook) do
|
||||
hook when is_function(hook, 1) -> hook.(conversation_id)
|
||||
_other -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
defp persist_assistant_response(conversation_id, response) do
|
||||
usage = normalize_usage(response.usage)
|
||||
|
||||
@@ -716,6 +767,25 @@ defmodule BDS.AI.Chat do
|
||||
ChatTools.available_specs(project_id, Catalog.model_capabilities(model))
|
||||
end
|
||||
|
||||
# BoundedToolLoop: the tool-calling round count is capped by
|
||||
# config.chat_max_tool_rounds (falling back to the built-in default).
|
||||
defp chat_max_tool_rounds do
|
||||
chat_config(:max_tool_rounds, @chat_max_tool_rounds)
|
||||
end
|
||||
|
||||
defp chat_await_timeout_ms do
|
||||
per_request_timeout_ms = BDS.AI.HttpClient.request_timeout_ms()
|
||||
|
||||
per_request_timeout_ms * (chat_max_tool_rounds() + 1) +
|
||||
chat_config(:await_timeout_margin_ms, @chat_await_timeout_margin_ms)
|
||||
end
|
||||
|
||||
defp chat_config(key, default) do
|
||||
:bds
|
||||
|> Application.get_env(:chat, [])
|
||||
|> Keyword.get(key, default)
|
||||
end
|
||||
|
||||
defp chat_system_prompt(project_id, tools) do
|
||||
base = get_setting("ai.system_prompt") || @default_system_prompt
|
||||
|
||||
@@ -742,15 +812,16 @@ defmodule BDS.AI.Chat do
|
||||
"- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.",
|
||||
"If a requested blog fact is available through these tools, call the tool instead of saying you cannot access the data.",
|
||||
"",
|
||||
"Available UI Render Tools:",
|
||||
"- Use render_chart to show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use it when presenting statistics or comparisons. Prefer heatmap over tables with emoji or color indicators for intensity grids or calendar-style activity.",
|
||||
"- Use render_table for tabular data, comparisons, and structured listings.",
|
||||
"- Use render_form to collect structured user input.",
|
||||
"- Use render_card for summaries, highlights, or actionable items.",
|
||||
"- Use render_metric for a single KPI or important statistic.",
|
||||
"- Use render_list for bullet lists, checklists, or simple enumerations.",
|
||||
"- Use render_tabs to organize multiple views into switchable tabs; tab content can contain text, metrics, lists, charts, and tables.",
|
||||
"When presenting data, statistics, or comparisons, prefer render tools over plain text. When building any visualization, render it as soon as you have enough data."
|
||||
"Available UI Render Tools (use these to show rich interactive elements):",
|
||||
"- render_chart: Show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use when presenting statistics or comparisons. Use stacked-bar when each bar has multiple segments (e.g., published vs draft posts per year). Use area for cumulative or trend data where the filled region emphasizes volume. Use donut for proportional breakdowns with a total displayed in the center. Use heatmap for grid/matrix visualizations where color intensity shows magnitude — e.g., posts per month across years (each series entry is a row like a year, each segment is a column like a month), or a calendar view where rows are weekdays and columns are week numbers. ALWAYS prefer heatmap over a table with emojis or color indicators when showing intensity grids or calendar-style activity views. IMPORTANT: a heatmap needs structured data — each entry in 'series' is a ROW and must include a 'segments' array whose entries are the COLUMNS (every segment needs a 'label' and a numeric 'value'); the row's own 'value' is ignored. Plan what the rows and columns represent before fetching data (e.g. rows = years, columns = months). A heatmap sent without segments renders empty.",
|
||||
"- render_table: Show data in a structured table. Use for tabular comparisons and listings.",
|
||||
"- render_form: Show an interactive form to collect user input (e.g., metadata edits, settings).",
|
||||
"- render_card: Show an information card with title, body, and action buttons.",
|
||||
"- render_metric: Show a single KPI or statistic prominently.",
|
||||
"- render_list: Show a bulleted list of items.",
|
||||
"- render_tabs: Organize information into switchable tabs. Tab content supports all content types: text, metrics, lists, charts, and tables.",
|
||||
"",
|
||||
"When presenting data, statistics, or comparisons, prefer using render tools (render_chart, render_table, render_metric) to show rich interactive UI instead of plain text. When you need user input for a multi-field operation, use render_form to present a structured form. Use render_card with action buttons when presenting items the user might want to navigate to (e.g., posts, media). When comparing data across multiple dimensions (e.g., statistics per year), use render_tabs with embedded charts or tables in each tab. When building any visualization, render it as soon as you have enough data."
|
||||
],
|
||||
"\n"
|
||||
)
|
||||
@@ -807,7 +878,7 @@ defmodule BDS.AI.Chat do
|
||||
:ok
|
||||
end
|
||||
|
||||
defp await_chat_task(task) do
|
||||
defp await_chat_task(task, timeout_ms) do
|
||||
ref = task.ref
|
||||
|
||||
receive do
|
||||
@@ -833,6 +904,10 @@ defmodule BDS.AI.Chat do
|
||||
_other ->
|
||||
{:error, :cancelled}
|
||||
end
|
||||
after
|
||||
timeout_ms ->
|
||||
_ = Task.shutdown(task, 100)
|
||||
{:error, :chat_timeout}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -876,6 +951,26 @@ defmodule BDS.AI.Chat do
|
||||
end
|
||||
end
|
||||
|
||||
# When someone is listening for chat events, ask the runtime to stream:
|
||||
# it emits cumulative content snapshots, which the editor renders with
|
||||
# replace semantics. The full-content notify after each round stays the
|
||||
# authoritative final state (and the only event for non-streaming runtimes).
|
||||
defp put_stream_callback(opts, conversation_id) do
|
||||
case Keyword.get(opts, :event_target) do
|
||||
nil ->
|
||||
opts
|
||||
|
||||
_target ->
|
||||
Keyword.put(opts, :on_stream, fn %{content: content} ->
|
||||
if is_binary(content) and String.trim(content) != "" do
|
||||
notify_chat_event(opts, {:chat_streaming_content, conversation_id, content})
|
||||
end
|
||||
|
||||
:ok
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_chat_event(opts, event) do
|
||||
case Keyword.get(opts, :event_target) do
|
||||
pid when is_pid(pid) -> send(pid, event)
|
||||
@@ -890,20 +985,6 @@ defmodule BDS.AI.Chat do
|
||||
Repo.one(from project in Project, where: project.is_active == true, select: project.id)
|
||||
end
|
||||
|
||||
defp allow_repo_sandbox(pid) when is_pid(pid) do
|
||||
if Code.ensure_loaded?(Ecto.Adapters.SQL.Sandbox) do
|
||||
try do
|
||||
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
|
||||
rescue
|
||||
_error -> :ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp encode_nullable(nil), do: nil
|
||||
defp encode_nullable(value), do: Jason.encode!(value)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ defmodule BDS.AI.ChatConversation do
|
||||
title: String.t() | nil,
|
||||
model: String.t() | nil,
|
||||
copilot_session_id: String.t() | nil,
|
||||
surface_state: map() | nil,
|
||||
created_at: integer() | nil,
|
||||
updated_at: integer() | nil
|
||||
}
|
||||
@@ -19,13 +20,16 @@ defmodule BDS.AI.ChatConversation do
|
||||
field :title, :string
|
||||
field :model, :string
|
||||
field :copilot_session_id, :string
|
||||
field :surface_state, :map
|
||||
field :created_at, :integer
|
||||
field :updated_at, :integer
|
||||
end
|
||||
|
||||
def changeset(conversation, attrs) do
|
||||
conversation
|
||||
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :created_at, :updated_at],
|
||||
|> cast(
|
||||
attrs,
|
||||
[:id, :title, :model, :copilot_session_id, :surface_state, :created_at, :updated_at],
|
||||
empty_values: [nil]
|
||||
)
|
||||
|> validate_required([:id, :title, :created_at, :updated_at])
|
||||
|
||||
@@ -803,10 +803,40 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"fields" => %{"type" => "array"},
|
||||
"submitLabel" => %{"type" => "string"},
|
||||
"submitAction" => %{"type" => "string"}
|
||||
"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
|
||||
@@ -815,10 +845,25 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"subtitle" => %{"type" => "string"},
|
||||
"body" => %{"type" => "string"},
|
||||
"actions" => %{"type" => "array"}
|
||||
"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
|
||||
@@ -827,8 +872,8 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"label" => %{"type" => "string"},
|
||||
"value" => %{"type" => "string"}
|
||||
"label" => %{"type" => "string", "description" => "Metric label"},
|
||||
"value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"}
|
||||
}
|
||||
}
|
||||
end
|
||||
@@ -837,8 +882,12 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"items" => %{"type" => "array"}
|
||||
"title" => %{"type" => "string", "description" => "Optional list title"},
|
||||
"items" => %{
|
||||
"type" => "array",
|
||||
"items" => %{"type" => "string"},
|
||||
"description" => "List items"
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
@@ -847,8 +896,58 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"tabs" => %{"type" => "array"}
|
||||
"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
|
||||
@@ -857,8 +956,24 @@ defmodule BDS.AI.ChatTools do
|
||||
%{
|
||||
"type" => "object",
|
||||
"properties" => %{
|
||||
"title" => %{"type" => "string"},
|
||||
"nodes" => %{"type" => "array"}
|
||||
"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
|
||||
|
||||
@@ -1,58 +1,150 @@
|
||||
defmodule BDS.AI.HttpClient do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Req-based HTTP client for AI endpoints.
|
||||
|
||||
Replaces the previous `:httpc` wrapper with explicit connect/receive
|
||||
timeouts, TLS verification via Req's defaults, and transient retries for
|
||||
idempotent GETs only — POSTs (chat completions) are never retried.
|
||||
|
||||
The response contract is unchanged: `{:ok, %{status, headers, body}}` with
|
||||
downcased single-valued header names and the body as a raw binary (callers
|
||||
decode JSON themselves), or `{:error, reason}` where transport failures
|
||||
surface as plain reason atoms such as `:timeout` or `:econnrefused`.
|
||||
|
||||
Config (`config :bds, BDS.AI.HttpClient`):
|
||||
|
||||
* `:connect_timeout_ms` — TCP/TLS connect budget (default 5_000)
|
||||
* `:receive_timeout_ms` — response budget (default 120_000; generous
|
||||
because local LLM completions are slow)
|
||||
* `:get_max_retries` — transient retries for GETs (default 2)
|
||||
* `:retry_delay_ms` — constant delay between retries (default 500)
|
||||
"""
|
||||
|
||||
@default_connect_timeout_ms 5_000
|
||||
@default_receive_timeout_ms 120_000
|
||||
@default_get_max_retries 2
|
||||
@default_retry_delay_ms 500
|
||||
|
||||
@spec request_timeout_ms() :: pos_integer()
|
||||
def request_timeout_ms do
|
||||
max(
|
||||
config(:connect_timeout_ms, @default_connect_timeout_ms),
|
||||
config(:receive_timeout_ms, @default_receive_timeout_ms)
|
||||
)
|
||||
end
|
||||
|
||||
@spec get(String.t(), %{String.t() => String.t()}) ::
|
||||
{:ok, %{status: non_neg_integer(), headers: map(), body: binary()}} | {:error, term()}
|
||||
def get(url, headers) when is_binary(url) and is_map(headers) do
|
||||
request =
|
||||
{String.to_charlist(url),
|
||||
Enum.map(headers, fn {key, value} ->
|
||||
{String.to_charlist(key), String.to_charlist(value)}
|
||||
end)}
|
||||
|
||||
:inets.start()
|
||||
:ssl.start()
|
||||
|
||||
case :httpc.request(:get, request, [], body_format: :binary) do
|
||||
{:ok, {{_version, status, _reason}, response_headers, body}} ->
|
||||
{:ok,
|
||||
%{
|
||||
status: status,
|
||||
headers: normalize_headers(response_headers),
|
||||
body: body
|
||||
}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
[
|
||||
method: :get,
|
||||
url: url,
|
||||
headers: headers,
|
||||
retry: :transient,
|
||||
max_retries: config(:get_max_retries, @default_get_max_retries),
|
||||
retry_delay: fn _retry_count -> config(:retry_delay_ms, @default_retry_delay_ms) end,
|
||||
retry_log_level: false
|
||||
]
|
||||
|> Keyword.merge(base_options())
|
||||
|> Req.request()
|
||||
|> normalize_result()
|
||||
end
|
||||
|
||||
@spec post(String.t(), %{String.t() => String.t()}, binary()) ::
|
||||
{:ok, %{status: non_neg_integer(), headers: map(), body: binary()}} | {:error, term()}
|
||||
def post(url, headers, body)
|
||||
when is_binary(url) and is_map(headers) and is_binary(body) do
|
||||
request =
|
||||
{String.to_charlist(url),
|
||||
Enum.map(headers, fn {key, value} ->
|
||||
{String.to_charlist(key), String.to_charlist(value)}
|
||||
end), ~c"application/json", body}
|
||||
[
|
||||
method: :post,
|
||||
url: url,
|
||||
headers: headers,
|
||||
body: body,
|
||||
# Completions are not idempotent; a retry could bill or generate twice.
|
||||
retry: false
|
||||
]
|
||||
|> Keyword.merge(base_options())
|
||||
|> Req.request()
|
||||
|> normalize_result()
|
||||
end
|
||||
|
||||
:inets.start()
|
||||
:ssl.start()
|
||||
@doc """
|
||||
Streaming POST: body chunks of a 200 response are folded into `acc` via
|
||||
`reducer.(chunk, acc)` as they arrive; non-200 bodies are collected whole
|
||||
for error reporting. Returns the final accumulator alongside the response.
|
||||
|
||||
case :httpc.request(:post, request, [], body_format: :binary) do
|
||||
{:ok, {{_version, status, _reason}, response_headers, response_body}} ->
|
||||
{:ok,
|
||||
%{
|
||||
status: status,
|
||||
headers: normalize_headers(response_headers),
|
||||
body: response_body
|
||||
}}
|
||||
Never retried (same reasoning as `post/3`), and `accept-encoding` is
|
||||
disabled so event-stream chunks arrive uncompressed. The request runs in
|
||||
the calling process — killing that process aborts the underlying
|
||||
connection, which is what makes mid-stream chat cancellation work.
|
||||
"""
|
||||
@spec post_stream(String.t(), %{String.t() => String.t()}, binary(), acc, (binary(), acc ->
|
||||
acc)) ::
|
||||
{:ok, %{status: non_neg_integer(), headers: map(), body: binary()}, acc}
|
||||
| {:error, term()}
|
||||
when acc: term()
|
||||
def post_stream(url, headers, body, acc, reducer)
|
||||
when is_binary(url) and is_map(headers) and is_binary(body) and is_function(reducer, 2) do
|
||||
into = fn {:data, data}, {req, resp} ->
|
||||
resp =
|
||||
if resp.status == 200 do
|
||||
next_acc = reducer.(data, Req.Response.get_private(resp, :bds_stream_acc, acc))
|
||||
Req.Response.put_private(resp, :bds_stream_acc, next_acc)
|
||||
else
|
||||
%{resp | body: collected_body(resp.body) <> data}
|
||||
end
|
||||
|
||||
{:cont, {req, resp}}
|
||||
end
|
||||
|
||||
[
|
||||
method: :post,
|
||||
url: url,
|
||||
headers: headers,
|
||||
body: body,
|
||||
retry: false,
|
||||
compressed: false,
|
||||
into: into
|
||||
]
|
||||
|> Keyword.merge(base_options())
|
||||
|> Req.request()
|
||||
|> case do
|
||||
{:ok, %Req.Response{} = resp} ->
|
||||
{:ok, %{status: resp.status, headers: normalize_headers(resp.headers), body: collected_body(resp.body)},
|
||||
Req.Response.get_private(resp, :bds_stream_acc, acc)}
|
||||
|
||||
{:error, %Req.TransportError{reason: reason}} ->
|
||||
{:error, reason}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp collected_body(body) when is_binary(body), do: body
|
||||
defp collected_body(_body), do: ""
|
||||
|
||||
defp base_options do
|
||||
[
|
||||
connect_options: [timeout: config(:connect_timeout_ms, @default_connect_timeout_ms)],
|
||||
receive_timeout: config(:receive_timeout_ms, @default_receive_timeout_ms),
|
||||
# Callers parse the body themselves; keep it a raw binary.
|
||||
decode_body: false
|
||||
]
|
||||
end
|
||||
|
||||
defp normalize_result({:ok, %Req.Response{status: status, headers: headers, body: body}}) do
|
||||
{:ok, %{status: status, headers: normalize_headers(headers), body: body}}
|
||||
end
|
||||
|
||||
defp normalize_result({:error, %Req.TransportError{reason: reason}}), do: {:error, reason}
|
||||
defp normalize_result({:error, reason}), do: {:error, reason}
|
||||
|
||||
# Req header names are already downcased; values arrive as lists.
|
||||
defp normalize_headers(headers) do
|
||||
Enum.into(headers, %{}, fn {key, value} ->
|
||||
{key |> to_string() |> String.downcase(), to_string(value)}
|
||||
end)
|
||||
Map.new(headers, fn {name, values} -> {name, Enum.join(List.wrap(values), ", ")} end)
|
||||
end
|
||||
|
||||
defp config(key, default) do
|
||||
Application.get_env(:bds, __MODULE__, []) |> Keyword.get(key, default)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,29 +1,38 @@
|
||||
defmodule BDS.AI.InFlight do
|
||||
@moduledoc false
|
||||
|
||||
# Registry of in-flight chat tasks keyed by conversation id. The named ETS
|
||||
# table is owned by this supervised GenServer (started from the application
|
||||
# supervision tree), so registrations survive the exit of the registering
|
||||
# process and there is no creation race between concurrent first callers.
|
||||
use GenServer
|
||||
|
||||
@table :bds_ai_in_flight
|
||||
|
||||
def start_link(opts) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
table = :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
|
||||
{:ok, table}
|
||||
end
|
||||
|
||||
def register(conversation_id, pid) when is_binary(conversation_id) and is_pid(pid) do
|
||||
:ets.insert(table(), {conversation_id, pid})
|
||||
:ets.insert(@table, {conversation_id, pid})
|
||||
:ok
|
||||
end
|
||||
|
||||
def unregister(conversation_id) when is_binary(conversation_id) do
|
||||
:ets.delete(table(), conversation_id)
|
||||
:ets.delete(@table, conversation_id)
|
||||
:ok
|
||||
end
|
||||
|
||||
def lookup(conversation_id) when is_binary(conversation_id) do
|
||||
case :ets.lookup(table(), conversation_id) do
|
||||
case :ets.lookup(@table, conversation_id) do
|
||||
[{^conversation_id, pid}] -> pid
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp table do
|
||||
case :ets.whereis(@table) do
|
||||
:undefined -> :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
|
||||
table -> table
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
42
lib/bds/ai/json_content.ex
Normal file
42
lib/bds/ai/json_content.ex
Normal file
@@ -0,0 +1,42 @@
|
||||
defmodule BDS.AI.JsonContent do
|
||||
@moduledoc """
|
||||
Decodes JSON object payloads from model responses, tolerating the markdown
|
||||
code fences and surrounding prose that smaller (local) models often emit
|
||||
instead of bare JSON.
|
||||
"""
|
||||
|
||||
@fence_pattern ~r/```(?:json)?\s*\n?(.*?)```/is
|
||||
|
||||
@spec decode(term()) :: map() | nil
|
||||
def decode(content) when is_binary(content) do
|
||||
decode_strict(content) || decode_fenced(content) || decode_embedded_object(content)
|
||||
end
|
||||
|
||||
def decode(_content), do: nil
|
||||
|
||||
defp decode_strict(content) do
|
||||
case Jason.decode(content) do
|
||||
{:ok, decoded} when is_map(decoded) -> decoded
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_fenced(content) do
|
||||
case Regex.run(@fence_pattern, content, capture: :all_but_first) do
|
||||
[inner] -> decode_strict(String.trim(inner)) || decode_embedded_object(inner)
|
||||
_no_fence -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_embedded_object(content) do
|
||||
with {start, _length} <- :binary.match(content, "{"),
|
||||
[{last, _} | _] <- content |> :binary.matches("}") |> Enum.take(-1),
|
||||
true <- last > start do
|
||||
content
|
||||
|> binary_part(start, last - start + 1)
|
||||
|> decode_strict()
|
||||
else
|
||||
_no_object -> nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,7 @@ defmodule BDS.AI.OneShot do
|
||||
require Logger
|
||||
|
||||
alias BDS.AI.Chat
|
||||
alias BDS.AI.JsonContent
|
||||
alias BDS.AI.OpenAICompatibleRuntime
|
||||
alias BDS.AI.Runtime
|
||||
alias BDS.Media.Media
|
||||
@@ -213,7 +214,9 @@ defmodule BDS.AI.OneShot do
|
||||
messages: [
|
||||
%{
|
||||
"role" => "system",
|
||||
"content" => one_shot_system_prompt(operation, language, source_language)
|
||||
"content" =>
|
||||
one_shot_system_prompt(operation, language, source_language) <>
|
||||
" Output raw JSON only, without markdown code fences."
|
||||
},
|
||||
%{
|
||||
"role" => "user",
|
||||
@@ -351,11 +354,11 @@ defmodule BDS.AI.OneShot do
|
||||
defp extract_json_response(%{json: json}) when is_map(json), do: {:ok, json}
|
||||
|
||||
defp extract_json_response(%{content: content}) when is_binary(content) do
|
||||
case Jason.decode(content) do
|
||||
{:ok, json} when is_map(json) ->
|
||||
case JsonContent.decode(content) do
|
||||
json when is_map(json) ->
|
||||
{:ok, json}
|
||||
|
||||
_other ->
|
||||
nil ->
|
||||
Logger.error(
|
||||
"AI extract_json_response failed to parse content as JSON. Content: #{String.slice(content, 0, 1000)}"
|
||||
)
|
||||
@@ -406,7 +409,9 @@ defmodule BDS.AI.OneShot do
|
||||
title: media.title || "",
|
||||
alt: media.alt || "",
|
||||
caption: media.caption || "",
|
||||
image_url: Map.get(media, :image_url),
|
||||
# A stored media row has no remote URL; resolve_image_data_url/1 fills
|
||||
# this from file_path before an :analyze_image request is built.
|
||||
image_url: nil,
|
||||
file_path: media.file_path,
|
||||
project_id: media.project_id,
|
||||
language: media.language || ""
|
||||
|
||||
@@ -4,6 +4,7 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
||||
require Logger
|
||||
|
||||
alias BDS.AI.HttpClient
|
||||
alias BDS.AI.SSE
|
||||
|
||||
def list_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
|
||||
http_client = Keyword.get(opts, :http_client, HttpClient)
|
||||
@@ -22,7 +23,7 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
||||
end
|
||||
end
|
||||
|
||||
def generate(endpoint, request, _opts) when is_map(endpoint) and is_map(request) do
|
||||
def generate(endpoint, request, opts) when is_map(endpoint) and is_map(request) do
|
||||
url = completions_url(endpoint.url)
|
||||
|
||||
headers =
|
||||
@@ -41,6 +42,14 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
||||
|> maybe_disable_thinking(request.model)
|
||||
|> maybe_put_tools(Map.get(request, :tools, []))
|
||||
|
||||
if stream?(request, opts) do
|
||||
generate_streaming(url, headers, payload, request, Keyword.fetch!(opts, :on_stream))
|
||||
else
|
||||
generate_blocking(url, headers, payload, request)
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_blocking(url, headers, payload, request) do
|
||||
payload_json = Jason.encode!(payload)
|
||||
|
||||
Logger.debug(
|
||||
@@ -81,6 +90,81 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
||||
end
|
||||
end
|
||||
|
||||
# Streaming variant: same request payload plus stream flags; SSE chunks are
|
||||
# folded into a BDS.AI.SSE assembler that emits cumulative content
|
||||
# snapshots to `on_stream` as they arrive. The assembled message goes
|
||||
# through the same normalization as the blocking path.
|
||||
defp generate_streaming(url, headers, payload, request, on_stream) do
|
||||
payload_json =
|
||||
payload
|
||||
|> Map.put("stream", true)
|
||||
|> Map.put("stream_options", %{"include_usage" => true})
|
||||
|> Jason.encode!()
|
||||
|
||||
Logger.debug(
|
||||
"AI OpenAI-compatible streaming request operation=#{inspect(Map.get(request, :operation))} model=#{inspect(request.model)} url=#{url} payload_size=#{byte_size(payload_json)}"
|
||||
)
|
||||
|
||||
sse = SSE.new(on_stream, emit_interval_ms: stream_emit_interval_ms())
|
||||
|
||||
case HttpClient.post_stream(url, headers, payload_json, sse, fn chunk, acc ->
|
||||
SSE.feed(acc, chunk)
|
||||
end) do
|
||||
{:ok, %{status: 200, headers: response_headers}, sse} ->
|
||||
if event_stream?(response_headers) do
|
||||
assembled = SSE.finish(sse)
|
||||
|
||||
{:ok,
|
||||
%{
|
||||
content: assembled.content,
|
||||
json: decode_json_content(assembled.content),
|
||||
tool_calls: normalize_tool_calls(assembled.tool_calls),
|
||||
usage: normalize_usage(assembled.usage || %{})
|
||||
}}
|
||||
else
|
||||
# The provider ignored the stream flag and sent a plain completion.
|
||||
normalize_response(SSE.raw_body(sse))
|
||||
end
|
||||
|
||||
{:ok, %{status: status, body: body}, _sse} ->
|
||||
Logger.error(
|
||||
"AI OpenAI-compatible streaming HTTP error status=#{status} body=#{String.slice(body, 0, 2000)}"
|
||||
)
|
||||
|
||||
{:error, %{kind: :http_error, status: status, body: body}}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("AI OpenAI-compatible streaming request failed: #{inspect(reason)}")
|
||||
{:error, %{kind: :http_error, reason: reason}}
|
||||
end
|
||||
end
|
||||
|
||||
# Streaming is opt-in per request (the caller passes :on_stream), limited
|
||||
# to interactive chat, and can be disabled globally for providers that do
|
||||
# not support SSE (config :bds, :chat, streaming: false).
|
||||
defp stream?(request, opts) do
|
||||
Map.get(request, :operation) == :chat and
|
||||
is_function(Keyword.get(opts, :on_stream), 1) and
|
||||
chat_config(:streaming, true)
|
||||
end
|
||||
|
||||
defp stream_emit_interval_ms, do: chat_config(:stream_emit_interval_ms, 100)
|
||||
|
||||
defp event_stream?(headers) do
|
||||
case headers["content-type"] do
|
||||
content_type when is_binary(content_type) ->
|
||||
String.contains?(content_type, "text/event-stream")
|
||||
|
||||
_missing ->
|
||||
# No content type: trust the request we made and parse as SSE.
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
defp chat_config(key, default) do
|
||||
:bds |> Application.get_env(:chat, []) |> Keyword.get(key, default)
|
||||
end
|
||||
|
||||
defp normalize_response(body) do
|
||||
with {:ok, payload} <- decode_json_body(body) do
|
||||
message = get_in(payload, ["choices", Access.at(0), "message"]) || %{}
|
||||
@@ -88,21 +172,17 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
||||
tool_calls = normalize_tool_calls(message["tool_calls"] || [])
|
||||
usage = normalize_usage(payload["usage"] || %{})
|
||||
|
||||
json =
|
||||
case content do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
value when is_binary(value) ->
|
||||
case Jason.decode(value) do
|
||||
{:ok, decoded} when is_map(decoded) -> decoded
|
||||
_other -> nil
|
||||
{:ok,
|
||||
%{
|
||||
content: content,
|
||||
json: decode_json_content(content),
|
||||
tool_calls: tool_calls,
|
||||
usage: usage
|
||||
}}
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, %{content: content, json: json, tool_calls: tool_calls, usage: usage}}
|
||||
end
|
||||
end
|
||||
defp decode_json_content(content), do: BDS.AI.JsonContent.decode(content)
|
||||
|
||||
defp completions_url(url) do
|
||||
cond do
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
defmodule BDS.AI.SecretBackend do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Encrypts and decrypts AI provider secrets (AES-256-GCM) with the
|
||||
machine-local master key resolved by `BDS.AI.SecretKey`.
|
||||
|
||||
Values written by earlier releases — encrypted with key material that
|
||||
shipped in the repository, or with the deterministic node-name fallback —
|
||||
are still readable: `decrypt/1` falls back to the legacy keys, and
|
||||
`BDS.AI.SecretMigration` re-encrypts such rows at boot. When no master key
|
||||
can be obtained, both operations return `{:error, :secret_key_unavailable}`
|
||||
instead of degrading to a weaker key.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
alias BDS.AI.SecretKey
|
||||
|
||||
@aad "bds-ai-secret"
|
||||
|
||||
# Key material shipped in the repository before TD-01. Retained only so
|
||||
# existing user databases can be read and re-encrypted by
|
||||
# BDS.AI.SecretMigration; remove both together in a future release.
|
||||
@legacy_repo_key binary_part(
|
||||
"bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001",
|
||||
0,
|
||||
32
|
||||
)
|
||||
|
||||
@spec encrypt(String.t()) :: {:ok, String.t()} | {:error, term()}
|
||||
def encrypt(value) when is_binary(value) do
|
||||
key = secret_key()
|
||||
with {:ok, key} <- secret_key() do
|
||||
iv = :crypto.strong_rand_bytes(12)
|
||||
|
||||
{ciphertext, tag} =
|
||||
@@ -12,14 +36,56 @@ defmodule BDS.AI.SecretBackend do
|
||||
|
||||
{:ok, Base.encode64(iv <> tag <> ciphertext)}
|
||||
end
|
||||
end
|
||||
|
||||
@spec decrypt(String.t()) :: {:ok, String.t()} | {:error, term()}
|
||||
def decrypt(encoded) when is_binary(encoded) do
|
||||
with {:ok, key} <- secret_key() do
|
||||
case decrypt_with(encoded, key) do
|
||||
{:ok, plaintext} -> {:ok, plaintext}
|
||||
{:error, :invalid_ciphertext} -> decrypt_legacy(encoded)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Decrypts strictly with the current master key — no legacy fallback. Used by
|
||||
`BDS.AI.SecretMigration` to detect rows that still need re-encryption.
|
||||
"""
|
||||
@spec decrypt_with_current_key(String.t()) :: {:ok, String.t()} | {:error, term()}
|
||||
def decrypt_with_current_key(encoded) when is_binary(encoded) do
|
||||
with {:ok, key} <- secret_key() do
|
||||
decrypt_with(encoded, key)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Attempts decryption with the legacy keys used by earlier releases.
|
||||
"""
|
||||
@spec decrypt_legacy(String.t()) :: {:ok, String.t()} | {:error, :invalid_ciphertext}
|
||||
def decrypt_legacy(encoded) when is_binary(encoded) do
|
||||
Enum.find_value(legacy_keys(), {:error, :invalid_ciphertext}, fn key ->
|
||||
case decrypt_with(encoded, key) do
|
||||
{:ok, plaintext} -> {:ok, plaintext}
|
||||
{:error, :invalid_ciphertext} -> nil
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp legacy_keys do
|
||||
[
|
||||
@legacy_repo_key,
|
||||
:crypto.hash(:sha256, Atom.to_string(node()) <> ":bds:ai")
|
||||
]
|
||||
end
|
||||
|
||||
defp decrypt_with(encoded, key) do
|
||||
with {:ok, binary} <- Base.decode64(encoded),
|
||||
<<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary,
|
||||
plaintext when is_binary(plaintext) <-
|
||||
:crypto.crypto_one_time_aead(
|
||||
:aes_256_gcm,
|
||||
secret_key(),
|
||||
key,
|
||||
iv,
|
||||
ciphertext,
|
||||
@aad,
|
||||
@@ -33,9 +99,13 @@ defmodule BDS.AI.SecretBackend do
|
||||
end
|
||||
|
||||
defp secret_key do
|
||||
case Application.get_env(:bds, :ai_secret_key) do
|
||||
key when is_binary(key) and byte_size(key) >= 32 -> binary_part(key, 0, 32)
|
||||
_other -> :crypto.hash(:sha256, Atom.to_string(node()) <> ":bds:ai")
|
||||
case SecretKey.fetch() do
|
||||
{:ok, key} ->
|
||||
{:ok, key}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("AI secret key unavailable: #{inspect(reason)}")
|
||||
{:error, :secret_key_unavailable}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
233
lib/bds/ai/secret_key.ex
Normal file
233
lib/bds/ai/secret_key.ex
Normal file
@@ -0,0 +1,233 @@
|
||||
defmodule BDS.AI.SecretKey do
|
||||
@moduledoc """
|
||||
Resolves the 32-byte machine-local master key that encrypts AI provider
|
||||
secrets at rest (the `SecureKeyStore` entity in `specs/ai.allium`).
|
||||
|
||||
Resolution order:
|
||||
|
||||
1. `config :bds, :ai_secret_key` — explicit override. Used by the test
|
||||
suite for determinism; must be at least 32 bytes (the first 32 are
|
||||
used). An invalid value is an error, never a silent fallback.
|
||||
2. A previously resolved key cached in `:persistent_term`.
|
||||
3. The OS keyring: on macOS the login Keychain via the `security` CLI; on
|
||||
other platforms — or when the Keychain is unavailable — a random key
|
||||
file under the private app dir, written with `0600` permissions.
|
||||
|
||||
A fresh random key is generated and stored on first use. There is no
|
||||
deterministic fallback: when no key can be obtained or persisted, `fetch/0`
|
||||
returns `{:error, reason}` and secret encryption/decryption fails loudly
|
||||
instead of degrading to obfuscation.
|
||||
|
||||
Config (`config :bds, BDS.AI.SecretKey`):
|
||||
|
||||
* `:strategy` — `:auto` (default; Keychain on macOS, key file elsewhere),
|
||||
`:keychain`, or `:file`
|
||||
* `:key_file_path` — overrides the key file location
|
||||
* `:command_runner` — 3-arity replacement for `System.cmd/3` (tests)
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@key_bytes 32
|
||||
@cache_key {__MODULE__, :key}
|
||||
@keychain_service "bDS2"
|
||||
@keychain_account "ai-secret-key"
|
||||
@keychain_not_found_status 44
|
||||
@key_file_name "ai_secret.key"
|
||||
|
||||
@spec fetch() :: {:ok, binary()} | {:error, term()}
|
||||
def fetch do
|
||||
case configured_key() do
|
||||
{:ok, key} -> {:ok, key}
|
||||
{:error, _detail} = error -> error
|
||||
:unset -> cached_or_resolve()
|
||||
end
|
||||
end
|
||||
|
||||
@doc "Clears the cached key so the next fetch re-resolves it (test helper)."
|
||||
@spec reset_cache() :: :ok
|
||||
def reset_cache do
|
||||
:persistent_term.erase(@cache_key)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp configured_key do
|
||||
case Application.get_env(:bds, :ai_secret_key) do
|
||||
nil ->
|
||||
:unset
|
||||
|
||||
key when is_binary(key) and byte_size(key) >= @key_bytes ->
|
||||
{:ok, binary_part(key, 0, @key_bytes)}
|
||||
|
||||
other ->
|
||||
{:error, {:invalid_configured_key, "expected a binary of at least #{@key_bytes} bytes, got: #{inspect(other)}"}}
|
||||
end
|
||||
end
|
||||
|
||||
defp cached_or_resolve do
|
||||
case :persistent_term.get(@cache_key, nil) do
|
||||
key when is_binary(key) ->
|
||||
{:ok, key}
|
||||
|
||||
nil ->
|
||||
with {:ok, key} <- resolve() do
|
||||
:persistent_term.put(@cache_key, key)
|
||||
{:ok, key}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve do
|
||||
case strategy() do
|
||||
:keychain -> resolve_keychain()
|
||||
:file -> resolve_file()
|
||||
end
|
||||
end
|
||||
|
||||
defp strategy do
|
||||
case config(:strategy, :auto) do
|
||||
:auto ->
|
||||
if match?({:unix, :darwin}, :os.type()), do: :keychain, else: :file
|
||||
|
||||
explicit when explicit in [:keychain, :file] ->
|
||||
explicit
|
||||
end
|
||||
end
|
||||
|
||||
# ─── macOS Keychain ─────────────────────────────────────────
|
||||
|
||||
defp resolve_keychain do
|
||||
case keychain_find() do
|
||||
{:ok, key} ->
|
||||
{:ok, key}
|
||||
|
||||
:not_found ->
|
||||
keychain_create()
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"AI secret key: macOS Keychain unavailable (#{inspect(reason)}); falling back to the key file"
|
||||
)
|
||||
|
||||
resolve_file()
|
||||
end
|
||||
end
|
||||
|
||||
defp keychain_find do
|
||||
case run_security([
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
@keychain_service,
|
||||
"-a",
|
||||
@keychain_account,
|
||||
"-w"
|
||||
]) do
|
||||
{output, 0} ->
|
||||
case output |> String.trim() |> Base.decode64() do
|
||||
{:ok, key} when byte_size(key) == @key_bytes -> {:ok, key}
|
||||
_other -> {:error, :corrupt_keychain_item}
|
||||
end
|
||||
|
||||
{_output, @keychain_not_found_status} ->
|
||||
:not_found
|
||||
|
||||
{output, status} ->
|
||||
{:error, {:security_failed, status, String.trim(output)}}
|
||||
end
|
||||
end
|
||||
|
||||
# The generated key passes through `security`'s argv, which is briefly
|
||||
# visible to other local processes of the same user. Accepted trade-off for
|
||||
# a single-user desktop app; `security` offers no non-interactive way to
|
||||
# take the password on stdin in one shot.
|
||||
defp keychain_create do
|
||||
key = :crypto.strong_rand_bytes(@key_bytes)
|
||||
|
||||
case run_security([
|
||||
"add-generic-password",
|
||||
"-U",
|
||||
"-s",
|
||||
@keychain_service,
|
||||
"-a",
|
||||
@keychain_account,
|
||||
"-w",
|
||||
Base.encode64(key)
|
||||
]) do
|
||||
{_output, 0} ->
|
||||
{:ok, key}
|
||||
|
||||
{output, status} ->
|
||||
Logger.warning(
|
||||
"AI secret key: could not store the key in the Keychain " <>
|
||||
"(status #{status}: #{String.trim(output)}); falling back to the key file"
|
||||
)
|
||||
|
||||
resolve_file()
|
||||
end
|
||||
end
|
||||
|
||||
defp run_security(args) do
|
||||
runner = config(:command_runner, &default_runner/3)
|
||||
runner.("security", args, stderr_to_stdout: true)
|
||||
end
|
||||
|
||||
defp default_runner(command, args, opts) do
|
||||
System.cmd(command, args, opts)
|
||||
rescue
|
||||
error in ErlangError -> {"#{command} unavailable: #{inspect(error.original)}", 127}
|
||||
end
|
||||
|
||||
# ─── Key file ───────────────────────────────────────────────
|
||||
|
||||
defp resolve_file do
|
||||
path = key_file_path()
|
||||
|
||||
case File.read(path) do
|
||||
{:ok, contents} -> decode_key_file(contents, path)
|
||||
{:error, :enoent} -> create_key_file(path)
|
||||
{:error, reason} -> {:error, {:key_file_unreadable, path, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp decode_key_file(contents, path) do
|
||||
case contents |> String.trim() |> Base.decode64() do
|
||||
{:ok, key} when byte_size(key) == @key_bytes -> {:ok, key}
|
||||
_other -> {:error, {:key_file_corrupt, path}}
|
||||
end
|
||||
end
|
||||
|
||||
defp create_key_file(path) do
|
||||
key = :crypto.strong_rand_bytes(@key_bytes)
|
||||
temp_path = path <> ".tmp." <> Integer.to_string(System.unique_integer([:positive]))
|
||||
|
||||
with :ok <- File.mkdir_p(Path.dirname(path)),
|
||||
:ok <- File.write(temp_path, Base.encode64(key) <> "\n"),
|
||||
:ok <- File.chmod(temp_path, 0o600),
|
||||
:ok <- File.rename(temp_path, path) do
|
||||
{:ok, key}
|
||||
else
|
||||
{:error, reason} ->
|
||||
_ = File.rm(temp_path)
|
||||
{:error, {:key_file_write_failed, path, reason}}
|
||||
end
|
||||
end
|
||||
|
||||
defp key_file_path do
|
||||
config(:key_file_path, nil) || Path.join(private_app_dir(), @key_file_name)
|
||||
end
|
||||
|
||||
# Same private app dir as BDS.Projects.private_app_dir/0 — on macOS
|
||||
# ~/Library/Application Support/BDS2. Duplicated to keep this module free of
|
||||
# project/DB dependencies.
|
||||
defp private_app_dir do
|
||||
case :filename.basedir(:user_config, "BDS2") do
|
||||
path when is_list(path) -> List.to_string(path)
|
||||
path -> path
|
||||
end
|
||||
|> Path.expand()
|
||||
end
|
||||
|
||||
defp config(key, default) do
|
||||
Application.get_env(:bds, __MODULE__, []) |> Keyword.get(key, default)
|
||||
end
|
||||
end
|
||||
67
lib/bds/ai/secret_migration.ex
Normal file
67
lib/bds/ai/secret_migration.ex
Normal file
@@ -0,0 +1,67 @@
|
||||
defmodule BDS.AI.SecretMigration do
|
||||
@moduledoc """
|
||||
Idempotent boot-time re-encryption of stored AI secrets.
|
||||
|
||||
Earlier releases encrypted secrets with key material shipped in the
|
||||
repository (or a deterministic node-name fallback). This pass finds the
|
||||
`__encrypted_*` rows in `settings`, decrypts them with the legacy keys, and
|
||||
re-encrypts them with the machine-local key from `BDS.AI.SecretKey`. Rows
|
||||
already encrypted with the current key are left untouched; rows no known
|
||||
key can decrypt are left in place and reported, so the user can re-enter
|
||||
the secret. Runs from `BDS.RepoBootstrap` on every boot; on a migrated
|
||||
database it is a cheap no-op.
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
require Logger
|
||||
|
||||
alias BDS.AI.SecretBackend
|
||||
alias BDS.Persistence
|
||||
alias BDS.Repo
|
||||
alias BDS.Settings.Setting
|
||||
|
||||
@encrypted_prefix "__encrypted_"
|
||||
|
||||
@spec migrate_legacy_secrets(module()) ::
|
||||
{:ok, %{migrated: non_neg_integer(), failed: non_neg_integer()}}
|
||||
def migrate_legacy_secrets(repo \\ Repo) do
|
||||
summary =
|
||||
from(setting in Setting, where: like(setting.key, ^"#{@encrypted_prefix}%"))
|
||||
|> repo.all()
|
||||
|> Enum.reduce(%{migrated: 0, failed: 0}, fn setting, acc ->
|
||||
case migrate_row(repo, setting) do
|
||||
:current -> acc
|
||||
:migrated -> %{acc | migrated: acc.migrated + 1}
|
||||
:failed -> %{acc | failed: acc.failed + 1}
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, summary}
|
||||
end
|
||||
|
||||
defp migrate_row(repo, setting) do
|
||||
with {:error, _no_current_key_match} <- SecretBackend.decrypt_with_current_key(setting.value),
|
||||
{:ok, plaintext} <- SecretBackend.decrypt_legacy(setting.value),
|
||||
{:ok, reencrypted} <- SecretBackend.encrypt(plaintext) do
|
||||
repo.update_all(
|
||||
from(s in Setting, where: s.key == ^setting.key),
|
||||
set: [value: reencrypted, updated_at: Persistence.now_ms()]
|
||||
)
|
||||
|
||||
Logger.info("AI secret #{setting.key} re-encrypted with the machine-local key")
|
||||
:migrated
|
||||
else
|
||||
{:ok, _already_current} ->
|
||||
:current
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"AI secret #{setting.key} could not be re-encrypted (#{inspect(reason)}); " <>
|
||||
"leaving it unchanged — the secret may need to be entered again"
|
||||
)
|
||||
|
||||
:failed
|
||||
end
|
||||
end
|
||||
end
|
||||
176
lib/bds/ai/sse.ex
Normal file
176
lib/bds/ai/sse.ex
Normal file
@@ -0,0 +1,176 @@
|
||||
defmodule BDS.AI.SSE do
|
||||
@moduledoc """
|
||||
Incremental assembler for OpenAI-compatible `text/event-stream` chat
|
||||
completions.
|
||||
|
||||
Fed raw transport chunks via `feed/2`, it buffers partial events, decodes
|
||||
`data:` payloads, and accumulates content deltas, tool-call fragments, and
|
||||
usage. Content is reported to the optional `on_event` callback as
|
||||
**cumulative snapshots** (`%{content: binary}`) — replace semantics, which
|
||||
matches how the chat editor renders streaming state and resets naturally
|
||||
between tool rounds. Emissions are throttled to `:emit_interval_ms`
|
||||
(the first delta always emits immediately for perceived latency).
|
||||
|
||||
`finish/1` returns the assembled message in OpenAI wire shape so the
|
||||
runtime can reuse its non-streaming normalization:
|
||||
`%{content: binary | nil, tool_calls: [%{"id" => _, "function" => %{"name" => _, "arguments" => json_string}}], usage: map | nil}`.
|
||||
"""
|
||||
|
||||
defstruct buffer: "",
|
||||
raw: [],
|
||||
content: [],
|
||||
content?: false,
|
||||
tool_calls: %{},
|
||||
usage: nil,
|
||||
done?: false,
|
||||
on_event: nil,
|
||||
emit_interval_ms: 100,
|
||||
last_emit_at: nil
|
||||
|
||||
@type t :: %__MODULE__{}
|
||||
|
||||
@spec new((map() -> any()) | nil, keyword()) :: t()
|
||||
def new(on_event \\ nil, opts \\ []) when is_list(opts) do
|
||||
%__MODULE__{
|
||||
on_event: on_event,
|
||||
emit_interval_ms: Keyword.get(opts, :emit_interval_ms, 100)
|
||||
}
|
||||
end
|
||||
|
||||
@spec feed(t(), binary()) :: t()
|
||||
def feed(%__MODULE__{done?: true} = sse, _chunk), do: sse
|
||||
|
||||
def feed(%__MODULE__{} = sse, chunk) when is_binary(chunk) do
|
||||
sse = %{sse | raw: [chunk | sse.raw]}
|
||||
parts = String.split(sse.buffer <> chunk, ~r/\r?\n\r?\n/)
|
||||
{complete_events, [rest]} = Enum.split(parts, -1)
|
||||
|
||||
Enum.reduce(complete_events, %{sse | buffer: rest}, &process_event(&2, &1))
|
||||
end
|
||||
|
||||
@doc """
|
||||
The unparsed transport bytes, for callers that discover after the fact
|
||||
that the response was not an event stream (e.g. a provider that ignored
|
||||
the `stream` flag and answered with plain JSON).
|
||||
"""
|
||||
@spec raw_body(t()) :: binary()
|
||||
def raw_body(%__MODULE__{} = sse) do
|
||||
sse.raw |> Enum.reverse() |> IO.iodata_to_binary()
|
||||
end
|
||||
|
||||
@spec finish(t()) :: %{content: binary() | nil, tool_calls: [map()], usage: map() | nil}
|
||||
def finish(%__MODULE__{} = sse) do
|
||||
# A final event may arrive without its trailing blank line.
|
||||
sse =
|
||||
case String.trim(sse.buffer) do
|
||||
"" -> sse
|
||||
remnant -> process_event(%{sse | buffer: ""}, remnant)
|
||||
end
|
||||
|
||||
%{
|
||||
content: assembled_content(sse),
|
||||
tool_calls: assembled_tool_calls(sse),
|
||||
usage: sse.usage
|
||||
}
|
||||
end
|
||||
|
||||
defp process_event(%{done?: true} = sse, _event), do: sse
|
||||
|
||||
defp process_event(sse, event) do
|
||||
data =
|
||||
event
|
||||
|> String.split(~r/\r?\n/)
|
||||
|> Enum.flat_map(&data_line/1)
|
||||
|> Enum.join("\n")
|
||||
|
||||
cond do
|
||||
data == "" ->
|
||||
sse
|
||||
|
||||
String.trim(data) == "[DONE]" ->
|
||||
%{sse | done?: true}
|
||||
|
||||
true ->
|
||||
case Jason.decode(data) do
|
||||
{:ok, payload} when is_map(payload) -> apply_payload(sse, payload)
|
||||
_other -> sse
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp data_line("data: " <> rest), do: [rest]
|
||||
defp data_line("data:" <> rest), do: [rest]
|
||||
defp data_line(_line), do: []
|
||||
|
||||
defp apply_payload(sse, payload) do
|
||||
delta = get_in(payload, ["choices", Access.at(0), "delta"]) || %{}
|
||||
|
||||
sse
|
||||
|> apply_content(delta["content"])
|
||||
|> apply_tool_calls(delta["tool_calls"])
|
||||
|> apply_usage(payload["usage"])
|
||||
end
|
||||
|
||||
defp apply_content(sse, content) when is_binary(content) and content != "" do
|
||||
%{sse | content: [content | sse.content], content?: true}
|
||||
|> maybe_emit()
|
||||
end
|
||||
|
||||
defp apply_content(sse, _content), do: sse
|
||||
|
||||
defp apply_tool_calls(sse, [_ | _] = fragments) do
|
||||
Enum.reduce(fragments, sse, fn fragment, acc ->
|
||||
index = fragment["index"] || 0
|
||||
existing = Map.get(acc.tool_calls, index, %{id: nil, name: nil, arguments: []})
|
||||
function_part = fragment["function"] || %{}
|
||||
|
||||
merged = %{
|
||||
id: existing.id || fragment["id"],
|
||||
name: existing.name || function_part["name"],
|
||||
arguments: [existing.arguments, function_part["arguments"] || ""]
|
||||
}
|
||||
|
||||
%{acc | tool_calls: Map.put(acc.tool_calls, index, merged)}
|
||||
end)
|
||||
end
|
||||
|
||||
defp apply_tool_calls(sse, _fragments), do: sse
|
||||
|
||||
defp apply_usage(sse, usage) when is_map(usage) and map_size(usage) > 0,
|
||||
do: %{sse | usage: usage}
|
||||
|
||||
defp apply_usage(sse, _usage), do: sse
|
||||
|
||||
defp maybe_emit(%{on_event: nil} = sse), do: sse
|
||||
|
||||
defp maybe_emit(sse) do
|
||||
now = System.monotonic_time(:millisecond)
|
||||
|
||||
if is_nil(sse.last_emit_at) or now - sse.last_emit_at >= sse.emit_interval_ms do
|
||||
sse.on_event.(%{content: assembled_content(sse) || ""})
|
||||
%{sse | last_emit_at: now}
|
||||
else
|
||||
sse
|
||||
end
|
||||
end
|
||||
|
||||
defp assembled_content(%{content?: false}), do: nil
|
||||
|
||||
defp assembled_content(sse) do
|
||||
sse.content |> Enum.reverse() |> IO.iodata_to_binary()
|
||||
end
|
||||
|
||||
defp assembled_tool_calls(sse) do
|
||||
sse.tool_calls
|
||||
|> Enum.sort_by(fn {index, _tool_call} -> index end)
|
||||
|> Enum.map(fn {_index, tool_call} ->
|
||||
%{
|
||||
"id" => tool_call.id,
|
||||
"function" => %{
|
||||
"name" => tool_call.name,
|
||||
"arguments" => IO.iodata_to_binary(tool_call.arguments)
|
||||
}
|
||||
}
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -25,26 +25,38 @@ defmodule BDS.Application do
|
||||
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
children =
|
||||
[
|
||||
{Phoenix.PubSub, name: BDS.PubSub},
|
||||
{BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()},
|
||||
BDS.Repo,
|
||||
BDS.RepoBootstrap,
|
||||
BDS.Tasks,
|
||||
BDS.AI.InFlight,
|
||||
BDS.Preview,
|
||||
BDS.Publishing,
|
||||
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
|
||||
{Task.Supervisor, name: BDS.TCP.TaskSupervisor},
|
||||
BDS.Scripting.JobStore,
|
||||
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
||||
BDS.Scripting.JobSupervisor
|
||||
| desktop_children(current_env())
|
||||
]
|
||||
BDS.Scripting.JobSupervisor,
|
||||
BDS.Embeddings.Index
|
||||
] ++ embedding_children() ++ desktop_children(current_env())
|
||||
|
||||
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
||||
Supervisor.start_link(children, opts)
|
||||
end
|
||||
|
||||
# The neural embedding backend runs as a supervised, lazily-initialised
|
||||
# GenServer (it loads the model only on the first embedding request). Only
|
||||
# start it when it is the configured backend.
|
||||
defp embedding_children do
|
||||
case Application.get_env(:bds, :embeddings, [])[:backend] do
|
||||
BDS.Embeddings.Backends.Neural -> [BDS.Embeddings.Backends.Neural]
|
||||
_other -> []
|
||||
end
|
||||
end
|
||||
|
||||
defp current_env do
|
||||
Application.get_env(:bds, :current_env_override) || @compiled_env
|
||||
end
|
||||
@@ -62,7 +74,8 @@ defmodule BDS.Application do
|
||||
|
||||
[
|
||||
{Desktop.Window, window_opts},
|
||||
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher)
|
||||
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher),
|
||||
{BDS.Desktop.DeepLink, []}
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -71,8 +84,11 @@ defmodule BDS.Application do
|
||||
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
|
||||
end
|
||||
|
||||
# The desktop endpoint binds to loopback only and its sessions do not need
|
||||
# to survive restarts, so without explicit config the signing secret is a
|
||||
# random per-boot value rather than a static one shipped in the repo.
|
||||
defp desktop_secret_key_base do
|
||||
Application.get_env(:bds, :desktop)[:secret_key_base] ||
|
||||
raise "missing :desktop secret_key_base configuration"
|
||||
Base.encode64(:crypto.strong_rand_bytes(48))
|
||||
end
|
||||
end
|
||||
|
||||
150
lib/bds/blogmark.ex
Normal file
150
lib/bds/blogmark.ex
Normal file
@@ -0,0 +1,150 @@
|
||||
defmodule BDS.Blogmark do
|
||||
@moduledoc """
|
||||
Receives `bds2://new-post` blogmark deep links and turns them into draft posts
|
||||
(spec: script.allium `BlogmarkReceived`/`ExecuteTransform`,
|
||||
editor_settings.allium `BookmarkletCopy`).
|
||||
|
||||
The browser bookmarklet (`BDS.Scripting.Capabilities.AppShell`) navigates to a
|
||||
`bds2://new-post?title=&url=` URL. The desktop layer hands that URL here, where
|
||||
it is parsed into a post candidate, run through the enabled transform pipeline
|
||||
(`BDS.Scripts.Transforms`), and finally persisted as a draft post — defaulting
|
||||
the category from the project's `blogmark_category` setting when neither the
|
||||
link nor a transform supplied one.
|
||||
|
||||
The `bds2://` scheme deliberately differs from the legacy app's `bds://` scheme
|
||||
so the two installs do not fight over the same registration.
|
||||
"""
|
||||
|
||||
alias BDS.Metadata
|
||||
alias BDS.Posts
|
||||
alias BDS.Scripts.Transforms
|
||||
|
||||
@scheme "bds2"
|
||||
@new_post_action "new-post"
|
||||
|
||||
@type candidate :: %{required(String.t()) => term()}
|
||||
|
||||
@type receive_result :: %{
|
||||
post: Posts.Post.t(),
|
||||
toasts: [String.t()],
|
||||
errors: [%{slug: String.t() | nil, reason: term()}]
|
||||
}
|
||||
|
||||
@doc """
|
||||
Parses a `bds2://new-post` deep link into a post candidate map.
|
||||
|
||||
Returns `{:ok, candidate}` with string-keyed `title`, `url`, `content`,
|
||||
`tags` and `categories`, or an error when the scheme or action is unsupported.
|
||||
"""
|
||||
@spec parse_deep_link(String.t()) ::
|
||||
{:ok, candidate()} | {:error, :unsupported_scheme | :unsupported_action | :invalid_url}
|
||||
def parse_deep_link(url) when is_binary(url) do
|
||||
case URI.parse(url) do
|
||||
%URI{scheme: @scheme, host: @new_post_action, query: query} ->
|
||||
{:ok, candidate_from_query(query)}
|
||||
|
||||
%URI{scheme: @scheme} ->
|
||||
{:error, :unsupported_action}
|
||||
|
||||
%URI{scheme: nil} ->
|
||||
{:error, :invalid_url}
|
||||
|
||||
%URI{} ->
|
||||
{:error, :unsupported_scheme}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Receives a blogmark deep link for `project_id`: parses it, runs the transform
|
||||
pipeline, and creates a draft post from the resulting candidate.
|
||||
|
||||
Returns `{:ok, %{post:, toasts:, errors:}}` where `toasts` are the
|
||||
budget-enforced transform messages and `errors` records any failed transforms.
|
||||
"""
|
||||
@spec receive_deep_link(String.t(), String.t(), keyword()) ::
|
||||
{:ok, receive_result()} | {:error, term()}
|
||||
def receive_deep_link(project_id, url, opts \\ [])
|
||||
when is_binary(project_id) and is_binary(url) and is_list(opts) do
|
||||
with {:ok, candidate} <- parse_deep_link(url),
|
||||
{:ok, %{data: data, toasts: toasts, errors: errors}} <-
|
||||
Transforms.run(project_id, candidate, opts),
|
||||
data <- apply_default_category(project_id, data),
|
||||
{:ok, post} <- create_draft(project_id, data) do
|
||||
{:ok, %{post: post, toasts: toasts, errors: errors}}
|
||||
end
|
||||
end
|
||||
|
||||
defp candidate_from_query(query) do
|
||||
params = URI.decode_query(query || "")
|
||||
|
||||
%{
|
||||
"title" => Map.get(params, "title", "") |> to_string(),
|
||||
"url" => optional(params, "url"),
|
||||
"content" => optional(params, "content"),
|
||||
"tags" => list_param(params, "tags"),
|
||||
"categories" => list_param(params, "categories")
|
||||
}
|
||||
end
|
||||
|
||||
defp optional(params, key) do
|
||||
case Map.get(params, key) do
|
||||
nil -> nil
|
||||
"" -> nil
|
||||
value -> value
|
||||
end
|
||||
end
|
||||
|
||||
defp list_param(params, key) do
|
||||
case Map.get(params, key) do
|
||||
value when is_binary(value) ->
|
||||
value
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|
||||
_ ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
# Apply the project default category only when neither the deep link nor a
|
||||
# transform produced one, so explicit categories always win.
|
||||
defp apply_default_category(project_id, data) do
|
||||
case Map.get(data, "categories") do
|
||||
categories when is_list(categories) and categories != [] ->
|
||||
data
|
||||
|
||||
_ ->
|
||||
case default_category(project_id) do
|
||||
nil -> data
|
||||
category -> Map.put(data, "categories", [category])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp default_category(project_id) do
|
||||
case Metadata.get_project_metadata(project_id) do
|
||||
{:ok, %{blogmark_category: category}} when is_binary(category) and category != "" ->
|
||||
category
|
||||
|
||||
_ ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
defp create_draft(project_id, data) do
|
||||
Posts.create_post(%{
|
||||
project_id: project_id,
|
||||
title: Map.get(data, "title", ""),
|
||||
content: optional_string(Map.get(data, "content")),
|
||||
tags: string_list(Map.get(data, "tags")),
|
||||
categories: string_list(Map.get(data, "categories"))
|
||||
})
|
||||
end
|
||||
|
||||
defp optional_string(value) when is_binary(value), do: value
|
||||
defp optional_string(_value), do: nil
|
||||
|
||||
defp string_list(list) when is_list(list), do: Enum.filter(list, &is_binary/1)
|
||||
defp string_list(_other), do: []
|
||||
end
|
||||
@@ -1,5 +1,8 @@
|
||||
defmodule BDS.BoundedAtoms do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Safe conversion of dynamic values to atoms from pre-defined allow-lists,
|
||||
preventing atom table exhaustion from untrusted input.
|
||||
"""
|
||||
|
||||
alias BDS.UI.Registry
|
||||
alias BDS.UI.MenuBar
|
||||
|
||||
@@ -54,6 +54,11 @@ defmodule BDS.CliSync do
|
||||
end)}
|
||||
end
|
||||
|
||||
def data_version do
|
||||
%{rows: [[version]]} = Repo.query!("PRAGMA data_version", [])
|
||||
version
|
||||
end
|
||||
|
||||
def prune_notifications(now \\ Persistence.now_ms()) when is_integer(now) do
|
||||
{processed_count, _} =
|
||||
Repo.delete_all(
|
||||
|
||||
@@ -29,7 +29,12 @@ defmodule BDS.CliSync.Watcher do
|
||||
Keyword.get(opts, :poll_interval_ms),
|
||||
@default_poll_interval_ms
|
||||
),
|
||||
pubsub: Keyword.get(opts, :pubsub, BDS.PubSub)
|
||||
pubsub: Keyword.get(opts, :pubsub, BDS.PubSub),
|
||||
data_version_reader: Keyword.get(opts, :data_version_reader, &CliSync.data_version/0),
|
||||
notification_fetcher:
|
||||
Keyword.get(opts, :notification_fetcher, &CliSync.db_file_change_detected/0),
|
||||
pruner: Keyword.get(opts, :pruner, &CliSync.prune_notifications/0),
|
||||
last_data_version: nil
|
||||
}
|
||||
|
||||
{:ok, schedule_poll(state)}
|
||||
@@ -49,8 +54,13 @@ defmodule BDS.CliSync.Watcher do
|
||||
end
|
||||
|
||||
defp process_notifications(state) do
|
||||
{:ok, notifications} = CliSync.db_file_change_detected()
|
||||
{:ok, _pruned} = CliSync.prune_notifications()
|
||||
current_data_version = state.data_version_reader.()
|
||||
|
||||
if state.last_data_version == current_data_version do
|
||||
%{state | last_data_version: current_data_version}
|
||||
else
|
||||
{:ok, notifications} = state.notification_fetcher.()
|
||||
{:ok, _pruned} = state.pruner.()
|
||||
|
||||
Enum.each(notifications, fn notification ->
|
||||
Phoenix.PubSub.broadcast(
|
||||
@@ -60,7 +70,8 @@ defmodule BDS.CliSync.Watcher do
|
||||
)
|
||||
end)
|
||||
|
||||
state
|
||||
%{state | last_data_version: current_data_version}
|
||||
end
|
||||
end
|
||||
|
||||
defp notification_payload(notification) do
|
||||
|
||||
@@ -58,7 +58,6 @@ defmodule BDS.Desktop.Automation do
|
||||
base_url = BDS.Desktop.url(port)
|
||||
|
||||
File.mkdir_p!(screenshot_dir)
|
||||
ensure_http_client_started()
|
||||
|
||||
app_port = start_app_process(project_root, port)
|
||||
:ok = wait_for_server(base_url)
|
||||
@@ -319,8 +318,14 @@ defmodule BDS.Desktop.Automation do
|
||||
end
|
||||
|
||||
defp do_wait_for_server(base_url, deadline) do
|
||||
case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do
|
||||
{:ok, {{_, 200, _}, _headers, _body}} ->
|
||||
case Req.request(
|
||||
method: :get,
|
||||
url: base_url <> "health",
|
||||
retry: false,
|
||||
connect_options: [timeout: 1_000],
|
||||
receive_timeout: 1_000
|
||||
) do
|
||||
{:ok, %Req.Response{status: 200}} ->
|
||||
:ok
|
||||
|
||||
_other ->
|
||||
@@ -340,12 +345,6 @@ defmodule BDS.Desktop.Automation do
|
||||
port
|
||||
end
|
||||
|
||||
defp ensure_http_client_started do
|
||||
_ = Application.ensure_all_started(:inets)
|
||||
_ = Application.ensure_all_started(:ssl)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp await_port_exit(nil, _timeout), do: :ok
|
||||
|
||||
defp await_port_exit(port, timeout) do
|
||||
|
||||
75
lib/bds/desktop/deep_link.ex
Normal file
75
lib/bds/desktop/deep_link.ex
Normal file
@@ -0,0 +1,75 @@
|
||||
defmodule BDS.Desktop.DeepLink do
|
||||
@moduledoc """
|
||||
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
|
||||
shell (spec: script.allium `BlogmarkReceived`).
|
||||
|
||||
On macOS the `BDS2.app` bundle registers `bds2://` as a custom URL scheme via
|
||||
the `CFBundleURLTypes` entry in its `Info.plist` (built by `BDS.MacBundle` /
|
||||
`mix bds.bundle.macos`). When the browser bookmarklet navigates to
|
||||
`bds2://new-post?title=&url=`, the OS launches/raises the app and `Desktop.Env`
|
||||
delivers an `{:open_url, [url]}` event. This GenServer subscribes to those
|
||||
events and forwards recognised `bds2://` links to the live shell over PubSub,
|
||||
where `BDS.Blogmark` turns them into draft posts.
|
||||
|
||||
The `bds2://` scheme is distinct from the legacy app's `bds://` so the two
|
||||
installs do not contend for the same registration.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
require Logger
|
||||
|
||||
alias BDS.CliSync.Watcher
|
||||
|
||||
@scheme "bds2://"
|
||||
|
||||
def child_spec(opts) do
|
||||
%{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}}
|
||||
end
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
|
||||
end
|
||||
|
||||
@impl true
|
||||
def init(opts) do
|
||||
pubsub = Keyword.get(opts, :pubsub, BDS.PubSub)
|
||||
topic = Keyword.get(opts, :topic, Watcher.topic())
|
||||
|
||||
subscribe_to_env()
|
||||
|
||||
{:ok, %{pubsub: pubsub, topic: topic}}
|
||||
end
|
||||
|
||||
# Desktop.Env delivers OS events as {event_name, args} tuples.
|
||||
@impl true
|
||||
def handle_info({:open_url, [url | _rest]}, state) when is_binary(url) do
|
||||
{:noreply, route(url, state)}
|
||||
end
|
||||
|
||||
def handle_info(_message, state), do: {:noreply, state}
|
||||
|
||||
defp route(url, state) do
|
||||
if String.starts_with?(url, @scheme) do
|
||||
Phoenix.PubSub.broadcast(state.pubsub, state.topic, {:blogmark_deep_link, url})
|
||||
else
|
||||
Logger.debug("ignoring non-bds2 deep link: #{inspect(url)}")
|
||||
end
|
||||
|
||||
state
|
||||
end
|
||||
|
||||
# Desktop.Env is only present when the wx desktop adapter is running. Guard the
|
||||
# subscribe so the GenServer can still start in headless/test configurations.
|
||||
defp subscribe_to_env do
|
||||
if Process.whereis(Desktop.Env) do
|
||||
try do
|
||||
Desktop.Env.subscribe()
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
end
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
@@ -12,6 +12,17 @@ defmodule BDS.Desktop.FilePicker do
|
||||
end
|
||||
end
|
||||
|
||||
def choose_files(prompt, opts \\ []) when is_binary(prompt) do
|
||||
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
|
||||
:cancel
|
||||
else
|
||||
case :os.type() do
|
||||
{:unix, :darwin} -> choose_files_macos(prompt, opts)
|
||||
_other -> {:error, %{message: "File selection is only supported on macOS desktop"}}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp choose_file_macos(prompt) do
|
||||
script = "POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\")"
|
||||
|
||||
@@ -21,6 +32,50 @@ defmodule BDS.Desktop.FilePicker do
|
||||
end
|
||||
end
|
||||
|
||||
defp choose_files_macos(prompt, opts) do
|
||||
multiple = Keyword.get(opts, :multiple, false)
|
||||
image_only = Keyword.get(opts, :image_only, false)
|
||||
|
||||
script_parts = ["POSIX path of (choose file with prompt \"#{escape_applescript(prompt)}\""]
|
||||
|
||||
script_parts =
|
||||
if image_only do
|
||||
script_parts ++ [" of type {\"public.image\"}"]
|
||||
else
|
||||
script_parts
|
||||
end
|
||||
|
||||
script_parts =
|
||||
if multiple do
|
||||
script_parts ++ [" with multiple selections allowed"]
|
||||
else
|
||||
script_parts
|
||||
end
|
||||
|
||||
script = Enum.join(script_parts, "") <> ")"
|
||||
|
||||
case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do
|
||||
{output, 0} -> parse_choose_files_result(String.trim(output), multiple)
|
||||
{output, _status} -> normalize_picker_failure(output)
|
||||
end
|
||||
end
|
||||
|
||||
@doc false
|
||||
def parse_choose_files_result(output, true = _multiple) do
|
||||
paths =
|
||||
output
|
||||
|> String.split("\n")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.reject(&(&1 == ""))
|
||||
|
||||
{:ok, paths}
|
||||
end
|
||||
|
||||
@doc false
|
||||
def parse_choose_files_result(output, false = _multiple) do
|
||||
{:ok, output}
|
||||
end
|
||||
|
||||
defp normalize_picker_failure(output) do
|
||||
message = String.trim(output)
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ defmodule BDS.Desktop.MainWindow do
|
||||
end
|
||||
|
||||
defp config_dir do
|
||||
case :filename.basedir(:user_config, "bds") do
|
||||
case :filename.basedir(:user_config, "BDS2") do
|
||||
path when is_list(path) -> List.to_string(path)
|
||||
path -> path
|
||||
end
|
||||
|
||||
@@ -35,7 +35,8 @@ defmodule BDS.Desktop.Overlay do
|
||||
title: Map.get(context, :insert_media_title, "Insert Media"),
|
||||
search_query: "",
|
||||
results: Enum.map(media, &to_insert_media_result/1),
|
||||
all_media: media
|
||||
all_media: media,
|
||||
post_id: current_id(context)
|
||||
}
|
||||
end
|
||||
|
||||
@@ -48,29 +49,32 @@ defmodule BDS.Desktop.Overlay do
|
||||
end
|
||||
|
||||
def open(:media, :confirm_delete, context) do
|
||||
delete_details = Map.get(context, :delete_details, %{})
|
||||
%{
|
||||
title: title,
|
||||
entity_name: entity_name,
|
||||
entity_type: entity_type,
|
||||
reference_list: reference_list
|
||||
} = context.delete_details
|
||||
|
||||
%{
|
||||
kind: :confirm_delete,
|
||||
title: Map.get(delete_details, :title, "Delete"),
|
||||
entity_name: Map.get(delete_details, :entity_name, ""),
|
||||
entity_type: Map.get(delete_details, :entity_type, "media"),
|
||||
reference_count: length(Map.get(delete_details, :reference_list, [])),
|
||||
reference_list: Map.get(delete_details, :reference_list, [])
|
||||
title: title,
|
||||
entity_name: entity_name,
|
||||
entity_type: entity_type,
|
||||
reference_count: length(reference_list),
|
||||
reference_list: reference_list
|
||||
}
|
||||
end
|
||||
|
||||
def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context)
|
||||
|
||||
def open(:tags, :confirm_merge, context) do
|
||||
merge = Map.get(context, :merge_details, %{})
|
||||
target = Map.get(merge, :target, "")
|
||||
count = Map.get(merge, :count, 0)
|
||||
%{title: title, message: message} = context.merge_details
|
||||
|
||||
%{
|
||||
kind: :confirm_dialog,
|
||||
title: Map.get(merge, :title, "Merge #{count} tags into #{target}?"),
|
||||
message: Map.get(merge, :message, "Cannot be undone.")
|
||||
title: title,
|
||||
message: message
|
||||
}
|
||||
end
|
||||
|
||||
@@ -115,8 +119,8 @@ defmodule BDS.Desktop.Overlay do
|
||||
|> Map.get(:all_media, [])
|
||||
|> Enum.filter(fn media ->
|
||||
normalized == "" or
|
||||
search_matches?(Map.get(media, :title, ""), normalized) or
|
||||
search_matches?(Map.get(media, :original_name, ""), normalized)
|
||||
search_matches?(media.title, normalized) or
|
||||
search_matches?(media.original_name, normalized)
|
||||
end)
|
||||
|> Enum.map(&to_insert_media_result/1)
|
||||
|
||||
@@ -203,18 +207,22 @@ defmodule BDS.Desktop.Overlay do
|
||||
def insert_media_result(_overlay, _media_id), do: nil
|
||||
|
||||
defp language_picker(context, source_language) do
|
||||
existing_translations = Map.get(context, :existing_translations, %{})
|
||||
language_names = Map.get(context, :language_names, %{})
|
||||
language_flags = Map.get(context, :language_flags, %{})
|
||||
|
||||
targets =
|
||||
context
|
||||
|> Map.get(:blog_languages, [])
|
||||
|> Enum.uniq()
|
||||
|> Enum.reject(&(&1 == source_language))
|
||||
|> Enum.map(fn code ->
|
||||
existing_status = Map.get(Map.get(context, :existing_translations, %{}), code)
|
||||
existing_status = Map.get(existing_translations, code)
|
||||
|
||||
%{
|
||||
code: code,
|
||||
name: Map.get(Map.get(context, :language_names, %{}), code, String.upcase(code)),
|
||||
flag_emoji: Map.get(Map.get(context, :language_flags, %{}), code, code),
|
||||
name: Map.get(language_names, code, String.upcase(code)),
|
||||
flag_emoji: Map.get(language_flags, code, code),
|
||||
has_existing_translation: not is_nil(existing_status),
|
||||
existing_status: existing_status
|
||||
}
|
||||
@@ -255,14 +263,20 @@ defmodule BDS.Desktop.Overlay do
|
||||
def set_ai_suggestions_error(overlay, _error_message), do: overlay
|
||||
|
||||
defp normalize_ai_fields(fields) do
|
||||
Enum.map(fields, fn field ->
|
||||
Enum.map(fields, fn %{
|
||||
key: key,
|
||||
label: label,
|
||||
current_value: current,
|
||||
suggested_value: suggested,
|
||||
locked: locked
|
||||
} = field ->
|
||||
%{
|
||||
key: to_string(Map.get(field, :key, "")),
|
||||
label: Map.get(field, :label, ""),
|
||||
current_value: Map.get(field, :current_value, ""),
|
||||
suggested_value: Map.get(field, :suggested_value, ""),
|
||||
accepted: not Map.get(field, :locked, false),
|
||||
locked: Map.get(field, :locked, false),
|
||||
key: to_string(key),
|
||||
label: label,
|
||||
current_value: current,
|
||||
suggested_value: suggested,
|
||||
accepted: not locked,
|
||||
locked: locked,
|
||||
loading: Map.get(field, :loading, false)
|
||||
}
|
||||
end)
|
||||
@@ -276,7 +290,7 @@ defmodule BDS.Desktop.Overlay do
|
||||
end
|
||||
|
||||
defp gallery_images(context) do
|
||||
images = Enum.filter(Map.get(context, :media, []), &Map.get(&1, :is_image, false))
|
||||
images = Enum.filter(Map.get(context, :media, []), & &1.is_image)
|
||||
post_media_ids = Map.get(context, :post_media_ids, [])
|
||||
|
||||
case Enum.filter(images, &(&1.id in post_media_ids)) do
|
||||
@@ -289,29 +303,29 @@ defmodule BDS.Desktop.Overlay do
|
||||
%{
|
||||
post_id: post.id,
|
||||
title: post.title,
|
||||
status: to_string(Map.get(post, :status, "draft")),
|
||||
canonical_url: Map.get(post, :canonical_url, "/posts/#{post.id}"),
|
||||
similarity_score: Map.get(post, :similarity_score)
|
||||
status: post.status,
|
||||
canonical_url: post.canonical_url,
|
||||
similarity_score: nil
|
||||
}
|
||||
end
|
||||
|
||||
defp to_insert_media_result(media) do
|
||||
%{
|
||||
media_id: media.id,
|
||||
title: Map.get(media, :title, ""),
|
||||
original_name: Map.get(media, :original_name, media.id),
|
||||
is_image: Map.get(media, :is_image, false),
|
||||
thumbnail_url: Map.get(media, :thumbnail_url)
|
||||
title: media.title,
|
||||
original_name: media.original_name,
|
||||
is_image: media.is_image,
|
||||
thumbnail_url: media.thumbnail_url
|
||||
}
|
||||
end
|
||||
|
||||
defp to_gallery_image(media) do
|
||||
%{
|
||||
media_id: media.id,
|
||||
thumbnail_url: Map.get(media, :thumbnail_url),
|
||||
image_url: Map.get(media, :image_url, Map.get(media, :thumbnail_url)),
|
||||
alt_text: Map.get(media, :alt_text),
|
||||
title: Map.get(media, :title, Map.get(media, :original_name, media.id))
|
||||
thumbnail_url: media.thumbnail_url,
|
||||
image_url: media.image_url,
|
||||
alt_text: media.alt_text,
|
||||
title: media.title
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -120,16 +120,7 @@ defmodule BDS.Desktop.ShellCommands do
|
||||
"rebuild_embedding_index",
|
||||
"Rebuild Embedding Index",
|
||||
"Embeddings",
|
||||
fn report ->
|
||||
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report)
|
||||
report.(1.0, "Embedding index rebuilt")
|
||||
|
||||
%{
|
||||
project_id: project.id,
|
||||
rebuilt_post_ids: rebuilt_post_ids,
|
||||
rebuilt_count: length(rebuilt_post_ids)
|
||||
}
|
||||
end
|
||||
fn report -> rebuild_embedding_index_work(project, report) end
|
||||
)
|
||||
end
|
||||
|
||||
@@ -449,7 +440,7 @@ defmodule BDS.Desktop.ShellCommands do
|
||||
end
|
||||
|
||||
defp translation_fill_enabled?(metadata) do
|
||||
([Map.get(metadata, :main_language)] ++ Map.get(metadata, :blog_languages, []))
|
||||
([metadata.main_language] ++ metadata.blog_languages)
|
||||
|> Enum.map(fn language ->
|
||||
language
|
||||
|> to_string()
|
||||
@@ -524,8 +515,14 @@ defmodule BDS.Desktop.ShellCommands do
|
||||
},
|
||||
%{
|
||||
name: "Rebuild Embedding Index",
|
||||
work: fn report ->
|
||||
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report)
|
||||
work: fn report -> rebuild_embedding_index_work(project, report) end
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
defp rebuild_embedding_index_work(project, report) do
|
||||
case Embeddings.rebuild_project(project.id, on_progress: report) do
|
||||
{:ok, rebuilt_post_ids} ->
|
||||
report.(1.0, "Embedding index rebuilt")
|
||||
|
||||
%{
|
||||
@@ -533,9 +530,22 @@ defmodule BDS.Desktop.ShellCommands do
|
||||
rebuilt_post_ids: rebuilt_post_ids,
|
||||
rebuilt_count: length(rebuilt_post_ids)
|
||||
}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, embedding_error_message(reason)}
|
||||
end
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
defp embedding_error_message(reason) do
|
||||
detail =
|
||||
case reason do
|
||||
message when is_binary(message) -> message
|
||||
{:embedding_backend_unavailable, _inner} -> "the embedding service did not start"
|
||||
other -> inspect(other)
|
||||
end
|
||||
|
||||
"Could not build the embedding index: #{detail}. The model is downloaded on first use, " <>
|
||||
"so check your internet connection — or turn off semantic similarity in Settings."
|
||||
end
|
||||
|
||||
defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok
|
||||
@@ -549,27 +559,62 @@ defmodule BDS.Desktop.ShellCommands do
|
||||
end
|
||||
end
|
||||
|
||||
defp wait_for_group_phase(_group_id, _names, timeout) when timeout <= 0, do: :timeout
|
||||
|
||||
defp wait_for_group_phase(group_id, names, timeout) do
|
||||
if timeout <= 0 do
|
||||
:timeout
|
||||
else
|
||||
Phoenix.PubSub.subscribe(BDS.PubSub, Tasks.topic())
|
||||
|
||||
try do
|
||||
case group_phase_status(group_id, names) do
|
||||
:waiting -> wait_for_group_phase_message(group_id, names, timeout)
|
||||
status -> status
|
||||
end
|
||||
after
|
||||
Phoenix.PubSub.unsubscribe(BDS.PubSub, Tasks.topic())
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp wait_for_group_phase_message(group_id, names, timeout) do
|
||||
started_at = System.monotonic_time(:millisecond)
|
||||
|
||||
receive do
|
||||
{:task_terminal, task} ->
|
||||
elapsed = System.monotonic_time(:millisecond) - started_at
|
||||
|
||||
cond do
|
||||
task.group_id == group_id and task.name in names and task.status == :failed ->
|
||||
:failed
|
||||
|
||||
task.group_id == group_id and task.name in names ->
|
||||
case group_phase_status(group_id, names) do
|
||||
:waiting ->
|
||||
wait_for_group_phase_message(group_id, names, timeout - elapsed)
|
||||
|
||||
status ->
|
||||
status
|
||||
end
|
||||
|
||||
true ->
|
||||
wait_for_group_phase_message(group_id, names, timeout - elapsed)
|
||||
end
|
||||
after
|
||||
timeout ->
|
||||
:timeout
|
||||
end
|
||||
end
|
||||
|
||||
defp group_phase_status(group_id, names) do
|
||||
tasks =
|
||||
BDS.Tasks.list_tasks()
|
||||
|> Enum.filter(&(&1.group_id == group_id and &1.name in names))
|
||||
|
||||
cond do
|
||||
length(tasks) < length(names) ->
|
||||
Process.sleep(50)
|
||||
wait_for_group_phase(group_id, names, timeout - 50)
|
||||
|
||||
Enum.any?(tasks, &(&1.status == :failed)) ->
|
||||
:failed
|
||||
|
||||
Enum.all?(tasks, &(&1.status == :completed)) ->
|
||||
:ok
|
||||
|
||||
true ->
|
||||
Process.sleep(50)
|
||||
wait_for_group_phase(group_id, names, timeout - 50)
|
||||
length(tasks) < length(names) -> :waiting
|
||||
Enum.any?(tasks, &(&1.status == :failed)) -> :failed
|
||||
Enum.all?(tasks, &(&1.status == :completed)) -> :ok
|
||||
true -> :waiting
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|
||||
import Phoenix.HTML
|
||||
|
||||
alias BDS.{AI, BoundedAtoms}
|
||||
alias BDS.{AI, Blogmark, BoundedAtoms, Metadata}
|
||||
alias BDS.CliSync.Watcher
|
||||
alias BDS.Desktop.{ExternalLinks, FolderPicker, ShellData, UILocale}
|
||||
alias BDS.Desktop.{ExternalLinks, FilePicker, FolderPicker, ShellData, UILocale}
|
||||
|
||||
alias BDS.Desktop.ShellLive.{
|
||||
Bridges,
|
||||
ChatEditor,
|
||||
GalleryImport,
|
||||
ImportEditor,
|
||||
MediaEditor,
|
||||
MenuEditor,
|
||||
@@ -83,6 +84,8 @@ defmodule BDS.Desktop.ShellLive do
|
||||
"load_more_sidebar"
|
||||
]
|
||||
|
||||
@git_action_events ["git_fetch", "git_pull", "git_push", "git_prune_lfs"]
|
||||
|
||||
@layout_menu_actions MapSet.new([
|
||||
:toggle_sidebar,
|
||||
:toggle_panel,
|
||||
@@ -175,6 +178,7 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|> assign(:output_entries, [])
|
||||
|> assign(:panel_post_links, %{backlinks: [], outlinks: []})
|
||||
|> assign(:panel_git_entries, [])
|
||||
|> assign(:auto_save_timers, %{})
|
||||
|> reload_shell(workbench)
|
||||
|> apply_url_params(params)
|
||||
|> tap(&sync_menu_bar_locale/1)}
|
||||
@@ -190,7 +194,8 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
|
||||
def handle_event("toggle_assistant_sidebar", _params, socket) do
|
||||
{:noreply, refresh_layout(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
|
||||
{:noreply,
|
||||
refresh_layout(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))}
|
||||
end
|
||||
|
||||
def handle_event("select_view", %{"view" => view_id}, socket) do
|
||||
@@ -235,6 +240,20 @@ defmodule BDS.Desktop.ShellLive do
|
||||
SidebarEvents.handle(socket, event, params, &refresh_sidebar/2)
|
||||
end
|
||||
|
||||
def handle_event(event, _params, socket) when event in @git_action_events do
|
||||
{:noreply, run_git_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)}
|
||||
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)}
|
||||
end
|
||||
|
||||
def handle_event("create_sidebar_item", %{"kind" => kind}, socket) do
|
||||
{:noreply, create_sidebar_item(socket, kind)}
|
||||
end
|
||||
@@ -251,6 +270,8 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
|
||||
def handle_event("select_tab", %{"type" => type, "id" => id}, socket) do
|
||||
socket = auto_save_current_post(socket)
|
||||
|
||||
workbench =
|
||||
Workbench.open_tab(
|
||||
socket.assigns.workbench,
|
||||
@@ -269,6 +290,8 @@ defmodule BDS.Desktop.ShellLive do
|
||||
end
|
||||
|
||||
def handle_event("close_tab", %{"type" => type, "id" => id}, socket) do
|
||||
socket = auto_save_current_post(socket)
|
||||
|
||||
type_atom = BoundedAtoms.editor_route(type, :post)
|
||||
workbench = Workbench.close_tab(socket.assigns.workbench, type_atom, id)
|
||||
tab_meta = Map.delete(socket.assigns.tab_meta, {type_atom, id})
|
||||
@@ -375,15 +398,6 @@ defmodule BDS.Desktop.ShellLive do
|
||||
def handle_event("overlay_confirm", params, socket),
|
||||
do: OverlayManager.handle_event("overlay_confirm", params, socket, overlay_callbacks())
|
||||
|
||||
def handle_event("overlay_select_gallery_image", params, socket),
|
||||
do:
|
||||
OverlayManager.handle_event(
|
||||
"overlay_select_gallery_image",
|
||||
params,
|
||||
socket,
|
||||
overlay_callbacks()
|
||||
)
|
||||
|
||||
def handle_event("overlay_close_lightbox", params, socket),
|
||||
do: OverlayManager.handle_event("overlay_close_lightbox", params, socket, overlay_callbacks())
|
||||
|
||||
@@ -399,6 +413,43 @@ defmodule BDS.Desktop.ShellLive do
|
||||
def handle_event("overlay_lightbox_next", params, socket),
|
||||
do: OverlayManager.handle_event("overlay_lightbox_next", params, socket, overlay_callbacks())
|
||||
|
||||
def handle_event("add_gallery_images", %{"post-id" => post_id}, socket) do
|
||||
if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
|
||||
{:noreply,
|
||||
append_output_entry(
|
||||
socket,
|
||||
dgettext("ui", "Add Gallery Images"),
|
||||
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
|
||||
nil,
|
||||
"info"
|
||||
)}
|
||||
else
|
||||
project_id = socket.assigns.projects.active_project_id
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
concurrency_limit = metadata.image_import_concurrency
|
||||
language = metadata.main_language || "en"
|
||||
parent = self()
|
||||
|
||||
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
||||
case FilePicker.choose_files(dgettext("ui", "Add Gallery Images"),
|
||||
image_only: true,
|
||||
multiple: true
|
||||
) do
|
||||
{:ok, paths} when is_list(paths) and paths != [] ->
|
||||
GalleryImport.start(paths, project_id, post_id, language, concurrency_limit, parent)
|
||||
|
||||
:cancel ->
|
||||
send(parent, {:add_images_cancelled})
|
||||
|
||||
{:error, reason} ->
|
||||
send(parent, {:add_images_error, reason})
|
||||
end
|
||||
end)
|
||||
|
||||
{:noreply, assign(socket, :gallery_import_post_id, post_id)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("toggle_project_menu", _params, socket) do
|
||||
{:noreply, assign(socket, :project_menu_open, not socket.assigns.project_menu_open)}
|
||||
end
|
||||
@@ -580,6 +631,87 @@ defmodule BDS.Desktop.ShellLive do
|
||||
OverlayManager.handle_info({:ai_suggestions_error, type, id, reason}, socket)
|
||||
end
|
||||
|
||||
def handle_info({:add_image_processed, title}, socket) do
|
||||
{:noreply,
|
||||
append_output_entry(
|
||||
socket,
|
||||
dgettext("ui", "Add Gallery Images"),
|
||||
dgettext("ui", "Added %{title}", title: title),
|
||||
nil,
|
||||
"info"
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_info({:add_images_complete, count}, socket) do
|
||||
post_id = socket.assigns[:gallery_import_post_id]
|
||||
|
||||
socket =
|
||||
if is_binary(post_id) do
|
||||
send_update(PostEditor,
|
||||
id: "post-editor-#{post_id}",
|
||||
action: :insert_content,
|
||||
content: "\n[[gallery]]\n"
|
||||
)
|
||||
|
||||
send_update(PostEditor,
|
||||
id: "post-editor-#{post_id}",
|
||||
action: :refresh
|
||||
)
|
||||
|
||||
socket
|
||||
|> assign(:gallery_import_post_id, nil)
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> append_output_entry(
|
||||
dgettext("ui", "Add Gallery Images"),
|
||||
dgettext("ui", "Added %{count} images to post", count: count),
|
||||
nil,
|
||||
"info"
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_info({:add_images_error, reason}, socket) do
|
||||
{:noreply,
|
||||
append_output_entry(
|
||||
socket,
|
||||
dgettext("ui", "Add Gallery Images"),
|
||||
inspect(reason),
|
||||
nil,
|
||||
"error"
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_info({:add_image_error, path, reason}, socket) do
|
||||
{:noreply,
|
||||
append_output_entry(
|
||||
socket,
|
||||
dgettext("ui", "Add Gallery Images"),
|
||||
dgettext("ui", "Failed to process %{path}: %{reason}",
|
||||
path: Path.basename(path),
|
||||
reason: inspect(reason)
|
||||
),
|
||||
nil,
|
||||
"error"
|
||||
)}
|
||||
end
|
||||
|
||||
def handle_info({:add_images_cancelled}, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:test_ping, caller, ref}, socket) do
|
||||
send(caller, {:test_pong, ref})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:blogmark_deep_link, url}, socket) when is_binary(url) do
|
||||
{:noreply, handle_blogmark_deep_link(socket, url)}
|
||||
end
|
||||
|
||||
def handle_info(message, socket) do
|
||||
Bridges.handle_info(message, socket, bridges_callbacks())
|
||||
end
|
||||
@@ -593,13 +725,17 @@ defmodule BDS.Desktop.ShellLive do
|
||||
defp 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}
|
||||
|
||||
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
|
||||
@@ -861,6 +997,56 @@ defmodule BDS.Desktop.ShellLive do
|
||||
defp create_sidebar_item(socket, kind),
|
||||
do: SidebarCreate.create(socket, kind, sidebar_create_callbacks())
|
||||
|
||||
# Receive a bds2://new-post blogmark deep link: run the transform pipeline,
|
||||
# create a draft post, open it in the editor, and surface transform toasts.
|
||||
defp handle_blogmark_deep_link(socket, url) do
|
||||
title = dgettext("ui", "Blogmark")
|
||||
|
||||
case current_project_id(socket) do
|
||||
project_id when is_binary(project_id) ->
|
||||
case Blogmark.receive_deep_link(project_id, url) do
|
||||
{:ok, %{post: post, toasts: toasts, errors: errors}} ->
|
||||
socket
|
||||
|> reload_shell(socket.assigns.workbench)
|
||||
|> open_sidebar_item(
|
||||
%{
|
||||
"route" => "post",
|
||||
"id" => post.id,
|
||||
"title" => post.title,
|
||||
"subtitle" => post.slug
|
||||
},
|
||||
:pin
|
||||
)
|
||||
|> append_blogmark_toasts(title, toasts)
|
||||
|> append_blogmark_errors(title, errors)
|
||||
|
||||
{:error, reason} ->
|
||||
append_output_entry(socket, title, inspect(reason), url, "error")
|
||||
end
|
||||
|
||||
_ ->
|
||||
append_output_entry(
|
||||
socket,
|
||||
title,
|
||||
dgettext("ui", "Open a project before importing a blogmark."),
|
||||
url,
|
||||
"warning"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
defp append_blogmark_toasts(socket, title, toasts) do
|
||||
Enum.reduce(toasts, socket, fn message, acc ->
|
||||
append_output_entry(acc, title, message, nil, "info")
|
||||
end)
|
||||
end
|
||||
|
||||
defp append_blogmark_errors(socket, title, errors) do
|
||||
Enum.reduce(errors, socket, fn %{slug: slug, reason: reason}, acc ->
|
||||
append_output_entry(acc, title, inspect(reason), slug, "error")
|
||||
end)
|
||||
end
|
||||
|
||||
defp handle_file_picker_result(socket, {:ok, _media}),
|
||||
do: refresh_content(socket, socket.assigns.workbench)
|
||||
|
||||
@@ -914,6 +1100,122 @@ defmodule BDS.Desktop.ShellLive do
|
||||
|> 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)
|
||||
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
|
||||
@@ -1045,6 +1347,18 @@ 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
|
||||
|
||||
@@ -9,25 +9,124 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
||||
alias BDS.Desktop.ShellLive.{CliSync, SessionUtil}
|
||||
alias BDS.UI.Workbench
|
||||
|
||||
@refreshable_tab_meta_types [:import, :chat]
|
||||
|
||||
@spec handle_info(tuple() | atom(), Phoenix.LiveView.Socket.t(), map()) ::
|
||||
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||
def handle_info({:import_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
|
||||
# ── Generic editor notifications (sent via Notify module) ────────────────
|
||||
|
||||
def handle_info({:editor_output, title, message, detail, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, detail, level)}
|
||||
end
|
||||
|
||||
def handle_info({:import_editor_tab_meta, definition_id, title, subtitle}, socket, callbacks) do
|
||||
tab_meta =
|
||||
Map.put(socket.assigns.tab_meta, {:import, definition_id}, %{
|
||||
title: title,
|
||||
subtitle: subtitle || ""
|
||||
})
|
||||
def handle_info({:editor_tab_meta, type, id, updates}, socket, callbacks)
|
||||
when is_atom(type) and is_map(updates) do
|
||||
key = {type, id}
|
||||
current_meta = Map.get(socket.assigns.tab_meta, key, %{})
|
||||
next_meta = Map.merge(current_meta, updates)
|
||||
tab_meta = Map.put(socket.assigns.tab_meta, key, next_meta)
|
||||
|
||||
socket = assign(socket, :tab_meta, tab_meta)
|
||||
|
||||
if type in @refreshable_tab_meta_types do
|
||||
{:noreply, callbacks.refresh_sidebar.(socket, socket.assigns.workbench)}
|
||||
else
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({:editor_dirty, type, id, dirty?}, socket, _callbacks) do
|
||||
workbench =
|
||||
if dirty? do
|
||||
Workbench.mark_dirty(socket.assigns.workbench, type, id)
|
||||
else
|
||||
Workbench.clear_dirty(socket.assigns.workbench, type, id)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :workbench, workbench)}
|
||||
end
|
||||
|
||||
@default_auto_save_delay 3000
|
||||
|
||||
def handle_info({:schedule_auto_save, type, id}, socket, _callbacks) do
|
||||
timers = socket.assigns[:auto_save_timers] || %{}
|
||||
key = {type, id}
|
||||
|
||||
case Map.get(timers, key) do
|
||||
nil -> :ok
|
||||
old_ref -> Process.cancel_timer(old_ref)
|
||||
end
|
||||
|
||||
delay = Application.get_env(:bds, :auto_save_delay, @default_auto_save_delay)
|
||||
ref = Process.send_after(self(), {:auto_save_fire, type, id}, delay)
|
||||
{:noreply, assign(socket, :auto_save_timers, Map.put(timers, key, ref))}
|
||||
end
|
||||
|
||||
def handle_info({:cancel_auto_save, type, id}, socket, _callbacks) do
|
||||
timers = socket.assigns[:auto_save_timers] || %{}
|
||||
key = {type, id}
|
||||
|
||||
case Map.get(timers, key) do
|
||||
nil -> :ok
|
||||
old_ref -> Process.cancel_timer(old_ref)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :auto_save_timers, Map.delete(timers, key))}
|
||||
end
|
||||
|
||||
def handle_info({:auto_save_fire, :post, post_id}, socket, _callbacks) do
|
||||
timers = socket.assigns[:auto_save_timers] || %{}
|
||||
send_update(PostEditor, id: "post-editor-#{post_id}", action: :save)
|
||||
{:noreply, assign(socket, :auto_save_timers, Map.delete(timers, {:post, post_id}))}
|
||||
end
|
||||
|
||||
def handle_info({:editor_command, action, params}, socket, callbacks) do
|
||||
{:noreply, callbacks.apply_shell_command.(socket, action, params)}
|
||||
end
|
||||
|
||||
# ── Shared actions (already generic) ─────────────────────────────────────
|
||||
|
||||
def handle_info({:open_sidebar_item, params, intent}, socket, callbacks) do
|
||||
{:noreply, callbacks.open_sidebar.(socket, params, intent)}
|
||||
end
|
||||
|
||||
def handle_info(:reload_shell, socket, callbacks) do
|
||||
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
|
||||
end
|
||||
|
||||
def handle_info({:close_tab, type, id}, socket, callbacks) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> callbacks.refresh_sidebar.(socket.assigns.workbench)}
|
||||
callbacks.refresh_layout.(socket, Workbench.close_tab(socket.assigns.workbench, type, id))}
|
||||
end
|
||||
|
||||
def handle_info(:tags_changed, socket, callbacks) do
|
||||
{:noreply, callbacks.refresh_content.(socket, socket.assigns.workbench)}
|
||||
end
|
||||
|
||||
def handle_info({:confirm_tag_delete, tag_id, _tag_name, post_count}, socket, _callbacks) do
|
||||
page_language = socket.assigns.page_language
|
||||
|
||||
suffix = if post_count == 1, do: "", else: "s"
|
||||
message = "This tag is used in #{post_count} post#{suffix}. Delete anyway?"
|
||||
|
||||
overlay = %{
|
||||
kind: :confirm_dialog,
|
||||
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"),
|
||||
message: message,
|
||||
tag_id: tag_id,
|
||||
confirm_action: :delete_tag
|
||||
}
|
||||
|
||||
{:noreply, assign(socket, :shell_overlay, overlay)}
|
||||
end
|
||||
|
||||
def handle_info(:settings_changed, socket, callbacks) do
|
||||
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
|
||||
end
|
||||
|
||||
# ── Chat editor messages (sent from AI streaming, not from Notify) ──────
|
||||
|
||||
def handle_info({:chat_tool_call, conversation_id, tool_call}, socket, _callbacks) do
|
||||
send_update(ChatEditor,
|
||||
id: "chat-editor-#{conversation_id}",
|
||||
@@ -68,25 +167,13 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
||||
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
|
||||
end
|
||||
|
||||
def handle_info({:chat_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
def handle_info({:persist_surface_state, conversation_id}, socket, _callbacks) do
|
||||
send_update(ChatEditor,
|
||||
id: "chat-editor-#{conversation_id}",
|
||||
action: :persist_surface_state
|
||||
)
|
||||
|
||||
def handle_info({:chat_editor_tab_meta, conversation_id, title, subtitle}, socket, callbacks) do
|
||||
tab_meta =
|
||||
Map.put(socket.assigns.tab_meta, {:chat, conversation_id}, %{
|
||||
title: title,
|
||||
subtitle: subtitle || ""
|
||||
})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:tab_meta, tab_meta)
|
||||
|> callbacks.refresh_sidebar.(socket.assigns.workbench)}
|
||||
end
|
||||
|
||||
def handle_info({:open_sidebar_item, params, intent}, socket, callbacks) do
|
||||
{:noreply, callbacks.open_sidebar.(socket, params, intent)}
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do
|
||||
@@ -112,6 +199,35 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
||||
callbacks.refresh_sidebar.(socket, Workbench.click_activity(socket.assigns.workbench, view))}
|
||||
end
|
||||
|
||||
# ── Post editor cross-component messages (sent from OverlayManager) ─────
|
||||
|
||||
def handle_info({:post_editor_insert_content, post_id, content}, socket, _callbacks) do
|
||||
send_update(PostEditor,
|
||||
id: "post-editor-#{post_id}",
|
||||
action: :insert_content,
|
||||
content: content
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:post_editor_translate, post_id, language}, socket, _callbacks) do
|
||||
send_update(PostEditor, id: "post-editor-#{post_id}", action: :translate, language: language)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:post_editor_apply_ai_suggestions, post_id, fields}, socket, _callbacks) do
|
||||
send_update(PostEditor,
|
||||
id: "post-editor-#{post_id}",
|
||||
action: :apply_ai_suggestions,
|
||||
fields: fields
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# ── External system messages ─────────────────────────────────────────────
|
||||
|
||||
def handle_info({:entity_changed, payload}, socket, callbacks) when is_map(payload) do
|
||||
{:noreply, CliSync.apply_entity_change(socket, payload, callbacks.refresh_content)}
|
||||
end
|
||||
@@ -155,126 +271,5 @@ defmodule BDS.Desktop.ShellLive.Bridges do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:tags_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info(:tags_changed, socket, callbacks) do
|
||||
{:noreply, callbacks.refresh_content.(socket, socket.assigns.workbench)}
|
||||
end
|
||||
|
||||
def handle_info({:settings_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info(:settings_changed, socket, callbacks) do
|
||||
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
|
||||
end
|
||||
|
||||
def handle_info({:menu_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info({:script_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info({:template_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info({:misc_editor_output, title, message, _detail, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info({:misc_editor_command, action, params}, socket, callbacks) do
|
||||
{:noreply, callbacks.apply_shell_command.(socket, action, params)}
|
||||
end
|
||||
|
||||
def handle_info({:misc_editor_tab_meta, tab_type, tab_id, updates}, socket, _callbacks) do
|
||||
key = {tab_type, tab_id}
|
||||
current_meta = Map.get(socket.assigns.tab_meta, key, %{})
|
||||
next_meta = Map.merge(current_meta, updates)
|
||||
{:noreply, assign(socket, :tab_meta, Map.put(socket.assigns.tab_meta, key, next_meta))}
|
||||
end
|
||||
|
||||
def handle_info({:post_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info({:post_editor_dirty, post_id, dirty?}, socket, _callbacks) do
|
||||
workbench =
|
||||
if dirty? do
|
||||
Workbench.mark_dirty(socket.assigns.workbench, :post, post_id)
|
||||
else
|
||||
Workbench.clear_dirty(socket.assigns.workbench, :post, post_id)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :workbench, workbench)}
|
||||
end
|
||||
|
||||
def handle_info({:post_editor_tab_meta, post_id, title, subtitle}, socket, _callbacks) do
|
||||
tab_meta =
|
||||
Map.put(socket.assigns.tab_meta, {:post, post_id}, %{title: title, subtitle: subtitle})
|
||||
|
||||
{:noreply, assign(socket, :tab_meta, tab_meta)}
|
||||
end
|
||||
|
||||
def handle_info({:post_editor_insert_content, post_id, content}, socket, _callbacks) do
|
||||
send_update(PostEditor,
|
||||
id: "post-editor-#{post_id}",
|
||||
action: :insert_content,
|
||||
content: content
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:post_editor_translate, post_id, language}, socket, _callbacks) do
|
||||
send_update(PostEditor, id: "post-editor-#{post_id}", action: :translate, language: language)
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:post_editor_apply_ai_suggestions, post_id, fields}, socket, _callbacks) do
|
||||
send_update(PostEditor,
|
||||
id: "post-editor-#{post_id}",
|
||||
action: :apply_ai_suggestions,
|
||||
fields: fields
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_info({:media_editor_output, title, message, level}, socket, callbacks) do
|
||||
{:noreply, callbacks.append_output.(socket, title, message, nil, level)}
|
||||
end
|
||||
|
||||
def handle_info({:media_editor_dirty, media_id, dirty?}, socket, _callbacks) do
|
||||
workbench =
|
||||
if dirty? do
|
||||
Workbench.mark_dirty(socket.assigns.workbench, :media, media_id)
|
||||
else
|
||||
Workbench.clear_dirty(socket.assigns.workbench, :media, media_id)
|
||||
end
|
||||
|
||||
{:noreply, assign(socket, :workbench, workbench)}
|
||||
end
|
||||
|
||||
def handle_info({:media_editor_tab_meta, media_id, title, subtitle}, socket, _callbacks) do
|
||||
tab_meta =
|
||||
Map.put(socket.assigns.tab_meta, {:media, media_id}, %{title: title, subtitle: subtitle})
|
||||
|
||||
{:noreply, assign(socket, :tab_meta, tab_meta)}
|
||||
end
|
||||
|
||||
def handle_info(:reload_shell, socket, callbacks) do
|
||||
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
|
||||
end
|
||||
|
||||
def handle_info({:close_tab, type, id}, socket, callbacks) do
|
||||
{:noreply,
|
||||
callbacks.refresh_layout.(socket, Workbench.close_tab(socket.assigns.workbench, type, id))}
|
||||
end
|
||||
|
||||
def handle_info(_message, socket, _callbacks), do: {:noreply, socket}
|
||||
end
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
@moduledoc false
|
||||
|
||||
require Logger
|
||||
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
import Phoenix.HTML, only: [raw: 1]
|
||||
|
||||
alias BDS.{AI, BoundedAtoms, MapUtils, Persistence}
|
||||
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
alias BDS.Desktop.ShellLive.TabHelpers
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
@@ -36,6 +39,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
{:ok, do_note_streaming_content(socket, content)}
|
||||
end
|
||||
|
||||
def update(%{action: :persist_surface_state}, socket) do
|
||||
{:ok, persist_surface_state(socket)}
|
||||
end
|
||||
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
@@ -77,16 +84,18 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
{:noreply, assign(socket, :model_selector_open?, false) |> build_data()}
|
||||
|
||||
{:error, reason} ->
|
||||
notify_parent({:chat_editor_output, dgettext("ui", "Chat"), inspect(reason), "error"})
|
||||
Notify.output(dgettext("ui", "Chat"), inspect(reason), "error")
|
||||
{:noreply, assign(socket, :model_selector_open?, false) |> build_data()}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_event("send_chat_editor_message", _params, socket) do
|
||||
Logger.info("CHAT send_chat_editor_message called, input=#{inspect(socket.assigns.input)}")
|
||||
{:noreply, do_send_message(socket)}
|
||||
end
|
||||
|
||||
def handle_event("abort_chat_editor_message", _params, socket) do
|
||||
Logger.info("CHAT abort_chat_editor_message called")
|
||||
{:noreply, do_abort_message(socket)}
|
||||
end
|
||||
|
||||
@@ -96,7 +105,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
socket
|
||||
) do
|
||||
next_data = Map.put(socket.assigns.surface_data, surface_id, fields)
|
||||
{:noreply, assign(socket, :surface_data, next_data) |> build_data()}
|
||||
|
||||
{:noreply,
|
||||
assign(socket, :surface_data, next_data) |> schedule_surface_state_persist() |> build_data()}
|
||||
end
|
||||
|
||||
def handle_event(
|
||||
@@ -110,6 +121,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
:surface_tabs,
|
||||
Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index))
|
||||
)
|
||||
|> persist_surface_state()
|
||||
|> build_data()
|
||||
|
||||
{:noreply, socket}
|
||||
@@ -119,6 +131,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|
||||
|> persist_surface_state()
|
||||
|> build_data()
|
||||
|
||||
{:noreply, socket}
|
||||
@@ -129,14 +142,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
end
|
||||
|
||||
def handle_event("open_chat_settings", _params, socket) do
|
||||
notify_parent(
|
||||
{:open_sidebar_item,
|
||||
Notify.open_sidebar_item(
|
||||
%{
|
||||
"route" => "settings",
|
||||
"id" => "settings-ai",
|
||||
"title" => "Settings",
|
||||
"subtitle" => "AI"
|
||||
}, :pin}
|
||||
},
|
||||
:pin
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
@@ -147,14 +160,29 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
defp ensure_state(socket) do
|
||||
conversation_id = socket.assigns.current_tab.id
|
||||
|
||||
persisted = AI.get_surface_state(conversation_id)
|
||||
|
||||
{surface_data, surface_tabs, dismissed_surfaces} =
|
||||
case persisted do
|
||||
state when is_map(state) and map_size(state) > 0 ->
|
||||
{
|
||||
state["surface_data"] || %{},
|
||||
state["surface_tabs"] || %{},
|
||||
MapSet.new(state["dismissed_surfaces"] || [])
|
||||
}
|
||||
|
||||
_other ->
|
||||
{%{}, %{}, MapSet.new()}
|
||||
end
|
||||
|
||||
defaults = %{
|
||||
conversation_id: conversation_id,
|
||||
input: "",
|
||||
model_selector_open?: false,
|
||||
request: nil,
|
||||
surface_data: %{},
|
||||
surface_tabs: %{},
|
||||
dismissed_surfaces: MapSet.new(),
|
||||
surface_data: surface_data,
|
||||
surface_tabs: surface_tabs,
|
||||
dismissed_surfaces: dismissed_surfaces,
|
||||
action_error: nil
|
||||
}
|
||||
|
||||
@@ -202,15 +230,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
not is_nil(socket.assigns.request) ->
|
||||
build_data(socket)
|
||||
|
||||
socket.assigns.offline_mode ->
|
||||
notify_parent(
|
||||
{:chat_editor_output, dgettext("ui", "Chat"),
|
||||
dgettext("ui", "Automatic AI actions stay gated by airplane mode."), "info"}
|
||||
socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() ->
|
||||
Notify.output(
|
||||
dgettext("ui", "Chat"),
|
||||
dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
|
||||
"info"
|
||||
)
|
||||
|
||||
build_data(socket)
|
||||
|
||||
ModelSelection.needs_api_key?(false) ->
|
||||
ModelSelection.needs_api_key?(socket.assigns.offline_mode) ->
|
||||
build_data(socket)
|
||||
|
||||
true ->
|
||||
@@ -227,7 +256,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|
||||
:ok = allow_repo_sandbox(task.pid)
|
||||
|
||||
notify_parent({:chat_editor_task_started, conversation_id, task.ref})
|
||||
Notify.parent({:chat_editor_task_started, conversation_id, task.ref})
|
||||
|
||||
socket
|
||||
|> assign(:input, "")
|
||||
@@ -254,7 +283,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
%{ref: ref} = _request ->
|
||||
:ok = AI.cancel_chat(conversation_id)
|
||||
|
||||
notify_parent({:chat_editor_task_cancelled, conversation_id, ref})
|
||||
Notify.parent({:chat_editor_task_cancelled, conversation_id, ref})
|
||||
|
||||
socket
|
||||
|> assign(:request, nil)
|
||||
@@ -293,9 +322,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
assign(socket, :request, nil) |> build_data()
|
||||
|
||||
{:error, reason} ->
|
||||
notify_parent(
|
||||
{:chat_editor_output, dgettext("ui", "Chat"), format_error(reason), "error"}
|
||||
)
|
||||
Notify.output(dgettext("ui", "Chat"), format_error(reason), "error")
|
||||
|
||||
assign(socket, :request, nil) |> build_data()
|
||||
end
|
||||
@@ -347,7 +374,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|> MapUtils.attr(:title)
|
||||
|
||||
if is_binary(title) and String.trim(title) != "" do
|
||||
notify_parent({:chat_editor_tab_meta, socket.assigns.conversation_id, title, ""})
|
||||
Notify.tab_meta(:chat, socket.assigns.conversation_id, title, "")
|
||||
end
|
||||
|
||||
socket
|
||||
@@ -368,14 +395,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
:open_post ->
|
||||
case Map.get(payload, "postId") || Map.get(payload, "post_id") do
|
||||
post_id when is_binary(post_id) and post_id != "" ->
|
||||
notify_parent(
|
||||
{:open_sidebar_item,
|
||||
Notify.open_sidebar_item(
|
||||
%{
|
||||
"route" => "post",
|
||||
"id" => post_id,
|
||||
"title" => TabHelpers.post_title(post_id),
|
||||
"subtitle" => TabHelpers.post_subtitle(post_id)
|
||||
}, :pin}
|
||||
},
|
||||
:pin
|
||||
)
|
||||
|
||||
assign(socket, :action_error, nil) |> build_data()
|
||||
@@ -387,14 +414,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
:open_media ->
|
||||
case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do
|
||||
media_id when is_binary(media_id) and media_id != "" ->
|
||||
notify_parent(
|
||||
{:open_sidebar_item,
|
||||
Notify.open_sidebar_item(
|
||||
%{
|
||||
"route" => "media",
|
||||
"id" => media_id,
|
||||
"title" => TabHelpers.media_title(media_id),
|
||||
"subtitle" => TabHelpers.media_subtitle(media_id)
|
||||
}, :pin}
|
||||
},
|
||||
:pin
|
||||
)
|
||||
|
||||
assign(socket, :action_error, nil) |> build_data()
|
||||
@@ -404,14 +431,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
end
|
||||
|
||||
:open_settings ->
|
||||
notify_parent(
|
||||
{:open_sidebar_item,
|
||||
Notify.open_sidebar_item(
|
||||
%{
|
||||
"route" => "settings",
|
||||
"id" => "settings-ai",
|
||||
"title" => "Settings",
|
||||
"subtitle" => "AI"
|
||||
}, :pin}
|
||||
},
|
||||
:pin
|
||||
)
|
||||
|
||||
assign(socket, :action_error, nil) |> build_data()
|
||||
@@ -421,14 +448,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") ||
|
||||
socket.assigns.conversation_id
|
||||
|
||||
notify_parent(
|
||||
{:open_sidebar_item,
|
||||
Notify.open_sidebar_item(
|
||||
%{
|
||||
"route" => "chat",
|
||||
"id" => chat_id,
|
||||
"title" => Map.get(payload, "title", "Chat"),
|
||||
"subtitle" => Map.get(payload, "subtitle", "")
|
||||
}, :pin}
|
||||
},
|
||||
:pin
|
||||
)
|
||||
|
||||
assign(socket, :action_error, nil) |> build_data()
|
||||
@@ -439,20 +466,20 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
set_action_error(socket, "Invalid payload for switchView action")
|
||||
|
||||
view ->
|
||||
notify_parent({:chat_editor_switch_view, view})
|
||||
Notify.parent({:chat_editor_switch_view, view})
|
||||
assign(socket, :action_error, nil) |> build_data()
|
||||
end
|
||||
|
||||
:toggle_sidebar ->
|
||||
notify_parent({:chat_editor_toggle_sidebar})
|
||||
Notify.parent({:chat_editor_toggle_sidebar})
|
||||
assign(socket, :action_error, nil) |> build_data()
|
||||
|
||||
:toggle_panel ->
|
||||
notify_parent({:chat_editor_toggle_panel})
|
||||
Notify.parent({:chat_editor_toggle_panel})
|
||||
assign(socket, :action_error, nil) |> build_data()
|
||||
|
||||
:toggle_assistant_sidebar ->
|
||||
notify_parent({:chat_editor_toggle_assistant_sidebar})
|
||||
Notify.parent({:chat_editor_toggle_assistant_sidebar})
|
||||
assign(socket, :action_error, nil) |> build_data()
|
||||
|
||||
:unknown ->
|
||||
@@ -659,6 +686,102 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
<h3><%= @surface.title %></h3>
|
||||
<% end %>
|
||||
<p class="chat-surface-chart-type"><%= @surface.chart_type %></p>
|
||||
<%= case @surface.chart_type do %>
|
||||
<% chart_type when chart_type in ["pie", "donut"] -> %>
|
||||
<% pie = BDS.Desktop.ShellLive.ChatEditor.ChartView.pie(@surface.series) %>
|
||||
<div class="chat-surface-chart-pie">
|
||||
<svg class="chat-surface-chart-pie-svg" viewBox={"0 0 #{pie.size} #{pie.size}"} preserveAspectRatio="xMidYMid meet">
|
||||
<%= for slice <- pie.slices do %>
|
||||
<%= if slice.full_circle do %>
|
||||
<circle class="chat-surface-chart-pie-slice" cx={pie.center} cy={pie.center} r={pie.radius} fill={slice.color}>
|
||||
<title><%= slice.label %>: <%= slice.value %></title>
|
||||
</circle>
|
||||
<% else %>
|
||||
<path class="chat-surface-chart-pie-slice" d={slice.d} fill={slice.color}>
|
||||
<title><%= slice.label %>: <%= slice.value %></title>
|
||||
</path>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= if @surface.chart_type == "donut" do %>
|
||||
<circle class="chat-surface-chart-donut-hole" cx={pie.center} cy={pie.center} r={pie.donut_inner} />
|
||||
<text class="chat-surface-chart-donut-total" x={pie.center} y={pie.center} text-anchor="middle" dominant-baseline="central"><%= pie.total %></text>
|
||||
<% end %>
|
||||
</svg>
|
||||
<div class="chat-surface-chart-legend">
|
||||
<%= for item <- pie.legend do %>
|
||||
<span class="chat-surface-chart-legend-item">
|
||||
<span class="chat-surface-chart-legend-swatch" style={"background-color: #{item.color}"}></span>
|
||||
<%= item.label %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% chart_type when chart_type in ["line", "area"] -> %>
|
||||
<% line = BDS.Desktop.ShellLive.ChatEditor.ChartView.line(@surface.series, @surface.chart_type == "area") %>
|
||||
<svg class="chat-surface-chart-line-svg" viewBox={line.view_box} preserveAspectRatio="xMidYMid meet">
|
||||
<%= for tick <- line.grid do %>
|
||||
<line class="chat-surface-chart-line-grid" x1={tick.x1} y1={tick.y} x2={tick.x2} y2={tick.y} />
|
||||
<text class="chat-surface-chart-line-y-label" x={tick.label_x} y={tick.y} text-anchor="end" dominant-baseline="middle"><%= tick.label %></text>
|
||||
<% end %>
|
||||
<%= if line.area? do %>
|
||||
<polygon class="chat-surface-chart-area-fill" points={line.area_points} />
|
||||
<% end %>
|
||||
<polyline class="chat-surface-chart-line-path" points={line.polyline} fill="none" />
|
||||
<%= for dot <- line.dots do %>
|
||||
<circle class="chat-surface-chart-line-dot" cx={dot.x} cy={dot.y} r="3">
|
||||
<title><%= dot.label %>: <%= dot.value %></title>
|
||||
</circle>
|
||||
<% end %>
|
||||
<%= for label <- line.x_labels do %>
|
||||
<text class="chat-surface-chart-line-x-label" x={label.x} y={label.y} text-anchor="middle"><%= label.label %></text>
|
||||
<% end %>
|
||||
</svg>
|
||||
|
||||
<% "heatmap" -> %>
|
||||
<% heat = BDS.Desktop.ShellLive.ChatEditor.ChartView.heatmap(@surface.series) %>
|
||||
<%= if heat.rows != [] do %>
|
||||
<div class="chat-surface-chart-heatmap" style={"grid-template-columns: auto repeat(#{heat.column_count}, 1fr)"}>
|
||||
<span class="chat-surface-chart-heatmap-corner"></span>
|
||||
<%= for col <- heat.columns do %>
|
||||
<span class="chat-surface-chart-heatmap-col-label"><%= col %></span>
|
||||
<% end %>
|
||||
<%= for row <- heat.rows do %>
|
||||
<span class="chat-surface-chart-heatmap-row-label"><%= row.label %></span>
|
||||
<%= for cell <- row.cells do %>
|
||||
<span class="chat-surface-chart-heatmap-cell" style={"background: #{cell.bg}; color: #{cell.fg}"} title={cell.value}><%= if cell.value > 0, do: cell.value %></span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% "stacked-bar" -> %>
|
||||
<% stacked = BDS.Desktop.ShellLive.ChatEditor.ChartView.stacked(@surface.series) %>
|
||||
<div class="chat-surface-chart-list">
|
||||
<%= for row <- stacked.rows do %>
|
||||
<div class="chat-surface-chart-row">
|
||||
<div class="chat-surface-chart-meta">
|
||||
<span><%= row.label %></span>
|
||||
<span><%= row.total %></span>
|
||||
</div>
|
||||
<div class="chat-surface-chart-bar chat-surface-chart-bar-stacked">
|
||||
<%= for seg <- row.segments do %>
|
||||
<span class="chat-surface-chart-bar-segment" style={"width: #{seg.width}%; background-color: #{seg.color}"} title={"#{seg.label}: #{seg.value}"}></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="chat-surface-chart-legend">
|
||||
<%= for item <- stacked.legend do %>
|
||||
<span class="chat-surface-chart-legend-item">
|
||||
<span class="chat-surface-chart-legend-swatch" style={"background-color: #{item.color}"}></span>
|
||||
<%= item.label %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% _bar -> %>
|
||||
<div class="chat-surface-chart-list">
|
||||
<%= for series <- @surface.series do %>
|
||||
<div class="chat-surface-chart-row">
|
||||
@@ -672,6 +795,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% "metric" -> %>
|
||||
<div class="chat-surface-metric">
|
||||
@@ -822,8 +946,40 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
|
||||
# ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
defp notify_parent(message) do
|
||||
send(self(), message)
|
||||
@surface_state_debounce_ms 500
|
||||
|
||||
defp persist_surface_state(socket) do
|
||||
conversation_id = socket.assigns.conversation_id
|
||||
surface_data = socket.assigns.surface_data
|
||||
surface_tabs = socket.assigns.surface_tabs
|
||||
dismissed_surfaces = socket.assigns.dismissed_surfaces
|
||||
|
||||
case AI.put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces) do
|
||||
{:ok, _state} ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Failed to persist surface state for conversation #{conversation_id}",
|
||||
reason: inspect(reason)
|
||||
)
|
||||
end
|
||||
|
||||
socket
|
||||
end
|
||||
|
||||
defp schedule_surface_state_persist(socket) do
|
||||
if socket.assigns[:surface_state_timer] do
|
||||
Process.cancel_timer(socket.assigns[:surface_state_timer])
|
||||
end
|
||||
|
||||
timer =
|
||||
Process.send_after(
|
||||
self(),
|
||||
{:persist_surface_state, socket.assigns.conversation_id},
|
||||
@surface_state_debounce_ms
|
||||
)
|
||||
|
||||
assign(socket, :surface_state_timer, timer)
|
||||
end
|
||||
|
||||
defp active_project_id(socket) do
|
||||
@@ -871,9 +1027,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|
||||
defp present?(value) when is_binary(value), do: String.trim(value) != ""
|
||||
defp present?(value), do: not is_nil(value)
|
||||
|
||||
defp format_error(%{kind: :endpoint_not_configured}),
|
||||
do: dgettext("ui", "Configure an API key in Settings to enable AI chat.")
|
||||
|
||||
# :endpoint_not_configured is handled by its own case clause before this is
|
||||
# reached; the chat surface already shows the configuration hint.
|
||||
defp format_error(reason), do: inspect(reason)
|
||||
|
||||
defp parse_integer(value) when is_integer(value), do: value
|
||||
|
||||
284
lib/bds/desktop/shell_live/chat_editor/chart_view.ex
Normal file
284
lib/bds/desktop/shell_live/chat_editor/chart_view.ex
Normal file
@@ -0,0 +1,284 @@
|
||||
defmodule BDS.Desktop.ShellLive.ChatEditor.ChartView do
|
||||
@moduledoc """
|
||||
Pure geometry/view helpers for rendering a2ui chart surfaces.
|
||||
|
||||
Mirrors the behaviour of the legacy bDS `A2UIChart` React component so the
|
||||
Elixir rewrite supports the same chart types (`bar`, `stacked-bar`, `line`,
|
||||
`area`, `pie`, `donut`, `heatmap`) with the same visual output. Each function
|
||||
returns plain maps/lists that a HEEx template can iterate over; no markup is
|
||||
produced here so the geometry stays unit-testable.
|
||||
|
||||
Series entries are expected to be maps with `:label`, `:value` and an optional
|
||||
list of `:segments` (each a map with `:label` and `:value`).
|
||||
"""
|
||||
|
||||
# Matches the legacy bDS SEGMENT_COLORS palette.
|
||||
@palette ["#75beff", "#89d185", "#d18616", "#f14c4c", "#b180d7", "#e2e210"]
|
||||
|
||||
# Line/area chart geometry (viewBox units), matching the legacy component.
|
||||
@line_width 300
|
||||
@line_height 140
|
||||
@pad_top 8
|
||||
@pad_right 12
|
||||
@pad_bottom 24
|
||||
@pad_left 40
|
||||
|
||||
@doc "Returns the colour for the series/segment at `index` (cycles the palette)."
|
||||
@spec color(integer()) :: String.t()
|
||||
def color(index), do: Enum.at(@palette, rem(index, length(@palette)))
|
||||
|
||||
@doc """
|
||||
Pie/donut geometry: SVG slices, a legend and the running total.
|
||||
"""
|
||||
@spec pie([map()]) :: map()
|
||||
def pie(series) do
|
||||
total = series |> Enum.map(& &1.value) |> Enum.sum()
|
||||
center = 70
|
||||
radius = 56
|
||||
|
||||
{slices, _angle} =
|
||||
if total > 0 do
|
||||
series
|
||||
|> Enum.with_index()
|
||||
|> Enum.map_reduce(0.0, fn {entry, i}, current ->
|
||||
slice_angle = entry.value / total * 360
|
||||
end_angle = current + slice_angle
|
||||
|
||||
slice =
|
||||
if slice_angle >= 359.99 do
|
||||
%{full_circle: true, d: nil, color: color(i), label: entry.label, value: entry.value}
|
||||
else
|
||||
%{
|
||||
full_circle: false,
|
||||
d: describe_slice(center, radius, current, end_angle),
|
||||
color: color(i),
|
||||
label: entry.label,
|
||||
value: entry.value
|
||||
}
|
||||
end
|
||||
|
||||
{slice, end_angle}
|
||||
end)
|
||||
else
|
||||
{[], 0.0}
|
||||
end
|
||||
|
||||
%{
|
||||
size: 140,
|
||||
center: center,
|
||||
radius: radius,
|
||||
donut_inner: radius * 0.58,
|
||||
total: total,
|
||||
slices: slices,
|
||||
legend: legend(series)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Line/area geometry: gridlines + Y labels, the polyline points, an optional
|
||||
area polygon, the data dots and the X-axis labels.
|
||||
"""
|
||||
@spec line([map()], boolean()) :: map()
|
||||
def line(series, area?) do
|
||||
plot_width = @line_width - @pad_left - @pad_right
|
||||
plot_height = @line_height - @pad_top - @pad_bottom
|
||||
max_value = Enum.max([0 | Enum.map(series, & &1.value)])
|
||||
ticks = y_ticks(max_value)
|
||||
y_max = List.last(ticks)
|
||||
len = length(series)
|
||||
x_step = if len > 1, do: plot_width / (len - 1), else: 0.0
|
||||
|
||||
points =
|
||||
series
|
||||
|> Enum.with_index()
|
||||
|> Enum.map(fn {entry, i} ->
|
||||
x = @pad_left + if(len > 1, do: i * x_step, else: plot_width / 2)
|
||||
y = @pad_top + if(y_max > 0, do: (1 - entry.value / y_max) * plot_height, else: plot_height)
|
||||
%{x: x, y: y, label: entry.label, value: entry.value}
|
||||
end)
|
||||
|
||||
polyline = Enum.map_join(points, " ", fn p -> "#{fmt(p.x)},#{fmt(p.y)}" end)
|
||||
baseline = @pad_top + plot_height
|
||||
|
||||
area_points =
|
||||
case {area?, points} do
|
||||
{true, [_ | _]} ->
|
||||
first = List.first(points)
|
||||
last = List.last(points)
|
||||
"#{fmt(first.x)},#{fmt(baseline)} #{polyline} #{fmt(last.x)},#{fmt(baseline)}"
|
||||
|
||||
_ ->
|
||||
""
|
||||
end
|
||||
|
||||
grid =
|
||||
Enum.map(ticks, fn tick ->
|
||||
y = @pad_top + if(y_max > 0, do: (1 - tick / y_max) * plot_height, else: plot_height)
|
||||
|
||||
%{
|
||||
y: fmt(y),
|
||||
x1: fmt(@pad_left),
|
||||
x2: fmt(@pad_left + plot_width),
|
||||
label: fmt(tick),
|
||||
label_x: fmt(@pad_left - 4)
|
||||
}
|
||||
end)
|
||||
|
||||
%{
|
||||
view_box: "0 0 #{@line_width} #{@line_height}",
|
||||
area?: area?,
|
||||
area_points: area_points,
|
||||
polyline: polyline,
|
||||
grid: grid,
|
||||
dots: Enum.map(points, fn p -> %{x: fmt(p.x), y: fmt(p.y), label: p.label, value: p.value} end),
|
||||
x_labels:
|
||||
Enum.map(points, fn p -> %{x: fmt(p.x), y: fmt(@line_height - 4), label: p.label} end)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Heatmap grid: column labels plus rows of colour-scaled cells. Each series
|
||||
entry is a row and each of its segments is a column.
|
||||
"""
|
||||
@spec heatmap([map()]) :: map()
|
||||
def heatmap(series) do
|
||||
rows_src = Enum.filter(series, &(&1.segments != []))
|
||||
columns = rows_src |> Enum.flat_map(fn e -> Enum.map(e.segments, & &1.label) end) |> Enum.uniq()
|
||||
|
||||
global_max =
|
||||
case Enum.flat_map(rows_src, fn e -> Enum.map(e.segments, & &1.value) end) do
|
||||
[] -> 0
|
||||
values -> Enum.max(values)
|
||||
end
|
||||
|
||||
rows =
|
||||
Enum.map(rows_src, fn entry ->
|
||||
seg_map = Map.new(entry.segments, fn s -> {s.label, s.value} end)
|
||||
|
||||
cells =
|
||||
Enum.map(columns, fn col ->
|
||||
value = Map.get(seg_map, col, 0)
|
||||
alpha = if global_max > 0, do: value / global_max, else: 0.0
|
||||
{bg, fg} = cell_colors(alpha)
|
||||
%{value: value, bg: bg, fg: fg}
|
||||
end)
|
||||
|
||||
%{label: entry.label, cells: cells}
|
||||
end)
|
||||
|
||||
%{columns: columns, column_count: length(columns), rows: rows}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stacked-bar geometry: each row's segments with widths and colours, plus a
|
||||
legend of the unique segment labels.
|
||||
"""
|
||||
@spec stacked([map()]) :: map()
|
||||
def stacked(series) do
|
||||
seg_labels =
|
||||
series |> Enum.flat_map(fn e -> Enum.map(e.segments, & &1.label) end) |> Enum.uniq()
|
||||
|
||||
totals =
|
||||
Enum.map(series, fn e ->
|
||||
if e.segments == [], do: e.value, else: Enum.sum(Enum.map(e.segments, & &1.value))
|
||||
end)
|
||||
|
||||
max_total = Enum.max([0 | totals])
|
||||
|
||||
rows =
|
||||
series
|
||||
|> Enum.zip(totals)
|
||||
|> Enum.map(fn {entry, total} ->
|
||||
segments =
|
||||
Enum.map(entry.segments, fn s ->
|
||||
width = if max_total > 0, do: s.value / max_total * 100, else: 0.0
|
||||
|
||||
%{
|
||||
label: s.label,
|
||||
value: s.value,
|
||||
width: fmt(width),
|
||||
color: color(Enum.find_index(seg_labels, &(&1 == s.label)) || 0)
|
||||
}
|
||||
end)
|
||||
|
||||
%{label: entry.label, total: total, segments: segments}
|
||||
end)
|
||||
|
||||
legend = seg_labels |> Enum.with_index() |> Enum.map(fn {l, i} -> %{label: l, color: color(i)} end)
|
||||
|
||||
%{rows: rows, legend: legend, max_total: max_total}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Nice, rounded Y-axis tick values for `max_value`. Always starts at 0 and ends
|
||||
at or above `max_value`.
|
||||
"""
|
||||
@spec y_ticks(number(), pos_integer()) :: [number()]
|
||||
def y_ticks(max_value, tick_count \\ 4)
|
||||
def y_ticks(max_value, _tick_count) when max_value <= 0, do: [0]
|
||||
|
||||
def y_ticks(max_value, tick_count) do
|
||||
raw_step = max_value / (tick_count - 1)
|
||||
magnitude = :math.pow(10, Float.floor(:math.log10(raw_step)))
|
||||
normalised = raw_step / magnitude
|
||||
|
||||
nice_step =
|
||||
cond do
|
||||
normalised <= 1 -> magnitude
|
||||
normalised <= 2 -> 2 * magnitude
|
||||
normalised <= 5 -> 5 * magnitude
|
||||
true -> 10 * magnitude
|
||||
end
|
||||
|
||||
ticks =
|
||||
0.0
|
||||
|> Stream.iterate(&(&1 + nice_step))
|
||||
|> Enum.take_while(&(&1 <= max_value + nice_step * 0.01))
|
||||
|> Enum.map(&Float.round(&1, 6))
|
||||
|
||||
if length(ticks) < 2, do: ticks ++ [Float.round(nice_step, 6)], else: ticks
|
||||
end
|
||||
|
||||
# ── private helpers ──────────────────────────────────────────────────
|
||||
|
||||
defp legend(series) do
|
||||
series |> Enum.with_index() |> Enum.map(fn {e, i} -> %{label: e.label, color: color(i)} end)
|
||||
end
|
||||
|
||||
defp describe_slice(center, radius, start_angle, end_angle) do
|
||||
start_rad = (start_angle - 90) * :math.pi() / 180
|
||||
end_rad = (end_angle - 90) * :math.pi() / 180
|
||||
x1 = center + radius * :math.cos(start_rad)
|
||||
y1 = center + radius * :math.sin(start_rad)
|
||||
x2 = center + radius * :math.cos(end_rad)
|
||||
y2 = center + radius * :math.sin(end_rad)
|
||||
large_arc = if end_angle - start_angle > 180, do: 1, else: 0
|
||||
|
||||
"M#{fmt(center)},#{fmt(center)} L#{fmt(x1)},#{fmt(y1)} " <>
|
||||
"A#{fmt(radius)},#{fmt(radius)} 0 #{large_arc} 1 #{fmt(x2)},#{fmt(y2)} Z"
|
||||
end
|
||||
|
||||
defp cell_colors(alpha) when alpha <= 0, do: {"transparent", "inherit"}
|
||||
|
||||
defp cell_colors(alpha) do
|
||||
{r, g, b} = lerp_rgb({53, 117, 56}, {183, 72, 72}, alpha)
|
||||
opacity = 0.25 + alpha * 0.75
|
||||
bg = "rgba(#{r},#{g},#{b},#{Float.round(opacity, 2)})"
|
||||
er = round(r * opacity + 30 * (1 - opacity))
|
||||
eg = round(g * opacity + 30 * (1 - opacity))
|
||||
eb = round(b * opacity + 30 * (1 - opacity))
|
||||
fg = if 0.299 * er + 0.587 * eg + 0.114 * eb > 140, do: "#000", else: "#fff"
|
||||
{bg, fg}
|
||||
end
|
||||
|
||||
defp lerp_rgb({ar, ag, ab}, {br, bg, bb}, t) do
|
||||
{round(ar + (br - ar) * t), round(ag + (bg - ag) * t), round(ab + (bb - ab) * t)}
|
||||
end
|
||||
|
||||
defp fmt(n) when is_integer(n), do: Integer.to_string(n)
|
||||
|
||||
defp fmt(n) when is_float(n) do
|
||||
rounded = Float.round(n, 2)
|
||||
if rounded == Float.round(rounded, 0), do: Integer.to_string(trunc(rounded)), else: :erlang.float_to_binary(rounded, [:short])
|
||||
end
|
||||
end
|
||||
@@ -189,6 +189,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
||||
|> mark_surfaces_expanded(assigns)
|
||||
end
|
||||
|
||||
# Only called from pending_user_message/2, which already narrows the
|
||||
# request to %{message: binary}.
|
||||
defp persisted_user_message_for_request?(messages, %{message: message} = request)
|
||||
when is_binary(message) do
|
||||
messages
|
||||
@@ -198,8 +200,6 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
||||
end)
|
||||
end
|
||||
|
||||
defp persisted_user_message_for_request?(_messages, _request), do: false
|
||||
|
||||
defp persisted_assistant_content_for_request?(messages, request, content)
|
||||
when is_binary(content) and content != "" do
|
||||
messages
|
||||
|
||||
@@ -87,7 +87,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|
||||
%{
|
||||
label: map_value(entry, "label", dgettext("ui", "Assistant")),
|
||||
value: numeric_value(map_value(entry, "value", 0)),
|
||||
segments: List.wrap(map_value(entry, "segments", []))
|
||||
segments:
|
||||
entry
|
||||
|> map_value("segments", [])
|
||||
|> List.wrap()
|
||||
|> Enum.map(fn segment ->
|
||||
%{
|
||||
label: map_value(segment, "label", ""),
|
||||
value: numeric_value(map_value(segment, "value", 0))
|
||||
}
|
||||
end)
|
||||
}
|
||||
end)
|
||||
|
||||
@@ -95,7 +104,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|
||||
id: surface_id,
|
||||
type: "chart",
|
||||
title: map_value(arguments, "title"),
|
||||
chart_type: map_value(arguments, "chart_type", "bar"),
|
||||
chart_type: map_value(arguments, "chartType") || map_value(arguments, "chart_type", "bar"),
|
||||
series: series,
|
||||
max_value: Enum.max([0 | Enum.map(series, & &1.value)])
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@
|
||||
|
||||
<form class="chat-input-wrapper flex items-end gap-2" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message" phx-target={@myself}>
|
||||
<textarea class="chat-input chat-surface-input ui-textarea" name="message" rows="1" placeholder={dgettext("ui", "Type a message...")} disabled={@chat_editor.is_streaming}><%= @chat_editor.input %></textarea>
|
||||
<button class="chat-send-button ui-button ui-button-primary" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself} disabled={@chat_editor.send_disabled?}>↑</button>
|
||||
<button class="chat-send-button ui-button ui-button-primary" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself}>↑</button>
|
||||
</form>
|
||||
|
||||
<%= if @chat_editor.action_error do %>
|
||||
|
||||
110
lib/bds/desktop/shell_live/editor_image_drop.ex
Normal file
110
lib/bds/desktop/shell_live/editor_image_drop.ex
Normal file
@@ -0,0 +1,110 @@
|
||||
defmodule BDS.Desktop.ShellLive.EditorImageDrop do
|
||||
@moduledoc false
|
||||
|
||||
# Implements the drag-and-drop image chain described in
|
||||
# action_patterns.allium DragDropImageChain (trigger: editor_post.allium
|
||||
# PostDragDropImage). A single image file dropped on the post editor body
|
||||
# runs four synchronous steps the user waits on, then two background steps
|
||||
# whose results are auto-applied without a modal.
|
||||
|
||||
require Logger
|
||||
|
||||
alias BDS.{AI, Media, Metadata, Posts}
|
||||
|
||||
@doc """
|
||||
Synchronous portion of the chain (steps 1-4):
|
||||
|
||||
1. importMedia(file) -> media record + file copy + base sidecar
|
||||
2. generateThumbnails(media) -> small/medium/large/ai (done inside import_media)
|
||||
3. linkMediaToPost(media, post) -> update sidecar linkedPostIds
|
||||
4. caller inserts the returned markdown at the cursor
|
||||
|
||||
Returns `{:ok, media, markdown}` where `markdown` is the reference inserted at
|
||||
the cursor. These steps are not AI activities, so they run regardless of
|
||||
airplane mode.
|
||||
"""
|
||||
@spec import_and_link(String.t(), String.t(), String.t()) ::
|
||||
{:ok, Media.Media.t(), String.t()} | {:error, term()}
|
||||
def import_and_link(project_id, post_id, source_path) do
|
||||
with {:ok, media} <-
|
||||
Media.import_media(%{project_id: project_id, source_path: source_path}),
|
||||
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
|
||||
{:ok, media, markdown_for(media)}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Markdown reference inserted at the cursor (step 4): `` for
|
||||
images, a plain link for other file types.
|
||||
"""
|
||||
@spec markdown_for(Media.Media.t()) :: String.t()
|
||||
def markdown_for(media) do
|
||||
if String.starts_with?(media.mime_type || "", "image/") do
|
||||
""
|
||||
else
|
||||
"[#{media.original_name}](bds-media://#{media.id})"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Background portion of the chain (steps 5-6), gated behind airplane mode:
|
||||
|
||||
5. aiImageAnalysis(media) -> results auto-applied to media metadata (no modal)
|
||||
6. if auto-translate enabled (post.do_not_translate == false):
|
||||
translateMediaMetadata(media, lang) for each blog language
|
||||
|
||||
Only runs for images. Failures are logged and never roll back the import.
|
||||
"""
|
||||
@spec enrich(Media.Media.t(), String.t(), String.t()) :: :ok
|
||||
def enrich(media, post_id, language) do
|
||||
if image?(media) do
|
||||
with {:ok, result} <- AI.analyze_image(media.id, language: language),
|
||||
{:ok, _updated} <-
|
||||
Media.update_media(media.id, %{
|
||||
title: result.title,
|
||||
alt: result.alt,
|
||||
caption: result.caption
|
||||
}) do
|
||||
maybe_translate(media.id, post_id, language)
|
||||
else
|
||||
{:error, reason} ->
|
||||
Logger.warning("Drag-drop AI analysis failed for #{media.id}: #{inspect(reason)}")
|
||||
end
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp maybe_translate(media_id, post_id, language) do
|
||||
post = Posts.get_post(post_id)
|
||||
|
||||
if post && not post.do_not_translate do
|
||||
translate_targets(post.project_id, language)
|
||||
|> Enum.each(fn target ->
|
||||
case AI.translate_media(media_id, target) do
|
||||
{:ok, translation} ->
|
||||
Media.upsert_media_translation(media_id, target, %{
|
||||
title: translation.title,
|
||||
alt: translation.alt,
|
||||
caption: translation.caption
|
||||
})
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Drag-drop media translation failed for #{media_id} -> #{target}: #{inspect(reason)}"
|
||||
)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
defp translate_targets(project_id, language) do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
|
||||
[metadata.main_language | metadata.blog_languages || []]
|
||||
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
||||
|> Enum.uniq()
|
||||
end
|
||||
|
||||
defp image?(media), do: String.starts_with?(media.mime_type || "", "image/")
|
||||
end
|
||||
214
lib/bds/desktop/shell_live/gallery_import.ex
Normal file
214
lib/bds/desktop/shell_live/gallery_import.ex
Normal file
@@ -0,0 +1,214 @@
|
||||
defmodule BDS.Desktop.ShellLive.GalleryImport do
|
||||
@moduledoc false
|
||||
|
||||
require Logger
|
||||
|
||||
alias BDS.{AI, Media, Metadata}
|
||||
|
||||
@doc """
|
||||
Starts the image import pipeline: for each selected path, imports the file,
|
||||
runs AI analysis, updates metadata, links to the post, and translates to
|
||||
all configured blog languages.
|
||||
|
||||
Processes images with a concurrency cap via a sliding window.
|
||||
"""
|
||||
@spec start(list(String.t()), String.t(), String.t(), String.t(), integer(), pid()) :: :ok
|
||||
def start(paths, project_id, post_id, language, concurrency_limit, parent) do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
main_language = metadata.main_language || language
|
||||
blog_languages = metadata.blog_languages || []
|
||||
|
||||
translate_targets =
|
||||
[main_language | blog_languages]
|
||||
|> Enum.reject(&(&1 == language or is_nil(&1)))
|
||||
|> Enum.uniq()
|
||||
|
||||
{in_flight, remaining} = Enum.split(paths, concurrency_limit)
|
||||
|
||||
tasks =
|
||||
Enum.map(in_flight, fn path ->
|
||||
Task.async(fn ->
|
||||
process_single_image(path, project_id, post_id, language, translate_targets, parent)
|
||||
end)
|
||||
end)
|
||||
|
||||
known_refs = MapSet.new(tasks, & &1.ref)
|
||||
|
||||
drain_tasks(
|
||||
remaining,
|
||||
tasks,
|
||||
known_refs,
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
)
|
||||
|
||||
send(parent, {:add_images_complete, length(paths)})
|
||||
end
|
||||
|
||||
defp drain_tasks(
|
||||
[],
|
||||
tasks,
|
||||
_known_refs,
|
||||
_project_id,
|
||||
_post_id,
|
||||
_language,
|
||||
_translate_targets,
|
||||
_parent
|
||||
) do
|
||||
Enum.each(tasks, fn task -> Task.await(task, :infinity) end)
|
||||
end
|
||||
|
||||
defp drain_tasks(
|
||||
[next_path | rest],
|
||||
tasks,
|
||||
known_refs,
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
) do
|
||||
receive do
|
||||
{ref, _result} when is_reference(ref) ->
|
||||
if MapSet.member?(known_refs, ref) do
|
||||
{_completed_task, remaining_tasks} = pop_task_by_ref(tasks, ref)
|
||||
|
||||
new_task =
|
||||
Task.async(fn ->
|
||||
process_single_image(
|
||||
next_path,
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
)
|
||||
end)
|
||||
|
||||
drain_tasks(
|
||||
rest,
|
||||
[new_task | remaining_tasks],
|
||||
MapSet.put(MapSet.delete(known_refs, ref), new_task.ref),
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
)
|
||||
else
|
||||
drain_tasks(
|
||||
[next_path | rest],
|
||||
tasks,
|
||||
known_refs,
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
)
|
||||
end
|
||||
|
||||
{:DOWN, ref, :process, _pid, _reason} when is_reference(ref) ->
|
||||
if MapSet.member?(known_refs, ref) do
|
||||
{_completed_task, remaining_tasks} = pop_task_by_ref(tasks, ref)
|
||||
|
||||
new_task =
|
||||
Task.async(fn ->
|
||||
process_single_image(
|
||||
next_path,
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
)
|
||||
end)
|
||||
|
||||
drain_tasks(
|
||||
rest,
|
||||
[new_task | remaining_tasks],
|
||||
MapSet.put(MapSet.delete(known_refs, ref), new_task.ref),
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
)
|
||||
else
|
||||
drain_tasks(
|
||||
[next_path | rest],
|
||||
tasks,
|
||||
known_refs,
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp pop_task_by_ref(tasks, ref) do
|
||||
Enum.reduce(tasks, {nil, []}, fn
|
||||
%{ref: ^ref} = task, {nil, rest} -> {task, rest}
|
||||
task, {found, rest} -> {found, [task | rest]}
|
||||
end)
|
||||
end
|
||||
|
||||
defp process_single_image(
|
||||
path,
|
||||
project_id,
|
||||
post_id,
|
||||
language,
|
||||
translate_targets,
|
||||
parent
|
||||
) do
|
||||
with {:ok, media} <- Media.import_media(%{project_id: project_id, source_path: path}),
|
||||
true <- String.starts_with?(media.mime_type || "", "image/"),
|
||||
{:ok, result} <- AI.analyze_image(media.id, language: language),
|
||||
{:ok, _updated} <-
|
||||
Media.update_media(media.id, %{
|
||||
title: result.title,
|
||||
alt: result.alt,
|
||||
caption: result.caption
|
||||
}),
|
||||
{:ok, _link} <- Media.link_media_to_post(media.id, post_id) do
|
||||
translate_media_translations(media.id, translate_targets)
|
||||
title = result.title || media.original_name
|
||||
send(parent, {:add_image_processed, title})
|
||||
else
|
||||
false ->
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning("Image pipeline error for #{path}: #{inspect(reason)}")
|
||||
send(parent, {:add_image_error, path, reason})
|
||||
end
|
||||
end
|
||||
|
||||
defp translate_media_translations(_media_id, []), do: :ok
|
||||
|
||||
defp translate_media_translations(media_id, [target | rest]) do
|
||||
case AI.translate_media(media_id, target) do
|
||||
{:ok, translation} ->
|
||||
Media.upsert_media_translation(media_id, target, %{
|
||||
title: translation.title,
|
||||
alt: translation.alt,
|
||||
caption: translation.caption
|
||||
})
|
||||
|
||||
translate_media_translations(media_id, rest)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.warning(
|
||||
"Media translation failed for #{media_id} -> #{target}: #{inspect(reason)}"
|
||||
)
|
||||
|
||||
translate_media_translations(media_id, rest)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
|
||||
alias BDS.{AI, ImportAnalysis, ImportDefinitions, ImportExecution}
|
||||
alias BDS.Desktop.{FilePicker, FolderPicker, ShellData}
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
|
||||
alias BDS.Desktop.ShellLive.ImportEditor.{
|
||||
AnalysisState,
|
||||
@@ -433,7 +434,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
socket =
|
||||
with %{} = definition <- ImportDefinitions.get_definition(definition_id),
|
||||
%{} = report <- ImportDefinitions.decode_analysis_result(definition) do
|
||||
if socket.assigns.offline_mode? do
|
||||
if socket.assigns.offline_mode? and not AI.airplane_endpoint_configured?() do
|
||||
notify_output(
|
||||
dgettext("ui", "Import"),
|
||||
BDS.Gettext.lgettext(
|
||||
@@ -641,12 +642,14 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
defp maybe_update_tab_meta(socket, name) do
|
||||
title = name || dgettext("ui", "Untitled Import")
|
||||
|
||||
notify_parent(
|
||||
{:import_editor_tab_meta, socket.assigns.definition_id, title,
|
||||
Notify.tab_meta(
|
||||
:import,
|
||||
socket.assigns.definition_id,
|
||||
title,
|
||||
dgettext(
|
||||
"ui",
|
||||
"Select a WordPress export file (WXR) and an uploads folder to analyze what would be imported."
|
||||
)}
|
||||
)
|
||||
)
|
||||
|
||||
socket
|
||||
@@ -1353,7 +1356,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
class="taxonomy-mapping-input"
|
||||
type="text"
|
||||
name="mapped_to"
|
||||
value={Map.get(@edit || %{}, :value, Map.get(item, :mapped_to) || "") || ""}
|
||||
value={Map.get(@edit, :value, Map.get(item, :mapped_to) || "") || ""}
|
||||
placeholder={dgettext("ui", "Map to...")}
|
||||
list={"taxonomy-suggestions-#{@type}"}
|
||||
autocomplete="off"
|
||||
@@ -1404,12 +1407,8 @@ defmodule BDS.Desktop.ShellLive.ImportEditor do
|
||||
|
||||
# ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
defp notify_parent(message) do
|
||||
send(self(), message)
|
||||
end
|
||||
|
||||
defp notify_output(socket, title, message, level \\ "info") do
|
||||
notify_parent({:import_editor_output, title, message, level})
|
||||
Notify.output(title, message, level)
|
||||
socket
|
||||
end
|
||||
|
||||
|
||||
@@ -266,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
||||
@spec default_author(term()) :: term()
|
||||
def default_author(project_id) do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
Map.get(metadata, :default_author)
|
||||
metadata.default_author
|
||||
end
|
||||
|
||||
@spec suggested_definition_name(term()) :: term()
|
||||
|
||||
@@ -82,7 +82,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
||||
%{} = definition <- ImportDefinitions.get_definition(definition_id),
|
||||
%{} = report <- ImportDefinitions.decode_analysis_result(definition) do
|
||||
cond do
|
||||
socket.assigns.offline_mode ->
|
||||
socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() ->
|
||||
socket
|
||||
|> append_output.(
|
||||
dgettext("ui", "Import"),
|
||||
@@ -222,7 +222,7 @@ defmodule BDS.Desktop.ShellLive.ImportEditor.TaxonomyEditing do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
|
||||
%{
|
||||
categories: Enum.uniq(Map.get(metadata, :categories, []) || []),
|
||||
categories: Enum.uniq(metadata.categories || []),
|
||||
tags: project_id |> Tags.list_tags() |> Enum.map(& &1.name) |> Enum.uniq()
|
||||
}
|
||||
end
|
||||
|
||||
@@ -568,7 +568,7 @@
|
||||
</div>
|
||||
|
||||
<footer class="status-bar flex h-[22px] shrink-0 items-center justify-between gap-2" data-region="status-bar" data-testid="status-bar">
|
||||
<div class="status-bar-left flex min-w-0 items-center gap-2 overflow-hidden">
|
||||
<div class="status-bar-left flex min-w-0 items-center gap-2">
|
||||
<%= if @is_mac_ui do %>
|
||||
<div class="status-shell-controls flex items-center gap-1" data-testid="status-shell-controls">
|
||||
<button
|
||||
@@ -659,7 +659,7 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<button class="status-bar-item status-bar-task-button inline-flex items-center gap-2" data-testid="status-task-button" type="button" phx-click="open_tasks_panel">
|
||||
<button class="status-bar-item status-bar-task-button inline-flex min-w-0 items-center gap-2" data-testid="status-task-button" type="button" phx-click="open_tasks_panel">
|
||||
<%= if @status.left.running_task_message do %>
|
||||
<span class="task-spinner"></span>
|
||||
<% end %>
|
||||
|
||||
@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.Desktop.{FilePicker}
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
alias BDS.{AI, I18n, Media}
|
||||
alias BDS.Media.Media, as: MediaRecord
|
||||
alias BDS.Media.Translation
|
||||
@@ -90,7 +91,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
|> build_data()
|
||||
|
||||
if dirty? != was_dirty? do
|
||||
notify_parent({:media_editor_dirty, socket.assigns.media_id, dirty?})
|
||||
Notify.dirty(:media, socket.assigns.media_id, dirty?)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
@@ -123,11 +124,13 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
|> assign(:dirty?, false)
|
||||
|> build_data()
|
||||
|
||||
notify_parent({:media_editor_dirty, media.id, false})
|
||||
Notify.dirty(:media, media.id, false)
|
||||
|
||||
notify_parent(
|
||||
{:media_editor_tab_meta, media.id, display_title(updated_media),
|
||||
updated_media.original_name || updated_media.mime_type || ""}
|
||||
Notify.tab_meta(
|
||||
:media,
|
||||
media.id,
|
||||
display_title(updated_media),
|
||||
updated_media.original_name || updated_media.mime_type || ""
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
@@ -150,7 +153,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
end
|
||||
|
||||
def handle_event("detect_media_editor_language", _params, socket) do
|
||||
if socket.assigns.offline_mode do
|
||||
if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
|
||||
notify_output(
|
||||
socket,
|
||||
dgettext("ui", "Detect Language"),
|
||||
@@ -343,7 +346,7 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
def handle_event("refresh_media_translation", %{"language" => language}, socket) do
|
||||
media = socket.assigns.media
|
||||
|
||||
if socket.assigns.offline_mode do
|
||||
if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
|
||||
notify_output(
|
||||
socket,
|
||||
dgettext("ui", "Translate"),
|
||||
@@ -483,11 +486,13 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
|> assign(:save_state, :saved)
|
||||
|> build_data()
|
||||
|
||||
notify_parent({:media_editor_dirty, media.id, false})
|
||||
Notify.dirty(:media, media.id, false)
|
||||
|
||||
notify_parent(
|
||||
{:media_editor_tab_meta, media.id, display_title(updated_media),
|
||||
updated_media.original_name || updated_media.mime_type || ""}
|
||||
Notify.tab_meta(
|
||||
:media,
|
||||
media.id,
|
||||
display_title(updated_media),
|
||||
updated_media.original_name || updated_media.mime_type || ""
|
||||
)
|
||||
|
||||
notify_output(socket, dgettext("ui", "Media"), dgettext("ui", "Media saved"))
|
||||
@@ -528,13 +533,13 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
|> assign(:quick_actions_open?, false)
|
||||
|> build_data()
|
||||
|
||||
notify_parent({:media_editor_dirty, media.id, dirty?})
|
||||
Notify.dirty(:media, media.id, dirty?)
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp do_translate(socket, language) do
|
||||
if socket.assigns.offline_mode do
|
||||
if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
|
||||
notify_output(
|
||||
socket,
|
||||
dgettext("ui", "Translate"),
|
||||
@@ -569,12 +574,8 @@ defmodule BDS.Desktop.ShellLive.MediaEditor do
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_parent(message) do
|
||||
send(self(), message)
|
||||
end
|
||||
|
||||
defp notify_output(socket, title, message, level \\ "info") do
|
||||
send(self(), {:media_editor_output, title, message, level})
|
||||
Notify.output(title, message, level)
|
||||
socket
|
||||
end
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
||||
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
|
||||
alias BDS.Desktop.ShellLive.MenuEditor.{
|
||||
DraftManagement,
|
||||
PageCategory,
|
||||
@@ -251,7 +253,7 @@ defmodule BDS.Desktop.ShellLive.MenuEditor do
|
||||
end
|
||||
|
||||
defp notify_output(title, message, level) do
|
||||
send(self(), {:menu_editor_output, title, message, level})
|
||||
Notify.output(title, message, level)
|
||||
end
|
||||
|
||||
attr(:menu_editor, :map, required: true)
|
||||
|
||||
@@ -180,6 +180,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
|
||||
end
|
||||
|
||||
@spec move_selected(term(), term()) :: term()
|
||||
def move_selected(%{selected_id: @home_item_id} = state, _direction), do: state
|
||||
|
||||
def move_selected(%{selected_id: selected_id} = state, direction)
|
||||
when direction in [:up, :down] do
|
||||
case find_path(state.items, selected_id) do
|
||||
@@ -209,6 +211,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
|
||||
end
|
||||
|
||||
@spec indent_selected(term()) :: term()
|
||||
def indent_selected(%{selected_id: @home_item_id} = state), do: state
|
||||
|
||||
def indent_selected(%{selected_id: selected_id} = state) do
|
||||
case find_path(state.items, selected_id) do
|
||||
nil ->
|
||||
@@ -249,6 +253,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
|
||||
end
|
||||
|
||||
@spec unindent_selected(term()) :: term()
|
||||
def unindent_selected(%{selected_id: @home_item_id} = state), do: state
|
||||
|
||||
def unindent_selected(%{selected_id: selected_id} = state) do
|
||||
case find_path(state.items, selected_id) do
|
||||
nil ->
|
||||
@@ -295,6 +301,8 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreeOps do
|
||||
when drag_item_id == target_item_id,
|
||||
do: state
|
||||
|
||||
def drop_selected(state, @home_item_id, _target_item_id, _position), do: state
|
||||
|
||||
@spec drop_selected(term(), term(), term(), term()) :: term()
|
||||
def drop_selected(state, drag_item_id, target_item_id, position) do
|
||||
drag_path = find_path(state.items, drag_item_id)
|
||||
|
||||
@@ -5,6 +5,9 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
|
||||
|
||||
@spec can_move_up?(term(), term()) :: term()
|
||||
def can_move_up?(items, selected_id) do
|
||||
if selected_id == TreeOps.home_item_id() do
|
||||
false
|
||||
else
|
||||
case TreeOps.find_path(items, selected_id) do
|
||||
[_parent, index] -> index > 0
|
||||
[index] -> index > 0
|
||||
@@ -12,9 +15,13 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
|
||||
_other -> false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec can_move_down?(term(), term()) :: term()
|
||||
def can_move_down?(items, selected_id) do
|
||||
if selected_id == TreeOps.home_item_id() do
|
||||
false
|
||||
else
|
||||
case TreeOps.find_path(items, selected_id) do
|
||||
nil ->
|
||||
false
|
||||
@@ -25,9 +32,13 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
|
||||
index < length(TreeOps.items_at_path(items, parent_path)) - 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec can_indent?(term(), term()) :: term()
|
||||
def can_indent?(items, selected_id) do
|
||||
if selected_id == TreeOps.home_item_id() do
|
||||
false
|
||||
else
|
||||
case TreeOps.find_path(items, selected_id) do
|
||||
nil ->
|
||||
false
|
||||
@@ -49,15 +60,20 @@ defmodule BDS.Desktop.ShellLive.MenuEditor.TreePredicates do
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec can_unindent?(term(), term()) :: term()
|
||||
def can_unindent?(items, selected_id) do
|
||||
if selected_id == TreeOps.home_item_id() do
|
||||
false
|
||||
else
|
||||
case TreeOps.find_path(items, selected_id) do
|
||||
[_index] -> false
|
||||
path when is_list(path) -> length(path) > 1
|
||||
_other -> false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@spec can_delete?(term()) :: term()
|
||||
def can_delete?(selected_id),
|
||||
|
||||
@@ -7,6 +7,7 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.{Embeddings, Generation, Git, HelpDocs, Posts, Repo}
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
alias BDS.MapUtils
|
||||
alias BDS.Settings.Setting
|
||||
use Gettext, backend: BDS.Gettext
|
||||
@@ -358,19 +359,19 @@ defmodule BDS.Desktop.ShellLive.MiscEditor do
|
||||
# ── Private helpers ────────────────────────────────────────────────────────
|
||||
|
||||
defp notify_command(action, params \\ %{}) do
|
||||
send(self(), {:misc_editor_command, action, params})
|
||||
Notify.command(action, params)
|
||||
end
|
||||
|
||||
defp notify_output(title, message, detail \\ nil, level \\ "info") do
|
||||
send(self(), {:misc_editor_output, title, message, detail, level})
|
||||
Notify.output(title, message, detail, level)
|
||||
end
|
||||
|
||||
defp notify_tab_meta(tab_type, tab_id, updates) do
|
||||
send(self(), {:misc_editor_tab_meta, tab_type, tab_id, updates})
|
||||
Notify.tab_meta_merge(tab_type, tab_id, updates)
|
||||
end
|
||||
|
||||
defp notify_open_sidebar_item(params, intent) do
|
||||
send(self(), {:open_sidebar_item, params, intent})
|
||||
Notify.open_sidebar_item(params, intent)
|
||||
end
|
||||
|
||||
defp rerun_action(assigns) do
|
||||
|
||||
82
lib/bds/desktop/shell_live/notify.ex
Normal file
82
lib/bds/desktop/shell_live/notify.ex
Normal file
@@ -0,0 +1,82 @@
|
||||
defmodule BDS.Desktop.ShellLive.Notify do
|
||||
@moduledoc """
|
||||
Standardized parent notification API for LiveComponent editors.
|
||||
|
||||
Instead of each editor defining its own `notify_parent/1` and sending
|
||||
editor-specific message atoms (e.g. `{:post_editor_output, ...}`),
|
||||
all editors call functions from this module, which sends generic
|
||||
messages that Bridges handles with a single clause per action type.
|
||||
"""
|
||||
|
||||
@spec output(String.t(), String.t(), String.t()) :: :ok
|
||||
def output(title, message, level) do
|
||||
send(self(), {:editor_output, title, message, nil, level})
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec output(String.t(), String.t(), String.t() | nil, String.t()) :: :ok
|
||||
def output(title, message, detail, level) do
|
||||
send(self(), {:editor_output, title, message, detail, level})
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec tab_meta(atom(), term(), String.t(), String.t()) :: :ok
|
||||
def tab_meta(type, id, title, subtitle) do
|
||||
send(self(), {:editor_tab_meta, type, id, %{title: title, subtitle: subtitle || ""}})
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec tab_meta_merge(atom(), term(), map()) :: :ok
|
||||
def tab_meta_merge(type, id, updates) when is_map(updates) do
|
||||
send(self(), {:editor_tab_meta, type, id, updates})
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec close_tab(atom(), term()) :: :ok
|
||||
def close_tab(type, id) do
|
||||
send(self(), {:close_tab, type, id})
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec reload :: :ok
|
||||
def reload do
|
||||
send(self(), :reload_shell)
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec dirty(atom(), term(), boolean()) :: :ok
|
||||
def dirty(type, id, dirty?) do
|
||||
send(self(), {:editor_dirty, type, id, dirty?})
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec command(atom() | String.t(), map()) :: :ok
|
||||
def command(action, params \\ %{}) do
|
||||
send(self(), {:editor_command, action, params})
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec open_sidebar_item(map(), atom() | nil) :: :ok
|
||||
def open_sidebar_item(params, intent \\ nil) do
|
||||
send(self(), {:open_sidebar_item, params, intent})
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec schedule_auto_save(atom(), term()) :: :ok
|
||||
def schedule_auto_save(type, id) do
|
||||
send(self(), {:schedule_auto_save, type, id})
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec cancel_auto_save(atom(), term()) :: :ok
|
||||
def cancel_auto_save(type, id) do
|
||||
send(self(), {:cancel_auto_save, type, id})
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec parent(term()) :: :ok
|
||||
def parent(message) do
|
||||
send(self(), message)
|
||||
:ok
|
||||
end
|
||||
end
|
||||
@@ -5,6 +5,7 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
import Ecto.Query
|
||||
require Logger
|
||||
|
||||
alias BDS.{I18n, Media, Metadata, Posts, Repo}
|
||||
alias BDS.Media.Media, as: MediaRecord
|
||||
@@ -70,7 +71,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||
metadata
|
||||
rescue
|
||||
_error -> %{main_language: "en", blog_languages: []}
|
||||
error ->
|
||||
log_overlay_warning("project metadata", error)
|
||||
%{main_language: "en", blog_languages: []}
|
||||
end
|
||||
|
||||
defp posts(nil), do: []
|
||||
@@ -137,7 +140,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
select: pm.media_id
|
||||
)
|
||||
rescue
|
||||
_error -> []
|
||||
error ->
|
||||
log_overlay_warning("post media ids for #{post_id}", error)
|
||||
[]
|
||||
end
|
||||
|
||||
defp post_media_ids(_tab), do: []
|
||||
@@ -150,7 +155,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
)
|
||||
|> Map.new(fn {language, status} -> {language, Atom.to_string(status || :draft)} end)
|
||||
rescue
|
||||
_error -> %{}
|
||||
error ->
|
||||
log_overlay_warning("post translations for #{post_id}", error)
|
||||
%{}
|
||||
end
|
||||
|
||||
defp existing_translations(%{type: :media, id: media_id}) do
|
||||
@@ -161,7 +168,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
)
|
||||
|> Map.new(fn {language, status} -> {language, status} end)
|
||||
rescue
|
||||
_error -> %{}
|
||||
error ->
|
||||
log_overlay_warning("media translations for #{media_id}", error)
|
||||
%{}
|
||||
end
|
||||
|
||||
defp existing_translations(_tab), do: %{}
|
||||
@@ -179,7 +188,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
_other -> metadata.main_language || "en"
|
||||
end
|
||||
rescue
|
||||
_error -> metadata.main_language || "en"
|
||||
error ->
|
||||
log_overlay_warning("post source language for #{post_id}", error)
|
||||
metadata.main_language || "en"
|
||||
end
|
||||
|
||||
defp source_language(%{type: :media, id: media_id}, metadata) do
|
||||
@@ -188,7 +199,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
_other -> metadata.main_language || "en"
|
||||
end
|
||||
rescue
|
||||
_error -> metadata.main_language || "en"
|
||||
error ->
|
||||
log_overlay_warning("media source language for #{media_id}", error)
|
||||
metadata.main_language || "en"
|
||||
end
|
||||
|
||||
defp source_language(_tab, metadata), do: metadata.main_language || "en"
|
||||
@@ -242,7 +255,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
[]
|
||||
end
|
||||
rescue
|
||||
_error -> []
|
||||
error ->
|
||||
log_overlay_warning("post AI fields for #{post_id}", error)
|
||||
[]
|
||||
end
|
||||
|
||||
defp ai_fields(%{type: :media, id: media_id}, title, _subtitle, page_language) do
|
||||
@@ -279,7 +294,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
[]
|
||||
end
|
||||
rescue
|
||||
_error -> []
|
||||
error ->
|
||||
log_overlay_warning("media AI fields for #{media_id}", error)
|
||||
[]
|
||||
end
|
||||
|
||||
defp ai_fields(_tab, _title, _subtitle, _page_language), do: []
|
||||
@@ -309,7 +326,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
reference_list: reference_list
|
||||
}
|
||||
rescue
|
||||
_error ->
|
||||
error ->
|
||||
log_overlay_warning("delete media details for #{media_id}", error)
|
||||
|
||||
%{
|
||||
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Media"),
|
||||
entity_name: media_id,
|
||||
@@ -330,7 +349,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
reference_list: []
|
||||
}
|
||||
rescue
|
||||
_error ->
|
||||
error ->
|
||||
log_overlay_warning("delete tag details", error)
|
||||
|
||||
%{
|
||||
title: BDS.Gettext.lgettext(page_language, "ui", "Delete Tag"),
|
||||
entity_name: "tag",
|
||||
@@ -367,7 +388,9 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
message: BDS.Gettext.lgettext(page_language, "ui", "Cannot be undone.")
|
||||
}
|
||||
rescue
|
||||
_error ->
|
||||
error ->
|
||||
log_overlay_warning("merge tag details for project #{project_id}", error)
|
||||
|
||||
%{
|
||||
target: "tag",
|
||||
count: 1,
|
||||
@@ -391,4 +414,8 @@ defmodule BDS.Desktop.ShellLive.OverlayComponents do
|
||||
|> String.replace(~r/[^a-z0-9]+/u, "-")
|
||||
|> String.trim("-")
|
||||
end
|
||||
|
||||
defp log_overlay_warning(context, error) do
|
||||
Logger.warning("overlay component fallback for #{context}: #{Exception.message(error)}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,11 +6,12 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
import Phoenix.Component, only: [assign: 3]
|
||||
import Phoenix.LiveView, only: [send_update: 2]
|
||||
|
||||
alias BDS.{AI, Media, Metadata}
|
||||
alias BDS.{AI, Media, Metadata, Tags}
|
||||
alias BDS.Desktop.{Overlay}
|
||||
|
||||
alias BDS.Desktop.ShellLive.{
|
||||
MediaEditor,
|
||||
Notify,
|
||||
PostEditor,
|
||||
TabHelpers
|
||||
}
|
||||
@@ -65,7 +66,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
|
||||
socket =
|
||||
if kind == "ai_suggestions" and not is_nil(overlay) do
|
||||
if socket.assigns.offline_mode do
|
||||
if socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() do
|
||||
callbacks.append_output.(
|
||||
socket,
|
||||
dgettext("ui", "AI Suggestions"),
|
||||
@@ -170,7 +171,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
"[#{result.original_name}](bds-media://#{result.media_id})"
|
||||
end
|
||||
|
||||
send(self(), {:post_editor_insert_content, post_id, syntax})
|
||||
Notify.parent({:post_editor_insert_content, post_id, syntax})
|
||||
socket
|
||||
end
|
||||
|
||||
@@ -195,7 +196,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
end
|
||||
|
||||
if details do
|
||||
send(self(), {:post_editor_insert_content, post_id, details})
|
||||
Notify.parent({:post_editor_insert_content, post_id, details})
|
||||
end
|
||||
|
||||
socket
|
||||
@@ -213,7 +214,7 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
socket =
|
||||
case {socket.assigns[:shell_overlay], current_tab} do
|
||||
{%{kind: :language_picker}, %{type: :post, id: post_id}} ->
|
||||
send(self(), {:post_editor_translate, post_id, code})
|
||||
Notify.parent({:post_editor_translate, post_id, code})
|
||||
socket
|
||||
|
||||
{%{kind: :language_picker}, %{type: :media, id: media_id}} ->
|
||||
@@ -285,6 +286,25 @@ defmodule BDS.Desktop.ShellLive.OverlayManager do
|
||||
{%{kind: :confirm_delete, title: title, entity_name: entity_name}, _tab} ->
|
||||
close_overlay_with_output(socket, callbacks.append_output, title, entity_name)
|
||||
|
||||
{%{kind: :confirm_dialog, confirm_action: :delete_tag, tag_id: tag_id}, _tab} ->
|
||||
case Tags.delete_tag(tag_id) do
|
||||
{:ok, :deleted} ->
|
||||
socket
|
||||
|> assign(:shell_overlay, nil)
|
||||
|> callbacks.reload.(socket.assigns.workbench)
|
||||
|
||||
{:error, reason} ->
|
||||
socket
|
||||
|> assign(:shell_overlay, nil)
|
||||
|> callbacks.append_output.(
|
||||
dgettext("ui", "Delete Tag"),
|
||||
inspect(reason),
|
||||
nil,
|
||||
"error"
|
||||
)
|
||||
|> callbacks.reload.(socket.assigns.workbench)
|
||||
end
|
||||
|
||||
{%{kind: :confirm_dialog, title: title, message: message}, _tab} ->
|
||||
close_overlay_with_output(socket, callbacks.append_output, title, message)
|
||||
|
||||
|
||||
@@ -294,12 +294,11 @@ defmodule BDS.Desktop.ShellLive.PanelRenderer do
|
||||
defp short_commit_hash(hash) when is_binary(hash), do: String.slice(hash, 0, 7)
|
||||
defp short_commit_hash(_hash), do: "-------"
|
||||
|
||||
# Only called inside the template's `is_number(task.progress)` guard.
|
||||
defp progress_percent(progress) when is_number(progress) do
|
||||
rounded = progress |> Kernel.*(100) |> Float.round(0) |> trunc()
|
||||
"#{rounded}%"
|
||||
end
|
||||
|
||||
defp progress_percent(_), do: ""
|
||||
|
||||
defp present?(value), do: value not in [nil, ""]
|
||||
end
|
||||
|
||||
@@ -3,8 +3,9 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
alias BDS.{AI, Posts, Preview}
|
||||
alias BDS.{AI, Metadata, Posts, Preview}
|
||||
alias BDS.Desktop.ShellData
|
||||
alias BDS.Desktop.ShellLive.{EditorImageDrop, Notify}
|
||||
alias BDS.Desktop.ShellLive.PostEditor.{DraftManagement, ListValues, Persistence, PostMetadata}
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Tags
|
||||
@@ -181,7 +182,11 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> build_data()
|
||||
|
||||
if dirty? != was_dirty? do
|
||||
notify_parent({:post_editor_dirty, post_id, dirty?})
|
||||
Notify.dirty(:post, post_id, dirty?)
|
||||
end
|
||||
|
||||
if dirty? do
|
||||
Notify.schedule_auto_save(:post, post_id)
|
||||
end
|
||||
|
||||
{:noreply, socket}
|
||||
@@ -203,6 +208,19 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
{:noreply, do_delete(socket)}
|
||||
end
|
||||
|
||||
def handle_event("archive_post_editor", _params, socket) do
|
||||
{:noreply, do_archive(socket)}
|
||||
end
|
||||
|
||||
def handle_event("editor_image_dropped", %{"path" => path}, socket)
|
||||
when is_binary(path) do
|
||||
{:noreply, do_image_drop(socket, path)}
|
||||
end
|
||||
|
||||
def handle_event("unarchive_post_editor", _params, socket) do
|
||||
{:noreply, do_unarchive(socket)}
|
||||
end
|
||||
|
||||
def handle_event("set_post_editor_mode", %{"mode" => mode}, socket) do
|
||||
normalized_mode = normalize_mode(mode)
|
||||
|
||||
@@ -369,6 +387,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
editing_canonical_language?(translations, active_language, canonical_language),
|
||||
can_publish?: post.status == :draft,
|
||||
can_delete?: post.status == :published,
|
||||
can_archive?: post.status in [:draft, :published],
|
||||
can_unarchive?: post.status == :archived,
|
||||
has_published_version?: has_published_version?(post),
|
||||
discard_label: discard_label(post),
|
||||
discard_title: discard_title(post),
|
||||
@@ -456,12 +476,15 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> assign(:dirty?, false)
|
||||
|> build_data()
|
||||
|
||||
notify_parent(
|
||||
{:post_editor_tab_meta, post.id, record_title(record, refreshed_post),
|
||||
Atom.to_string(record_status(record))}
|
||||
Notify.tab_meta(
|
||||
:post,
|
||||
post.id,
|
||||
record_title(record, refreshed_post),
|
||||
Atom.to_string(record_status(record))
|
||||
)
|
||||
|
||||
notify_parent({:post_editor_dirty, post.id, false})
|
||||
Notify.dirty(:post, post.id, false)
|
||||
Notify.cancel_auto_save(:post, post.id)
|
||||
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post saved"))
|
||||
socket
|
||||
|
||||
@@ -497,12 +520,14 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> assign(:dirty?, false)
|
||||
|> build_data()
|
||||
|
||||
notify_parent(
|
||||
{:post_editor_tab_meta, post.id, record_title(record, refreshed_post),
|
||||
Atom.to_string(record_status(record))}
|
||||
Notify.tab_meta(
|
||||
:post,
|
||||
post.id,
|
||||
record_title(record, refreshed_post),
|
||||
Atom.to_string(record_status(record))
|
||||
)
|
||||
|
||||
notify_parent({:post_editor_dirty, post.id, false})
|
||||
Notify.dirty(:post, post.id, false)
|
||||
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post published"))
|
||||
socket
|
||||
|
||||
@@ -534,13 +559,14 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> assign(:dirty?, false)
|
||||
|> build_data()
|
||||
|
||||
notify_parent(
|
||||
{:post_editor_tab_meta, post.id,
|
||||
Notify.tab_meta(
|
||||
:post,
|
||||
post.id,
|
||||
restored_post.title || restored_post.slug || restored_post.id,
|
||||
Atom.to_string(restored_post.status || :draft)}
|
||||
Atom.to_string(restored_post.status || :draft)
|
||||
)
|
||||
|
||||
notify_parent({:post_editor_dirty, post.id, false})
|
||||
Notify.dirty(:post, post.id, false)
|
||||
socket
|
||||
|
||||
{:error, reason} ->
|
||||
@@ -555,7 +581,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|
||||
case Posts.delete_post(post_id) do
|
||||
{:ok, :deleted} ->
|
||||
notify_parent({:close_tab, :post, post_id})
|
||||
Notify.close_tab(:post, post_id)
|
||||
socket
|
||||
|
||||
{:error, reason} ->
|
||||
@@ -564,8 +590,124 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
end
|
||||
end
|
||||
|
||||
defp do_archive(socket) do
|
||||
case socket.assigns.post do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
%Post{} = post ->
|
||||
case Posts.archive_post(post.id) do
|
||||
{:ok, archived_post} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:post, archived_post)
|
||||
|> assign(:drafts, %{})
|
||||
|> assign(:dirty?, false)
|
||||
|> assign(:quick_actions_open?, false)
|
||||
|> build_data()
|
||||
|
||||
Notify.tab_meta(
|
||||
:post,
|
||||
post.id,
|
||||
archived_post.title || archived_post.slug || archived_post.id,
|
||||
"archived"
|
||||
)
|
||||
|
||||
Notify.dirty(:post, post.id, false)
|
||||
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post archived"))
|
||||
|
||||
{:error, reason} ->
|
||||
notify_output(socket, dgettext("ui", "Post"), inspect(reason), "error")
|
||||
|> build_data()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Drag-and-drop image chain (action_patterns.allium DragDropImageChain).
|
||||
# Steps 1-4 run synchronously while the user waits; steps 5-6 (AI analysis +
|
||||
# auto-translate) run in the background and are gated behind airplane mode.
|
||||
defp do_image_drop(socket, path) do
|
||||
case socket.assigns.post do
|
||||
%Post{} = post ->
|
||||
case EditorImageDrop.import_and_link(post.project_id, post.id, path) do
|
||||
{:ok, media, markdown} ->
|
||||
maybe_enrich_dropped_image(media, post)
|
||||
|
||||
socket
|
||||
|> Phoenix.LiveView.push_event("post-editor-insert-content", %{
|
||||
id: socket.assigns.post_id,
|
||||
content: markdown
|
||||
})
|
||||
|> notify_output(
|
||||
dgettext("ui", "Insert Image"),
|
||||
dgettext("ui", "Added %{name}", name: media.original_name)
|
||||
)
|
||||
|
||||
{:error, reason} ->
|
||||
notify_output(
|
||||
socket,
|
||||
dgettext("ui", "Insert Image"),
|
||||
dgettext("ui", "Failed to import %{path}: %{reason}",
|
||||
path: Path.basename(path),
|
||||
reason: inspect(reason)
|
||||
),
|
||||
"error"
|
||||
)
|
||||
end
|
||||
|
||||
_other ->
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_enrich_dropped_image(media, post) do
|
||||
unless AI.airplane_mode?() do
|
||||
{:ok, metadata} = Metadata.get_project_metadata(post.project_id)
|
||||
language = metadata.main_language || "en"
|
||||
|
||||
Task.Supervisor.start_child(BDS.TCP.TaskSupervisor, fn ->
|
||||
EditorImageDrop.enrich(media, post.id, language)
|
||||
end)
|
||||
end
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp do_unarchive(socket) do
|
||||
case socket.assigns.post do
|
||||
nil ->
|
||||
socket
|
||||
|
||||
%Post{} = post ->
|
||||
case Posts.unarchive_post(post.id) do
|
||||
{:ok, unarchived_post} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:post, unarchived_post)
|
||||
|> assign(:drafts, %{})
|
||||
|> assign(:dirty?, false)
|
||||
|> assign(:quick_actions_open?, false)
|
||||
|> build_data()
|
||||
|
||||
Notify.tab_meta(
|
||||
:post,
|
||||
post.id,
|
||||
unarchived_post.title || unarchived_post.slug || unarchived_post.id,
|
||||
"draft"
|
||||
)
|
||||
|
||||
Notify.dirty(:post, post.id, false)
|
||||
notify_output(socket, dgettext("ui", "Post"), dgettext("ui", "Post unarchived"))
|
||||
|
||||
{:error, reason} ->
|
||||
notify_output(socket, dgettext("ui", "Post"), inspect(reason), "error")
|
||||
|> build_data()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp do_detect_language(socket) do
|
||||
if Map.get(socket.assigns, :offline_mode, true) do
|
||||
if Map.get(socket.assigns, :offline_mode, true) and not AI.airplane_endpoint_configured?() do
|
||||
notify_output(
|
||||
socket,
|
||||
dgettext("ui", "Detect Language"),
|
||||
@@ -614,7 +756,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
end
|
||||
|
||||
defp do_translate(socket, language) do
|
||||
if Map.get(socket.assigns, :offline_mode, true) do
|
||||
if Map.get(socket.assigns, :offline_mode, true) and not AI.airplane_endpoint_configured?() do
|
||||
notify_output(
|
||||
socket,
|
||||
dgettext("ui", "Translate"),
|
||||
@@ -642,7 +784,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> assign(:quick_actions_open?, false)
|
||||
|> build_data()
|
||||
|
||||
notify_parent({:post_editor_dirty, post_id, false})
|
||||
Notify.dirty(:post, post_id, false)
|
||||
socket
|
||||
else
|
||||
{:error, reason} ->
|
||||
@@ -698,7 +840,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> assign(:shell_overlay, nil)
|
||||
|> build_data()
|
||||
|
||||
notify_parent({:post_editor_dirty, post_id, true})
|
||||
Notify.dirty(:post, post_id, true)
|
||||
socket
|
||||
|
||||
{:error, reason} ->
|
||||
@@ -741,7 +883,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> put_component_draft_field(field_key(kind), updated)
|
||||
|> build_data()
|
||||
|
||||
notify_parent({:post_editor_dirty, socket.assigns.post_id, true})
|
||||
Notify.dirty(:post, socket.assigns.post_id, true)
|
||||
assign(socket, :dirty?, true)
|
||||
end
|
||||
end
|
||||
@@ -771,7 +913,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
|> put_component_draft_field(field_key(kind), updated)
|
||||
|> build_data()
|
||||
|
||||
notify_parent({:post_editor_dirty, socket.assigns.post_id, true})
|
||||
Notify.dirty(:post, socket.assigns.post_id, true)
|
||||
assign(socket, :dirty?, true)
|
||||
end
|
||||
end
|
||||
@@ -807,12 +949,8 @@ defmodule BDS.Desktop.ShellLive.PostEditor do
|
||||
defp assign_query(socket, :tags, value), do: assign(socket, :tag_query, value)
|
||||
defp assign_query(socket, :categories, value), do: assign(socket, :category_query, value)
|
||||
|
||||
defp notify_parent(message) do
|
||||
send(self(), message)
|
||||
end
|
||||
|
||||
defp notify_output(socket, title, message, level \\ "info") do
|
||||
send(self(), {:post_editor_output, title, message, level})
|
||||
Notify.output(title, message, level)
|
||||
socket
|
||||
end
|
||||
|
||||
|
||||
@@ -114,9 +114,7 @@ defmodule BDS.Desktop.ShellLive.PostEditor.ListValues do
|
||||
end
|
||||
end
|
||||
|
||||
defp normalize_color(nil), do: nil
|
||||
defp normalize_color(""), do: nil
|
||||
|
||||
# nil is handled by tag_chip_style/1 before this is reached.
|
||||
defp normalize_color("#" <> rest = color) when byte_size(rest) == 6 do
|
||||
if String.match?(rest, ~r/\A[0-9a-fA-F]{6}\z/), do: color, else: nil
|
||||
end
|
||||
|
||||
@@ -168,11 +168,19 @@ defmodule BDS.Desktop.ShellLive.PostEditor.PostMetadata do
|
||||
|
||||
@spec gallery_count(term()) :: term()
|
||||
def gallery_count(form) do
|
||||
form
|
||||
|> Map.get("content", "")
|
||||
|> to_string()
|
||||
content = form |> Map.get("content", "") |> to_string()
|
||||
|
||||
image_count =
|
||||
content
|
||||
|> then(&Regex.scan(~r/!\[[^\]]*\]\([^\)]+\)/, &1))
|
||||
|> length()
|
||||
|
||||
gallery_macro_count =
|
||||
content
|
||||
|> then(&Regex.scan(~r/\[\[gallery\]\]/i, &1))
|
||||
|> length()
|
||||
|
||||
max(image_count, gallery_macro_count)
|
||||
end
|
||||
|
||||
@spec preview_url(term(), term(), term(), term()) :: term()
|
||||
|
||||
@@ -61,6 +61,42 @@
|
||||
<small><%= dgettext("ui", "Select a target language for this post") %></small>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<%= if @post_editor.can_archive? or @post_editor.can_unarchive? do %>
|
||||
<div class="quick-actions-divider"></div>
|
||||
|
||||
<%= if @post_editor.can_archive? do %>
|
||||
<button
|
||||
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
|
||||
data-testid="post-archive-button"
|
||||
type="button"
|
||||
phx-click="archive_post_editor"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<span class="quick-action-icon">📦</span>
|
||||
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
|
||||
<strong><%= dgettext("ui", "Archive") %></strong>
|
||||
<small><%= dgettext("ui", "Move this post to the archive") %></small>
|
||||
</span>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @post_editor.can_unarchive? do %>
|
||||
<button
|
||||
class="quick-action-item ui-dropdown-item flex items-start gap-3 text-left"
|
||||
data-testid="post-unarchive-button"
|
||||
type="button"
|
||||
phx-click="unarchive_post_editor"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<span class="quick-action-icon">📤</span>
|
||||
<span class="quick-action-text flex min-w-0 flex-1 flex-col">
|
||||
<strong><%= dgettext("ui", "Unarchive") %></strong>
|
||||
<small><%= dgettext("ui", "Restore this post to draft") %></small>
|
||||
</span>
|
||||
</button>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -362,6 +398,14 @@
|
||||
>
|
||||
<%= dgettext("ui", "Insert Media") %>
|
||||
</button>
|
||||
<button
|
||||
class="add-gallery-images-button"
|
||||
type="button"
|
||||
phx-click="add_gallery_images"
|
||||
phx-value-post-id={@post_editor.id}
|
||||
>
|
||||
<%= dgettext("ui", "Add Gallery Images") %>
|
||||
</button>
|
||||
<% end %>
|
||||
|
||||
<%= if @post_editor.gallery_count > 0 do %>
|
||||
@@ -392,11 +436,14 @@
|
||||
class="post-editor-markdown-surface monaco-editor-shell"
|
||||
data-testid="post-editor-markdown-surface"
|
||||
phx-hook="MonacoEditor"
|
||||
phx-target={@myself}
|
||||
data-monaco-editor-id={@post_editor.id}
|
||||
data-monaco-input-id={"post-editor-content-#{@post_editor.id}"}
|
||||
data-monaco-language="markdown-with-macros"
|
||||
data-monaco-word-wrap="on"
|
||||
data-monaco-insert-event="post-editor-insert-content"
|
||||
data-monaco-drop-event="editor_image_dropped"
|
||||
data-monaco-drop-post-id={@post_editor.id}
|
||||
>
|
||||
<div id={"post-editor-monaco-#{@post_editor.id}"} class="monaco-editor-instance min-h-0 flex-1" phx-update="ignore"></div>
|
||||
<textarea id={"post-editor-content-#{@post_editor.id}"} class="monaco-editor-input post-editor-content" data-testid="post-editor-content" data-post-editor-id={@post_editor.id} name="post_editor[content]" rows="18" spellcheck="false"><%= @post_editor.form["content"] %></textarea>
|
||||
|
||||
@@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.ScriptEditor do
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
alias BDS.{Scripts, Scripting}
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
alias BDS.Scripts.Script
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
@@ -225,7 +226,7 @@ defmodule BDS.Desktop.ShellLive.ScriptEditor do
|
||||
|
||||
case Scripts.delete_script(script_id) do
|
||||
{:ok, _deleted} ->
|
||||
send(self(), {:close_tab, :scripts, script_id})
|
||||
Notify.close_tab(:scripts, script_id)
|
||||
socket
|
||||
|
||||
{:error, reason} ->
|
||||
@@ -282,12 +283,12 @@ defmodule BDS.Desktop.ShellLive.ScriptEditor do
|
||||
end
|
||||
|
||||
defp notify_output(socket, title, message, level \\ "info") do
|
||||
send(self(), {:script_editor_output, title, message, level})
|
||||
Notify.output(title, message, level)
|
||||
socket
|
||||
end
|
||||
|
||||
defp notify_reload(socket) do
|
||||
send(self(), :reload_shell)
|
||||
Notify.reload()
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,6 +12,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
alias BDS.Desktop.ShellLive.SettingsEditor.AISettings
|
||||
alias BDS.Desktop.ShellLive.SettingsEditor.EditorSettings
|
||||
alias BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
alias BDS.Desktop.ShellLive.SettingsEditor.MCPConfig
|
||||
alias BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings
|
||||
alias BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings
|
||||
@@ -308,13 +309,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor do
|
||||
|
||||
defp append_output_callback do
|
||||
fn socket, title, message, _details, level ->
|
||||
send(self(), {:settings_output, title, message, level})
|
||||
Notify.output(title, message, level)
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_parent(message) do
|
||||
send(self(), message)
|
||||
Notify.parent(message)
|
||||
end
|
||||
|
||||
defp current_settings_section(assigns) do
|
||||
|
||||
@@ -9,8 +9,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
||||
|
||||
@spec ai_form(term()) :: term()
|
||||
def ai_form(assigns) do
|
||||
{:ok, online_endpoint} = AI.get_endpoint(:online)
|
||||
{:ok, airplane_endpoint} = AI.get_endpoint(:airplane)
|
||||
online_endpoint = safe_endpoint(:online)
|
||||
airplane_endpoint = safe_endpoint(:airplane)
|
||||
|
||||
%{
|
||||
"online_url" => Map.get(online_endpoint || %{}, :url, ""),
|
||||
@@ -25,8 +25,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
||||
model_disables_reasoning?(
|
||||
get_model_preference(:chat) || Map.get(online_endpoint || %{}, :model, "")
|
||||
),
|
||||
"online_title_model" => get_model_preference(:title),
|
||||
"online_image_analysis_model" => get_model_preference(:image_analysis),
|
||||
"online_title_model" => get_model_preference(:title) || "",
|
||||
"online_image_analysis_model" => get_model_preference(:image_analysis) || "",
|
||||
"online_chat_images" => model_supports_images?(get_model_preference(:image_analysis)),
|
||||
"offline_url" => Map.get(airplane_endpoint || %{}, :url, ""),
|
||||
"offline_api_key" => Map.get(airplane_endpoint || %{}, :api_key, ""),
|
||||
@@ -42,8 +42,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
||||
model_disables_reasoning?(
|
||||
get_model_preference(:airplane_chat) || Map.get(airplane_endpoint || %{}, :model, "")
|
||||
),
|
||||
"offline_title_model" => get_model_preference(:airplane_title),
|
||||
"offline_image_analysis_model" => get_model_preference(:airplane_image_analysis),
|
||||
"offline_title_model" => get_model_preference(:airplane_title) || "",
|
||||
"offline_image_analysis_model" => get_model_preference(:airplane_image_analysis) || "",
|
||||
"offline_chat_images" =>
|
||||
model_supports_images?(get_model_preference(:airplane_image_analysis)),
|
||||
"system_prompt" => EditorSettings.get_global_setting("ai.system_prompt") || ""
|
||||
@@ -168,6 +168,13 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
||||
end
|
||||
end
|
||||
|
||||
defp safe_endpoint(kind) do
|
||||
case AI.get_endpoint(kind) do
|
||||
{:ok, ep} -> ep
|
||||
_error -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp ai_attrs(assigns) do
|
||||
draft = Map.get(assigns, :settings_editor_ai_draft, %{})
|
||||
|
||||
@@ -218,10 +225,12 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.AISettings do
|
||||
}
|
||||
end
|
||||
|
||||
# Returns nil when no preference is stored so `||` fallbacks to the
|
||||
# endpoint's model actually fire.
|
||||
defp get_model_preference(key) do
|
||||
case AI.get_model_preference(key) do
|
||||
{:ok, value} -> value || ""
|
||||
_other -> ""
|
||||
{:ok, value} when is_binary(value) and value != "" -> value
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
|
||||
|
||||
@spec category_rows(term()) :: term()
|
||||
def category_rows(metadata) do
|
||||
categories = Map.get(metadata, :categories, [])
|
||||
settings = Map.get(metadata, :category_settings, %{})
|
||||
categories = metadata.categories
|
||||
settings = metadata.category_settings
|
||||
|
||||
Enum.map(categories, fn category ->
|
||||
category_settings = Map.get(settings, category, %{})
|
||||
@@ -167,7 +167,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ManagedCategories do
|
||||
end
|
||||
end
|
||||
|
||||
defp category_names(metadata), do: Map.get(metadata, :categories, [])
|
||||
defp category_names(metadata), do: metadata.categories
|
||||
|
||||
defp ensure_default_categories(project_id) do
|
||||
Enum.reduce_while(Map.keys(@default_category_settings), :ok, fn category, _acc ->
|
||||
|
||||
@@ -16,17 +16,18 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
|
||||
@spec project_form(term()) :: term()
|
||||
def project_form(metadata) do
|
||||
%{
|
||||
"name" => Map.get(metadata, :name, ""),
|
||||
"description" => Map.get(metadata, :description, ""),
|
||||
"public_url" => Map.get(metadata, :public_url, ""),
|
||||
"main_language" => Map.get(metadata, :main_language) || "en",
|
||||
"default_author" => Map.get(metadata, :default_author, ""),
|
||||
"max_posts_per_page" => Integer.to_string(Map.get(metadata, :max_posts_per_page, 50)),
|
||||
"name" => metadata.name || "",
|
||||
"description" => metadata.description || "",
|
||||
"public_url" => metadata.public_url || "",
|
||||
"main_language" => metadata.main_language || "en",
|
||||
"default_author" => metadata.default_author || "",
|
||||
"max_posts_per_page" => Integer.to_string(metadata.max_posts_per_page),
|
||||
"image_import_concurrency" => Integer.to_string(metadata.image_import_concurrency),
|
||||
"blogmark_category" =>
|
||||
Map.get(metadata, :blogmark_category) ||
|
||||
List.first(Map.get(metadata, :categories, [])) || "article",
|
||||
"blog_languages" => Map.get(metadata, :blog_languages, []),
|
||||
"semantic_similarity_enabled" => Map.get(metadata, :semantic_similarity_enabled, false)
|
||||
metadata.blogmark_category ||
|
||||
List.first(metadata.categories) || "article",
|
||||
"blog_languages" => metadata.blog_languages,
|
||||
"semantic_similarity_enabled" => metadata.semantic_similarity_enabled
|
||||
}
|
||||
end
|
||||
|
||||
@@ -71,6 +72,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
|
||||
main_language: blank_to_nil(Map.get(draft, "main_language")),
|
||||
default_author: blank_to_nil(Map.get(draft, "default_author")),
|
||||
max_posts_per_page: parse_integer(Map.get(draft, "max_posts_per_page"), 50),
|
||||
image_import_concurrency: parse_integer(Map.get(draft, "image_import_concurrency"), 4),
|
||||
blogmark_category: blank_to_nil(Map.get(draft, "blogmark_category")),
|
||||
blog_languages: Map.get(draft, "blog_languages", []),
|
||||
semantic_similarity_enabled: truthy?(Map.get(draft, "semantic_similarity_enabled"))
|
||||
@@ -85,6 +87,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.ProjectSettings do
|
||||
"main_language" => Map.get(params, "main_language", "en"),
|
||||
"default_author" => Map.get(params, "default_author", ""),
|
||||
"max_posts_per_page" => Map.get(params, "max_posts_per_page", "50"),
|
||||
"image_import_concurrency" => Map.get(params, "image_import_concurrency", "4"),
|
||||
"blogmark_category" => Map.get(params, "blogmark_category", "article"),
|
||||
"blog_languages" => List.wrap(Map.get(params, "blog_languages", [])),
|
||||
"semantic_similarity_enabled" => truthy?(Map.get(params, "semantic_similarity_enabled"))
|
||||
|
||||
@@ -8,7 +8,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.PublishingSettings do
|
||||
|
||||
@spec publishing_form(term()) :: term()
|
||||
def publishing_form(metadata) do
|
||||
prefs = Map.get(metadata, :publishing_preferences, %{})
|
||||
prefs = metadata.publishing_preferences
|
||||
|
||||
%{
|
||||
"ssh_host" => Map.get(prefs, "ssh_host", ""),
|
||||
|
||||
@@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
|
||||
use Phoenix.Component
|
||||
|
||||
alias BDS.Metadata
|
||||
alias BDS.Preview
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
@themes [
|
||||
@@ -42,7 +43,8 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
|
||||
applied_theme: current_theme(assigns),
|
||||
preview_mode: preview_mode,
|
||||
preview_url:
|
||||
"http://127.0.0.1:4123/__style-preview?theme=#{selected_theme}&mode=#{preview_mode}"
|
||||
Preview.base_url() <>
|
||||
"/__style-preview?theme=#{selected_theme}&mode=#{preview_mode}"
|
||||
}
|
||||
end
|
||||
|
||||
@@ -88,7 +90,7 @@ defmodule BDS.Desktop.ShellLive.SettingsEditor.StyleEditor do
|
||||
def current_theme(assigns) do
|
||||
case Metadata.get_project_metadata(assigns.projects.active_project_id) do
|
||||
{:ok, metadata} ->
|
||||
case Map.get(metadata, :pico_theme) do
|
||||
case metadata.pico_theme do
|
||||
nil -> "default"
|
||||
"" -> "default"
|
||||
theme -> theme
|
||||
|
||||
@@ -82,6 +82,10 @@
|
||||
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Max Posts Per Page") %></label></div>
|
||||
<div class="setting-control"><input class="ui-input" type="number" min="1" max="500" name="settings_project[max_posts_per_page]" value={@settings_editor.project["max_posts_per_page"]} /></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Image Import Concurrency") %></label></div>
|
||||
<div class="setting-control"><input class="ui-input" type="number" min="1" max="8" name="settings_project[image_import_concurrency]" value={@settings_editor.project["image_import_concurrency"]} /></div>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<div class="setting-info"><label class="setting-label"><%= dgettext("ui", "Blogmark Category") %></label></div>
|
||||
<div class="setting-control">
|
||||
|
||||
@@ -257,6 +257,7 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
|
||||
"media_grid" -> render_media_sidebar(assigns)
|
||||
"entity_list" -> render_entity_sidebar(assigns)
|
||||
"nav_list" -> render_nav_sidebar(assigns)
|
||||
"git" -> render_git_sidebar(assigns)
|
||||
_other -> render_default_sidebar(assigns)
|
||||
end
|
||||
end
|
||||
@@ -483,6 +484,141 @@ defmodule BDS.Desktop.ShellLive.SidebarComponents do
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_git_sidebar(assigns) do
|
||||
assigns = assign(assigns, :git_state, Map.get(assigns.sidebar_data, :git_state, "not_a_repo"))
|
||||
|
||||
~H"""
|
||||
<div class="git-sidebar">
|
||||
<%= if @git_state == "active" do %>
|
||||
<%= render_git_active(assigns) %>
|
||||
<% else %>
|
||||
<%= render_git_not_a_repo(assigns) %>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_git_not_a_repo(assigns) do
|
||||
~H"""
|
||||
<section class="git-section git-not-a-repo">
|
||||
<p class="git-empty-hint"><%= dgettext("ui", "This project is not a Git repository yet.") %></p>
|
||||
<form class="git-init-form flex flex-col gap-2" data-testid="git-init-form" phx-submit="git_initialize">
|
||||
<input
|
||||
type="text"
|
||||
name="git[remote_url]"
|
||||
placeholder={dgettext("ui", "Remote URL (optional)")}
|
||||
value={Map.get(@sidebar_data, :remote_url) || ""}
|
||||
/>
|
||||
<button class="git-action-button" data-testid="git-initialize" type="submit">
|
||||
<%= dgettext("ui", "Initialize Git") %>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_git_active(assigns) do
|
||||
~H"""
|
||||
<header class="git-header">
|
||||
<div class="git-branch-row flex items-center gap-2">
|
||||
<span class="git-branch-icon">⎇</span>
|
||||
<span class="git-branch" data-testid="git-branch"><%= @sidebar_data.branch %></span>
|
||||
<%= if @sidebar_data.upstream do %>
|
||||
<span class="git-upstream" data-testid="git-upstream"><%= @sidebar_data.upstream %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="git-tracking flex items-center gap-3">
|
||||
<span class="git-ahead" data-testid="git-ahead" title={dgettext("ui", "Ahead")}>↑ <%= @sidebar_data.ahead %></span>
|
||||
<span class="git-behind" data-testid="git-behind" title={dgettext("ui", "Behind")}>↓ <%= @sidebar_data.behind %></span>
|
||||
</div>
|
||||
<div class="git-sync-legend flex items-center gap-3">
|
||||
<span class="git-legend-item"><span class="git-sync-dot git-sync-synced"></span><%= dgettext("ui", "Synced") %></span>
|
||||
<span class="git-legend-item"><span class="git-sync-dot git-sync-local_only"></span><%= dgettext("ui", "Local only") %></span>
|
||||
<span class="git-legend-item"><span class="git-sync-dot git-sync-remote_only"></span><%= dgettext("ui", "Remote only") %></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="git-actions flex items-center gap-2">
|
||||
<button class="git-action-button" data-testid="git-action-fetch" type="button" phx-click="git_fetch" title={dgettext("ui", "Fetch")}><%= dgettext("ui", "Fetch") %></button>
|
||||
<button class="git-action-button" data-testid="git-action-pull" type="button" phx-click="git_pull" title={dgettext("ui", "Pull")}><%= dgettext("ui", "Pull") %></button>
|
||||
<button class="git-action-button" data-testid="git-action-push" type="button" phx-click="git_push" title={dgettext("ui", "Push")}><%= dgettext("ui", "Push") %></button>
|
||||
<button class="git-action-button" data-testid="git-action-prune-lfs" type="button" phx-click="git_prune_lfs" title={dgettext("ui", "Prune LFS")}><%= dgettext("ui", "Prune LFS") %></button>
|
||||
</div>
|
||||
|
||||
<section class="git-section git-changes">
|
||||
<div class="git-section-title">
|
||||
<span><%= dgettext("ui", "Changes") %></span>
|
||||
<span class="git-section-count"><%= length(@sidebar_data.status_files) %></span>
|
||||
</div>
|
||||
|
||||
<form class="git-commit-form flex flex-col gap-2" data-testid="git-commit-form" phx-submit="git_commit">
|
||||
<input type="text" name="git[message]" placeholder={dgettext("ui", "Commit message")} />
|
||||
<button class="git-action-button" data-testid="git-commit" type="submit"><%= dgettext("ui", "Commit") %></button>
|
||||
</form>
|
||||
|
||||
<%= if Enum.any?(@sidebar_data.status_files) do %>
|
||||
<div class="git-status-list flex flex-col">
|
||||
<%= for file <- @sidebar_data.status_files do %>
|
||||
<button
|
||||
class="git-status-file flex items-center justify-between gap-2"
|
||||
data-testid="git-status-file"
|
||||
data-route="git_diff"
|
||||
type="button"
|
||||
title={"#{file.label}: #{file.path}"}
|
||||
phx-click="open_sidebar_item"
|
||||
phx-value-route="git_diff"
|
||||
phx-value-id={"git-diff:" <> file.path}
|
||||
phx-value-title={file.path}
|
||||
phx-value-subtitle={file.label}
|
||||
>
|
||||
<span class="git-status-path"><%= file.path %></span>
|
||||
<span class={"git-status-badge git-status-#{file.status}"}><%= file.code %></span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="git-empty-hint"><%= dgettext("ui", "No changes") %></p>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section class="git-section git-history">
|
||||
<div class="git-section-title">
|
||||
<span><%= dgettext("ui", "History") %></span>
|
||||
</div>
|
||||
<%= if Enum.any?(@sidebar_data.history_entries) do %>
|
||||
<div class="git-history-list flex flex-col">
|
||||
<%= for entry <- @sidebar_data.history_entries do %>
|
||||
<button
|
||||
class="git-history-entry flex flex-col"
|
||||
data-testid="git-history-entry"
|
||||
data-route="git_diff"
|
||||
type="button"
|
||||
phx-click="open_sidebar_item"
|
||||
phx-value-route="git_diff"
|
||||
phx-value-id={"git-diff:commit:" <> entry.short_hash}
|
||||
phx-value-title={entry.short_hash}
|
||||
phx-value-subtitle={entry.subject || ""}
|
||||
>
|
||||
<span class="git-history-subject"><%= entry.subject %></span>
|
||||
<span class="git-history-meta flex items-center gap-2">
|
||||
<span class={"git-sync-dot git-sync-#{entry.sync_status}"}></span>
|
||||
<span class="git-history-hash"><%= entry.short_hash %></span>
|
||||
<%= if entry.author do %><span class="git-history-author"><%= entry.author %></span><% end %>
|
||||
<%= if entry.date do %><span class="git-history-date"><%= entry.date %></span><% end %>
|
||||
</span>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= if @sidebar_data.has_more_history do %>
|
||||
<p class="git-history-more"><%= dgettext("ui", "Older history available") %></p>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p class="git-empty-hint"><%= dgettext("ui", "No commits yet") %></p>
|
||||
<% end %>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
defp render_default_sidebar(assigns) do
|
||||
~H"""
|
||||
<%= for section <- Map.get(@sidebar_data, :sections, []) do %>
|
||||
|
||||
@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
||||
import Ecto.Query
|
||||
|
||||
alias BDS.{Repo, Tags}
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
alias BDS.Posts.Post
|
||||
alias BDS.Tags.Tag
|
||||
alias BDS.Templates.Template
|
||||
@@ -15,6 +16,16 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
||||
|
||||
@tags_sections ~w(cloud manage merge)
|
||||
|
||||
@colour_presets ~w(
|
||||
#ef4444 #f97316 #f59e0b #eab308 #84cc16
|
||||
#22c55e #10b981 #14b8a6 #06b6d4 #0ea5e9
|
||||
#3b82f6 #6366f1 #8b5cf6 #a855f7 #d946ef
|
||||
#ec4899 #64748b
|
||||
)
|
||||
|
||||
@spec colour_presets() :: [String.t()]
|
||||
def colour_presets, do: @colour_presets
|
||||
|
||||
@spec update(map(), Phoenix.LiveView.Socket.t()) :: {:ok, Phoenix.LiveView.Socket.t()}
|
||||
@impl true
|
||||
def update(%{action: :save} = assigns, socket) do
|
||||
@@ -106,6 +117,26 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
||||
{:noreply, assign(socket, :tags_editor, tags_editor)}
|
||||
end
|
||||
|
||||
def handle_event("pick_new_tag_color", %{"color" => color}, socket) do
|
||||
tags_editor =
|
||||
Map.put(socket.assigns.tags_editor, :new_tag, %{
|
||||
socket.assigns.tags_editor.new_tag
|
||||
| "color" => color
|
||||
})
|
||||
|
||||
{:noreply, assign(socket, :tags_editor, tags_editor)}
|
||||
end
|
||||
|
||||
def handle_event("pick_edit_tag_color", %{"color" => color}, socket) do
|
||||
tags_editor =
|
||||
Map.put(socket.assigns.tags_editor, :edit_draft, %{
|
||||
socket.assigns.tags_editor.edit_draft
|
||||
| "color" => color
|
||||
})
|
||||
|
||||
{:noreply, assign(socket, :tags_editor, tags_editor)}
|
||||
end
|
||||
|
||||
def handle_event("save_tag_editor", _params, socket) do
|
||||
{:noreply, do_save(socket)}
|
||||
end
|
||||
@@ -113,29 +144,18 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
||||
def handle_event("delete_tag_editor", _params, socket) do
|
||||
case socket.assigns.tags_editor.selected do
|
||||
[tag_name] ->
|
||||
case Repo.get_by(Tag,
|
||||
project_id: socket.assigns.project_id,
|
||||
name: tag_name
|
||||
) do
|
||||
project_id = socket.assigns.project_id
|
||||
|
||||
case Repo.get_by(Tag, project_id: project_id, name: tag_name) do
|
||||
nil ->
|
||||
{:noreply, socket}
|
||||
|
||||
%Tag{} = tag ->
|
||||
case Tags.delete_tag(tag.id) do
|
||||
{:ok, _deleted} ->
|
||||
notify_parent(:tags_changed)
|
||||
|
||||
socket
|
||||
|> put_in_tags_editor([:selected], [])
|
||||
|> put_in_tags_editor([:edit_draft], %{})
|
||||
|> load_data()
|
||||
|> noreply()
|
||||
|
||||
{:error, reason} ->
|
||||
notify_output(dgettext("ui", "Tags"), inspect(reason), "error")
|
||||
%Tag{id: tag_id} ->
|
||||
counts = tag_counts(project_id)
|
||||
post_count = Map.get(counts, tag_name, 0)
|
||||
Notify.parent({:confirm_tag_delete, tag_id, tag_name, post_count})
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
_other ->
|
||||
{:noreply, socket}
|
||||
@@ -240,6 +260,55 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
||||
end
|
||||
end
|
||||
|
||||
attr(:color, :string, default: nil)
|
||||
attr(:presets, :list, required: true)
|
||||
attr(:pick_event, :string, required: true)
|
||||
attr(:target, :any, required: true)
|
||||
|
||||
defp colour_picker(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="colour-picker-wrap"
|
||||
id={"cp-#{@pick_event}"}
|
||||
phx-hook="ColourPicker"
|
||||
data-pick-event={@pick_event}
|
||||
data-target={if @target, do: @target.cid}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="colour-picker-trigger"
|
||||
style={"background-color: #{if @color in [nil, ""], do: "#3b82f6", else: @color}"}
|
||||
phx-click={Phoenix.LiveView.JS.toggle(to: "#cp-#{@pick_event} .colour-picker-popover")}
|
||||
/>
|
||||
<div class="colour-picker-popover hidden">
|
||||
<div class="colour-picker-grid">
|
||||
<%= for preset <- @presets do %>
|
||||
<button
|
||||
type="button"
|
||||
class={"colour-picker-swatch#{if normalize_hex(@color) == normalize_hex(preset), do: " selected", else: ""}"}
|
||||
style={"background-color: #{preset}"}
|
||||
phx-click={Phoenix.LiveView.JS.push(@pick_event, value: %{color: preset}, target: if(@target, do: @target.cid)) |> Phoenix.LiveView.JS.add_class("hidden", to: "#cp-#{@pick_event} .colour-picker-popover")}
|
||||
/>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="colour-picker-custom">
|
||||
<label>#</label>
|
||||
<input
|
||||
type="text"
|
||||
maxlength="7"
|
||||
placeholder="RRGGBB"
|
||||
value={if @color in [nil, ""], do: "", else: @color}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp normalize_hex(nil), do: nil
|
||||
defp normalize_hex(""), do: nil
|
||||
defp normalize_hex(hex), do: String.downcase(hex)
|
||||
|
||||
defp load_data(socket) do
|
||||
project_id = socket.assigns.project_id
|
||||
|
||||
@@ -279,7 +348,8 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
||||
merge_target:
|
||||
Map.get(socket.assigns, :tags_editor, %{})
|
||||
|> Map.get(:merge_target, List.first(selected) || ""),
|
||||
selected_section: selected_section
|
||||
selected_section: selected_section,
|
||||
colour_presets: @colour_presets
|
||||
}
|
||||
|
||||
assign(socket, :tags_editor, data)
|
||||
@@ -292,11 +362,11 @@ defmodule BDS.Desktop.ShellLive.TagsEditor do
|
||||
defp noreply(socket), do: {:noreply, socket}
|
||||
|
||||
defp notify_parent(message) do
|
||||
send(self(), message)
|
||||
Notify.parent(message)
|
||||
end
|
||||
|
||||
defp notify_output(title, message, level) do
|
||||
send(self(), {:tags_editor_output, title, message, level})
|
||||
Notify.output(title, message, level)
|
||||
end
|
||||
|
||||
@spec tag_font_size(term(), term()) :: term()
|
||||
|
||||
@@ -38,7 +38,13 @@
|
||||
<form class="tag-create-form" phx-change="change_new_tag_editor" phx-target={@myself}>
|
||||
<div class="tag-form-row flex flex-wrap items-center gap-3">
|
||||
<input class="ui-input" type="text" name="new_tag[name]" value={@tags_editor.new_tag["name"]} placeholder={dgettext("ui", "Tag name")} />
|
||||
<input type="color" name="new_tag[color]" value={if(@tags_editor.new_tag["color"] in [nil, ""], do: "#3b82f6", else: @tags_editor.new_tag["color"])} />
|
||||
<input type="hidden" name="new_tag[color]" value={@tags_editor.new_tag["color"] || ""} />
|
||||
<.colour_picker
|
||||
color={@tags_editor.new_tag["color"]}
|
||||
presets={@tags_editor.colour_presets}
|
||||
pick_event="pick_new_tag_color"
|
||||
target={@myself}
|
||||
/>
|
||||
<button class="primary ui-button ui-button-primary" type="button" phx-click="create_tag_editor" phx-target={@myself}><%= dgettext("ui", "Create") %></button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -47,7 +53,13 @@
|
||||
<form class="tag-edit-form" phx-change="change_edit_tag_editor" phx-target={@myself}>
|
||||
<div class="tag-form-row flex flex-wrap items-center gap-3">
|
||||
<input class="ui-input" type="text" name="edit_tag[name]" value={@tags_editor.edit_draft["name"]} />
|
||||
<input type="color" name="edit_tag[color]" value={if(@tags_editor.edit_draft["color"] in [nil, ""], do: "#3b82f6", else: @tags_editor.edit_draft["color"])} />
|
||||
<input type="hidden" name="edit_tag[color]" value={@tags_editor.edit_draft["color"] || ""} />
|
||||
<.colour_picker
|
||||
color={@tags_editor.edit_draft["color"]}
|
||||
presets={@tags_editor.colour_presets}
|
||||
pick_event="pick_edit_tag_color"
|
||||
target={@myself}
|
||||
/>
|
||||
<select class="ui-input" name="edit_tag[post_template_slug]">
|
||||
<option value=""><%= dgettext("ui", "No Template") %></option>
|
||||
<%= for template <- @tags_editor.templates do %>
|
||||
|
||||
@@ -4,6 +4,7 @@ defmodule BDS.Desktop.ShellLive.TemplateEditor do
|
||||
use Phoenix.LiveComponent
|
||||
|
||||
alias BDS.{MCP, Templates}
|
||||
alias BDS.Desktop.ShellLive.Notify
|
||||
alias BDS.Templates.Template
|
||||
use Gettext, backend: BDS.Gettext
|
||||
|
||||
@@ -182,7 +183,7 @@ defmodule BDS.Desktop.ShellLive.TemplateEditor do
|
||||
|
||||
case Templates.delete_template(template_id, force: true) do
|
||||
{:ok, _deleted} ->
|
||||
send(self(), {:close_tab, :templates, template_id})
|
||||
Notify.close_tab(:templates, template_id)
|
||||
socket
|
||||
|
||||
{:error, reason} ->
|
||||
@@ -231,12 +232,12 @@ defmodule BDS.Desktop.ShellLive.TemplateEditor do
|
||||
defp normalize_template_kind(_kind), do: :post
|
||||
|
||||
defp notify_output(socket, title, message, level \\ "info") do
|
||||
send(self(), {:template_editor_output, title, message, level})
|
||||
Notify.output(title, message, level)
|
||||
socket
|
||||
end
|
||||
|
||||
defp notify_reload(socket) do
|
||||
send(self(), :reload_shell)
|
||||
Notify.reload()
|
||||
socket
|
||||
end
|
||||
end
|
||||
|
||||
@@ -61,9 +61,26 @@ defmodule BDS.Desktop.Shutdown do
|
||||
|
||||
def command_menu_selected(_event, _command_event), do: :ok
|
||||
|
||||
@doc """
|
||||
Terminate the OS process directly with SIGKILL.
|
||||
|
||||
`Desktop.Window.quit/0` routes through `System.halt/1`, which calls the libc
|
||||
`exit()` and runs the wxWidgets C++ static destructors on the way out. On
|
||||
macOS that races the still-running wx event loop on the main thread and
|
||||
segfaults (`wxMenu::~wxMenu` vs `wxAppBase::ProcessIdle`). A SIGKILL is a
|
||||
kernel-level termination that skips those destructors entirely, so the app
|
||||
exits cleanly without producing a crash report.
|
||||
"""
|
||||
@spec quit() :: :ok
|
||||
def quit do
|
||||
kill_heart()
|
||||
kill_beam()
|
||||
:ok
|
||||
end
|
||||
|
||||
defp start_shutdown_task do
|
||||
Task.start(fn ->
|
||||
MainWindow.persist_now()
|
||||
persist_safely()
|
||||
maybe_hide_window()
|
||||
Process.sleep(50)
|
||||
quit_module().quit()
|
||||
@@ -72,6 +89,66 @@ defmodule BDS.Desktop.Shutdown do
|
||||
:ok
|
||||
end
|
||||
|
||||
# quit/0 SIGKILLs the BEAM, so no terminate/2 callback ever runs on shutdown;
|
||||
# everything that must reach disk has to be flushed here. Each step is
|
||||
# hardened individually so one failure never blocks quit or the other steps.
|
||||
defp persist_safely do
|
||||
persist_step(fn -> MainWindow.persist_now() end)
|
||||
persist_step(fn -> BDS.Embeddings.Index.flush_all() end)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp persist_step(fun) do
|
||||
fun.()
|
||||
:ok
|
||||
rescue
|
||||
_error -> :ok
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
end
|
||||
|
||||
# heart, when present, would relaunch the app after we kill the BEAM, so it
|
||||
# has to be terminated first. When the app is started without heart (e.g. via
|
||||
# `mix`) there is nothing to do here.
|
||||
defp kill_heart do
|
||||
with heart when is_pid(heart) <- Process.whereis(:heart),
|
||||
{:links, links} <- Process.info(heart, :links),
|
||||
port when is_port(port) <- Enum.find(links, &is_port/1),
|
||||
{:os_pid, heart_pid} <- Port.info(port, :os_pid) do
|
||||
os_kill(heart_pid)
|
||||
else
|
||||
_ -> :ok
|
||||
end
|
||||
rescue
|
||||
_error -> :ok
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
end
|
||||
|
||||
defp kill_beam do
|
||||
os_kill(:os.getpid())
|
||||
end
|
||||
|
||||
defp os_kill(os_pid) do
|
||||
os_kill_fun().(os_pid)
|
||||
:ok
|
||||
rescue
|
||||
_error -> :ok
|
||||
catch
|
||||
:exit, _reason -> :ok
|
||||
end
|
||||
|
||||
defp os_kill_fun do
|
||||
Application.get_env(:bds, :desktop_os_kill_fun, &__MODULE__.hard_kill/1)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec hard_kill(charlist() | integer() | String.t()) :: :ok
|
||||
def hard_kill(os_pid) do
|
||||
System.cmd("kill", ["-9", to_string(os_pid)], stderr_to_stdout: true)
|
||||
:ok
|
||||
end
|
||||
|
||||
defp maybe_hide_window do
|
||||
module = window_module()
|
||||
|
||||
@@ -86,8 +163,10 @@ defmodule BDS.Desktop.Shutdown do
|
||||
:exit, _reason -> :ok
|
||||
end
|
||||
|
||||
defp quit_module do
|
||||
Application.get_env(:bds, :desktop_window_quit_module, Window)
|
||||
@doc false
|
||||
@spec quit_module() :: module()
|
||||
def quit_module do
|
||||
Application.get_env(:bds, :desktop_window_quit_module, __MODULE__)
|
||||
end
|
||||
|
||||
defp window_module do
|
||||
|
||||
@@ -11,6 +11,21 @@ defmodule BDS.Desktop.UILocale do
|
||||
process dictionary directly. Use `with_locale/2` around any render or
|
||||
component that needs a locale binding; use `current/0` to read it.
|
||||
|
||||
## Invariant
|
||||
|
||||
Every code path that evaluates HEEx templates containing `translated/1,2`
|
||||
calls **must** call `UILocale.put/1` before template evaluation:
|
||||
|
||||
* `ShellLive.render/1` — sets locale at the top of every LiveView render.
|
||||
* `SidebarComponents.sidebar_content/1` — sets locale before the function
|
||||
component's HEEx (runs in the same process, may be called outside
|
||||
the parent render cycle via `send_update`).
|
||||
* `MenuBar.mount/1` and `MenuBar.handle_info({:set_ui_locale, _})` — set
|
||||
locale in the separate menu-bar process which has its own render cycle.
|
||||
|
||||
Violating this invariant causes `current/0` to return a stale or `nil`
|
||||
locale, producing untranslated UI text.
|
||||
|
||||
Direct use of `Process.put(:bds_ui_locale, _)` or
|
||||
`Process.get(:bds_ui_locale)` is forbidden outside this module.
|
||||
"""
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
defmodule BDS.DocumentFields do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Accessor functions for document frontmatter fields, supporting key aliases
|
||||
(e.g. "date" and "published_at" resolve to the same value).
|
||||
"""
|
||||
|
||||
def get(fields, key, default \\ nil) when is_map(fields) and is_binary(key) do
|
||||
case fetch(fields, key) do
|
||||
|
||||
@@ -2,6 +2,7 @@ defmodule BDS.Embeddings do
|
||||
@moduledoc false
|
||||
|
||||
import Ecto.Query
|
||||
require Logger
|
||||
|
||||
alias BDS.Persistence
|
||||
alias BDS.Embeddings.DismissedDuplicatePair
|
||||
@@ -15,6 +16,7 @@ defmodule BDS.Embeddings do
|
||||
|
||||
@duplicate_threshold 0.92
|
||||
@exact_match_score 0.999999
|
||||
@key_batch_size 199
|
||||
|
||||
def model_id, do: configured_backend().model_info().model_id
|
||||
def dimensions, do: configured_backend().model_info().dimensions
|
||||
@@ -73,9 +75,17 @@ defmodule BDS.Embeddings do
|
||||
order_by: [asc: post.created_at, asc: post.slug]
|
||||
)
|
||||
|
||||
Enum.each(posts, &sync_post_if_enabled(&1, refresh_index: false))
|
||||
existing_keys = preload_keys_by_post_id(project_id, Enum.map(posts, & &1.id))
|
||||
|
||||
case build_key_rows(posts, existing_keys, max_label_value(), nil, false) do
|
||||
{:ok, rows} ->
|
||||
batch_upsert_keys(rows)
|
||||
:ok = rebuild_snapshot(project_id)
|
||||
{:ok, Enum.map(posts, & &1.id)}
|
||||
|
||||
{:error, _reason} = error ->
|
||||
error
|
||||
end
|
||||
else
|
||||
{:ok, []}
|
||||
end
|
||||
@@ -95,25 +105,26 @@ defmodule BDS.Embeddings do
|
||||
)
|
||||
|
||||
post_ids = Enum.map(posts, & &1.id)
|
||||
total_posts = length(posts)
|
||||
|
||||
:ok = report_rebuild_started(on_progress, total_posts, "embedding entries")
|
||||
|
||||
Repo.delete_all(
|
||||
from key in Key,
|
||||
where: key.project_id == ^project_id and key.post_id not in ^post_ids
|
||||
)
|
||||
|
||||
posts
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.each(fn {post, index} ->
|
||||
sync_post_if_enabled(post, refresh_index: false)
|
||||
:ok = report_rebuild_progress(on_progress, index, total_posts, "embedding entries")
|
||||
end)
|
||||
existing_keys = preload_keys_by_post_id(project_id)
|
||||
|
||||
# An explicit rebuild re-embeds every post from scratch (ReindexAll),
|
||||
# ignoring the content_hash skip optimisation.
|
||||
case build_key_rows(posts, existing_keys, max_label_value(), on_progress, true) do
|
||||
{:ok, rows} ->
|
||||
batch_upsert_keys(rows)
|
||||
:ok = report_rebuild_phase(on_progress, 0.99, "Persisting embedding snapshot")
|
||||
:ok = rebuild_snapshot(project_id)
|
||||
{:ok, post_ids}
|
||||
|
||||
{:error, _reason} = error ->
|
||||
error
|
||||
end
|
||||
else
|
||||
{:ok, []}
|
||||
end
|
||||
@@ -167,16 +178,15 @@ defmodule BDS.Embeddings do
|
||||
|
||||
case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do
|
||||
%Key{content_hash: ^content_hash} ->
|
||||
if Keyword.get(opts, :refresh_index, true) and
|
||||
snapshot_content_hash(post.project_id, post.id) != content_hash do
|
||||
:ok = rebuild_snapshot(post.project_id)
|
||||
end
|
||||
|
||||
# Embedding is already current. The HNSW index self-heals on query
|
||||
# (find_similar/find_duplicates rebuild when no index is loaded), so
|
||||
# there is nothing to refresh here.
|
||||
:ok
|
||||
|
||||
existing_key ->
|
||||
case embed_text(raw_text, post.language) do
|
||||
{:ok, vector} ->
|
||||
label = existing_key_label(existing_key) || next_label()
|
||||
{:ok, vector} = embed_text(raw_text, post.language)
|
||||
|
||||
(existing_key || %Key{})
|
||||
|> Key.changeset(%{
|
||||
@@ -184,7 +194,7 @@ defmodule BDS.Embeddings do
|
||||
post_id: post.id,
|
||||
project_id: post.project_id,
|
||||
content_hash: content_hash,
|
||||
vector: Jason.encode!(vector)
|
||||
vector: encode_vector(vector)
|
||||
})
|
||||
|> Repo.insert_or_update()
|
||||
|
||||
@@ -192,9 +202,150 @@ defmodule BDS.Embeddings do
|
||||
:ok = rebuild_snapshot(post.project_id)
|
||||
end
|
||||
|
||||
:ok
|
||||
|
||||
{:error, reason} ->
|
||||
# Embedding is best-effort on post save: if the model is unavailable
|
||||
# (e.g. offline first-use download), leave the post unindexed rather
|
||||
# than failing the save. An explicit reindex surfaces the error.
|
||||
Logger.warning(
|
||||
"Embedding unavailable for post #{post.id}: #{inspect(reason)}; left unindexed"
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp preload_keys_by_post_id(project_id) do
|
||||
Repo.all(from key in Key, where: key.project_id == ^project_id)
|
||||
|> Map.new(&{&1.post_id, &1})
|
||||
end
|
||||
|
||||
defp preload_keys_by_post_id(project_id, post_ids) do
|
||||
Repo.all(
|
||||
from key in Key,
|
||||
where: key.project_id == ^project_id and key.post_id in ^post_ids
|
||||
)
|
||||
|> Map.new(&{&1.post_id, &1})
|
||||
end
|
||||
|
||||
defp max_label_value do
|
||||
Repo.one(from key in Key, select: max(key.label)) || 0
|
||||
end
|
||||
|
||||
# Builds the upsert rows for a batch of posts. Unless `force?` is set, posts
|
||||
# whose content_hash is unchanged are skipped (ContentHashSkipsUnchanged); the
|
||||
# rest are embedded in batches (see embed_pending/2) so model inference is not
|
||||
# serialised one post at a time. Labels keep their existing value or take the
|
||||
# next free integer. Returns `{:error, reason}` if the model is unavailable.
|
||||
defp build_key_rows(posts, existing_keys, base_label, on_progress, force?) do
|
||||
prepared =
|
||||
Enum.map(posts, fn post ->
|
||||
raw_text = compose_embedding_source(post.title, resolve_post_body(post))
|
||||
existing = Map.get(existing_keys, post.id)
|
||||
content_hash = hash_text(raw_text)
|
||||
|
||||
%{
|
||||
post: post,
|
||||
existing: existing,
|
||||
raw_text: raw_text,
|
||||
content_hash: content_hash,
|
||||
needs_embed?: force? or is_nil(existing) or existing.content_hash != content_hash
|
||||
}
|
||||
end)
|
||||
|
||||
pending = Enum.filter(prepared, & &1.needs_embed?)
|
||||
:ok = report_rebuild_started(on_progress, length(pending), "embedding entries")
|
||||
|
||||
case embed_pending(pending, on_progress) do
|
||||
{:ok, vectors_by_post_id} -> {:ok, collect_rows(prepared, vectors_by_post_id, base_label)}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
defp collect_rows(prepared, vectors_by_post_id, base_label) do
|
||||
{rows, _next_label} =
|
||||
Enum.reduce(prepared, {[], base_label + 1}, fn entry, {acc, next_label} ->
|
||||
if entry.needs_embed? do
|
||||
vector = Map.fetch!(vectors_by_post_id, entry.post.id)
|
||||
label = if entry.existing, do: entry.existing.label, else: next_label
|
||||
bump = if entry.existing, do: 0, else: 1
|
||||
|
||||
row = [
|
||||
label,
|
||||
entry.post.id,
|
||||
entry.post.project_id,
|
||||
entry.content_hash,
|
||||
encode_vector(vector)
|
||||
]
|
||||
|
||||
{[row | acc], next_label + bump}
|
||||
else
|
||||
{acc, next_label}
|
||||
end
|
||||
end)
|
||||
|
||||
rows
|
||||
end
|
||||
|
||||
defp embed_pending([], _on_progress), do: {:ok, %{}}
|
||||
|
||||
defp embed_pending(pending, on_progress) do
|
||||
total = length(pending)
|
||||
batch = batch_size()
|
||||
|
||||
pending
|
||||
# Group by language so the lexical stub stems consistently; the neural
|
||||
# backend is multilingual and ignores the language hint.
|
||||
|> Enum.group_by(& &1.post.language)
|
||||
|> Enum.reduce_while({%{}, 0}, fn {language, group}, acc ->
|
||||
group
|
||||
|> Enum.chunk_every(batch)
|
||||
|> Enum.reduce_while(acc, fn chunk, {vectors, done} ->
|
||||
case embed_many(Enum.map(chunk, & &1.raw_text), language) do
|
||||
{:ok, chunk_vectors} ->
|
||||
vectors =
|
||||
chunk
|
||||
|> Enum.zip(chunk_vectors)
|
||||
|> Enum.reduce(vectors, fn {entry, vector}, acc ->
|
||||
Map.put(acc, entry.post.id, vector)
|
||||
end)
|
||||
|
||||
done = done + length(chunk)
|
||||
:ok = report_rebuild_progress(on_progress, done, total, "embedding entries")
|
||||
{:cont, {vectors, done}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:halt, {:error, reason}}
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
{:error, reason} -> {:halt, {:error, reason}}
|
||||
accumulator -> {:cont, accumulator}
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
{:error, reason} -> {:error, reason}
|
||||
{vectors, _done} -> {:ok, vectors}
|
||||
end
|
||||
end
|
||||
|
||||
defp batch_upsert_keys([]), do: :ok
|
||||
|
||||
defp batch_upsert_keys(rows) do
|
||||
rows
|
||||
|> Enum.chunk_every(@key_batch_size)
|
||||
|> Enum.each(fn chunk ->
|
||||
placeholders = Enum.map_join(chunk, ", ", fn _ -> "(?, ?, ?, ?, ?)" end)
|
||||
params = List.flatten(chunk)
|
||||
|
||||
Repo.query!(
|
||||
"INSERT INTO embedding_keys (label, post_id, project_id, content_hash, vector) VALUES #{placeholders} ON CONFLICT(label) DO UPDATE SET content_hash = excluded.content_hash, vector = excluded.vector",
|
||||
params
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
def remove_post(post_id) when is_binary(post_id) do
|
||||
project_id =
|
||||
@@ -227,29 +378,21 @@ defmodule BDS.Embeddings do
|
||||
order_by: [asc: post.created_at, asc: post.slug]
|
||||
)
|
||||
|
||||
Enum.each(posts, fn post ->
|
||||
body = resolve_post_body(post)
|
||||
content_hash = hash_text(compose_embedding_source(post.title, body))
|
||||
|
||||
case Repo.get_by(Key, post_id: post.id, project_id: project_id) do
|
||||
%Key{content_hash: ^content_hash} ->
|
||||
:ok
|
||||
|
||||
_other ->
|
||||
:ok =
|
||||
sync_post_if_enabled(
|
||||
%{post | content: if(post.content in [nil, ""], do: body, else: post.content)},
|
||||
refresh_index: false
|
||||
)
|
||||
end
|
||||
end)
|
||||
existing_keys = preload_keys_by_post_id(project_id)
|
||||
|
||||
case build_key_rows(posts, existing_keys, max_label_value(), nil, false) do
|
||||
{:ok, rows} ->
|
||||
batch_upsert_keys(rows)
|
||||
:ok = rebuild_snapshot(project_id)
|
||||
|
||||
indexed =
|
||||
Repo.all(from key in Key, where: key.project_id == ^project_id, select: key.post_id)
|
||||
|
||||
{:ok, indexed}
|
||||
|
||||
{:error, _reason} = error ->
|
||||
error
|
||||
end
|
||||
else
|
||||
{:ok, []}
|
||||
end
|
||||
@@ -263,28 +406,28 @@ defmodule BDS.Embeddings do
|
||||
{:error, :not_found} ->
|
||||
{:ok, []}
|
||||
|
||||
{:ok, post, source_vector} ->
|
||||
similar =
|
||||
case Index.neighbors(post.project_id, post.id, limit) do
|
||||
{:ok, _post, nil} ->
|
||||
{:ok, []}
|
||||
|
||||
{:ok, post, %Key{} = key} ->
|
||||
{:ok, query_similar(post.project_id, key, limit)}
|
||||
end
|
||||
end
|
||||
|
||||
# Queries the HNSW index for a post's neighbours, rebuilding the index from
|
||||
# the DB vectors if it is not currently loaded (e.g. after a restart).
|
||||
defp query_similar(project_id, %Key{} = key, limit) do
|
||||
case Index.neighbors(project_id, key.label, key.vector, limit) do
|
||||
{:ok, neighbors} ->
|
||||
neighbors
|
||||
|
||||
{:error, :missing} ->
|
||||
Repo.all(
|
||||
from key in Key,
|
||||
where: key.project_id == ^post.project_id and key.post_id != ^post.id
|
||||
)
|
||||
|> Enum.map(fn key ->
|
||||
%{
|
||||
post_id: key.post_id,
|
||||
score: cosine_similarity(source_vector, decode_vector(key.vector))
|
||||
}
|
||||
end)
|
||||
|> Enum.sort_by(& &1.score, :desc)
|
||||
|> Enum.take(max(limit, 0))
|
||||
end
|
||||
:ok = rebuild_snapshot(project_id)
|
||||
|
||||
{:ok, similar}
|
||||
case Index.neighbors(project_id, key.label, key.vector, limit) do
|
||||
{:ok, neighbors} -> neighbors
|
||||
{:error, :missing} -> []
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -297,8 +440,12 @@ defmodule BDS.Embeddings do
|
||||
{:error, :not_found} ->
|
||||
{:ok, %{}}
|
||||
|
||||
{:ok, post, source_vector} ->
|
||||
{:ok, _post, nil} ->
|
||||
{:ok, %{}}
|
||||
|
||||
{:ok, post, %Key{} = source_key} ->
|
||||
target_ids = Enum.uniq(target_post_ids)
|
||||
source_vector = decode_vector(source_key.vector)
|
||||
|
||||
scores =
|
||||
Repo.all(
|
||||
@@ -354,47 +501,19 @@ defmodule BDS.Embeddings do
|
||||
if enabled_for_project?(project_id) do
|
||||
on_progress = progress_callback(opts)
|
||||
dismissed = dismissed_pair_keys(project_id)
|
||||
entries = load_index_entries(project_id)
|
||||
|
||||
pairs =
|
||||
case duplicate_pairs_with_rebuild(project_id, entries, on_progress) do
|
||||
{:ok, pairs} -> pairs
|
||||
{:error, :missing} -> []
|
||||
end
|
||||
|
||||
duplicates =
|
||||
case Index.duplicate_pairs(project_id, @duplicate_threshold, on_progress: on_progress) do
|
||||
{:ok, pairs} ->
|
||||
pairs
|
||||
|> Enum.reject(fn pair -> pair_key(pair.post_id_a, pair.post_id_b) in dismissed end)
|
||||
|> enrich_duplicate_pairs(project_id)
|
||||
|
||||
{:error, :missing} ->
|
||||
keys =
|
||||
Repo.all(
|
||||
from key in Key,
|
||||
where: key.project_id == ^project_id,
|
||||
order_by: [asc: key.post_id]
|
||||
)
|
||||
|
||||
total_keys = length(keys)
|
||||
|
||||
:ok = report_rebuild_started(on_progress, total_keys, "embedding entries")
|
||||
|
||||
keys
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.flat_map(fn {left, index} ->
|
||||
:ok = report_rebuild_progress(on_progress, index, total_keys, "embedding entries")
|
||||
|
||||
for right <- keys,
|
||||
left.post_id < right.post_id,
|
||||
pair_key(left.post_id, right.post_id) not in dismissed,
|
||||
similarity =
|
||||
cosine_similarity(decode_vector(left.vector), decode_vector(right.vector)),
|
||||
similarity >= @duplicate_threshold do
|
||||
%{
|
||||
post_id_a: left.post_id,
|
||||
post_id_b: right.post_id,
|
||||
score: similarity
|
||||
}
|
||||
end
|
||||
end)
|
||||
|> enrich_duplicate_pairs(project_id)
|
||||
end
|
||||
|
||||
:ok = report_rebuild_phase(on_progress, 0.99, "Resolving duplicate candidates")
|
||||
{:ok, duplicates}
|
||||
else
|
||||
@@ -457,17 +576,35 @@ defmodule BDS.Embeddings do
|
||||
with {:ok, post} <- fetch_post(post_id) do
|
||||
if enabled_for_project?(post.project_id) do
|
||||
:ok = ensure_key(post)
|
||||
|
||||
case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do
|
||||
nil -> {:ok, post, []}
|
||||
key -> {:ok, post, decode_vector(key.vector)}
|
||||
end
|
||||
{:ok, post, Repo.get_by(Key, post_id: post.id, project_id: post.project_id)}
|
||||
else
|
||||
{:disabled, post.project_id}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp duplicate_pairs_with_rebuild(project_id, entries, on_progress) do
|
||||
case Index.duplicate_pairs(project_id, entries, @duplicate_threshold,
|
||||
on_progress: on_progress
|
||||
) do
|
||||
{:ok, pairs} ->
|
||||
{:ok, pairs}
|
||||
|
||||
{:error, :missing} ->
|
||||
:ok = rebuild_snapshot(project_id)
|
||||
Index.duplicate_pairs(project_id, entries, @duplicate_threshold, on_progress: on_progress)
|
||||
end
|
||||
end
|
||||
|
||||
defp load_index_entries(project_id) do
|
||||
Repo.all(
|
||||
from key in Key,
|
||||
where: key.project_id == ^project_id,
|
||||
order_by: [asc: key.post_id]
|
||||
)
|
||||
|> Enum.map(fn key -> %{label: key.label, post_id: key.post_id, vector: key.vector} end)
|
||||
end
|
||||
|
||||
defp ensure_key(%Post{} = post) do
|
||||
case Repo.get_by(Key, post_id: post.id, project_id: post.project_id) do
|
||||
nil -> sync_post(post)
|
||||
@@ -574,11 +711,42 @@ defmodule BDS.Embeddings do
|
||||
end
|
||||
|
||||
defp embed_text(raw_text, language) do
|
||||
configured_backend().embed("query: " <> raw_text, language: language)
|
||||
# Per-backend preprocessing (e5 "query: " prefix, pooling, normalisation)
|
||||
# is the backend's responsibility — see BDS.Embeddings.Backends.Neural.
|
||||
configured_backend().embed(raw_text, language: language)
|
||||
end
|
||||
|
||||
# Embeds a batch of texts in one shot. Backends that implement the optional
|
||||
# embed_many/2 callback (e.g. the neural backend, which feeds them through the
|
||||
# model as a single batched inference run) handle the whole list; others fall
|
||||
# back to sequential single embeds.
|
||||
defp embed_many(texts, language) do
|
||||
backend = configured_backend()
|
||||
|
||||
if function_exported?(backend, :embed_many, 2) do
|
||||
backend.embed_many(texts, language: language)
|
||||
else
|
||||
Enum.reduce_while(texts, {:ok, []}, fn text, {:ok, acc} ->
|
||||
case backend.embed(text, language: language) do
|
||||
{:ok, vector} -> {:cont, {:ok, [vector | acc]}}
|
||||
{:error, _reason} = error -> {:halt, error}
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
{:ok, vectors} -> {:ok, Enum.reverse(vectors)}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp batch_size do
|
||||
Application.get_env(:bds, :embeddings, [])
|
||||
|> Keyword.get(:batch_size, 16)
|
||||
|> max(1)
|
||||
end
|
||||
|
||||
defp rebuild_snapshot(project_id) do
|
||||
Index.rebuild(project_id, model_id: model_id(), dimensions: dimensions())
|
||||
Index.put(project_id, dimensions(), load_index_entries(project_id))
|
||||
end
|
||||
|
||||
defp progress_callback(opts), do: ProgressReporter.callback(opts)
|
||||
@@ -603,13 +771,6 @@ defmodule BDS.Embeddings do
|
||||
defp report_rebuild_phase(callback, value, label),
|
||||
do: ProgressReporter.report_phase(callback, value, label)
|
||||
|
||||
defp snapshot_content_hash(project_id, post_id) do
|
||||
case Index.read(project_id) do
|
||||
{:ok, snapshot} -> get_in(snapshot, ["entries", post_id, "content_hash"])
|
||||
_other -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp current_embedding_status(nil, _expected_hash), do: "missing"
|
||||
|
||||
defp current_embedding_status(%Key{vector: vector}, _expected_hash) when vector in [nil, ""],
|
||||
@@ -645,8 +806,22 @@ defmodule BDS.Embeddings do
|
||||
|
||||
defp hash_text(text), do: :crypto.hash(:sha256, text) |> Base.encode16(case: :lower)
|
||||
|
||||
# Vectors are persisted as a packed little-endian Float32 BLOB
|
||||
# (`dimensions` * 4 bytes; 1536 bytes for multilingual-e5-small) per the
|
||||
# VectorCacheInDb invariant in specs/embedding.allium.
|
||||
defp encode_vector(values) when is_list(values) do
|
||||
for value <- values, into: <<>>, do: <<float32(value)::float-32-little>>
|
||||
end
|
||||
|
||||
defp float32(value) when is_float(value), do: value
|
||||
defp float32(value) when is_integer(value), do: value * 1.0
|
||||
|
||||
defp decode_vector(nil), do: []
|
||||
defp decode_vector(vector), do: Jason.decode!(vector)
|
||||
defp decode_vector(<<>>), do: []
|
||||
|
||||
defp decode_vector(binary) when is_binary(binary) do
|
||||
for <<value::float-32-little <- binary>>, do: value
|
||||
end
|
||||
|
||||
defp cosine_similarity([], _other), do: 0.0
|
||||
defp cosine_similarity(_vector, []), do: 0.0
|
||||
|
||||
@@ -3,4 +3,15 @@ defmodule BDS.Embeddings.Backend do
|
||||
|
||||
@callback model_info() :: %{model_id: String.t(), dimensions: pos_integer()}
|
||||
@callback embed(String.t(), keyword()) :: {:ok, [number()]} | {:error, term()}
|
||||
|
||||
@doc """
|
||||
Embeds a list of texts in a single call.
|
||||
|
||||
Backends that can amortise work across inputs (e.g. running the neural model
|
||||
on a batched tensor) should implement this. The result list is aligned with
|
||||
the input list. Optional — callers fall back to repeated `embed/2`.
|
||||
"""
|
||||
@callback embed_many([String.t()], keyword()) :: {:ok, [[number()]]} | {:error, term()}
|
||||
|
||||
@optional_callbacks embed_many: 2
|
||||
end
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
defmodule BDS.Embeddings.Backends.InApp do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Deterministic lexical embedding stub.
|
||||
|
||||
This backend does NOT satisfy the `RealNeuralModel` invariant — it projects
|
||||
stemmed tokens and bigrams into a sparse hashed vector. It exists only as an
|
||||
offline, dependency-free fallback for tests and environments where the neural
|
||||
model (see `BDS.Embeddings.Backends.Neural`) cannot be loaded. Production and
|
||||
development use the neural backend.
|
||||
"""
|
||||
|
||||
@behaviour BDS.Embeddings.Backend
|
||||
|
||||
@@ -29,6 +37,17 @@ defmodule BDS.Embeddings.Backends.InApp do
|
||||
{:ok, vector}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def embed_many(texts, opts) when is_list(texts) and is_list(opts) do
|
||||
vectors =
|
||||
Enum.map(texts, fn text ->
|
||||
{:ok, vector} = embed(text, opts)
|
||||
vector
|
||||
end)
|
||||
|
||||
{:ok, vectors}
|
||||
end
|
||||
|
||||
defp tokenize(text) do
|
||||
Regex.scan(~r/[[:alnum:]]+/u, String.downcase(text))
|
||||
|> List.flatten()
|
||||
|
||||
211
lib/bds/embeddings/backends/neural.ex
Normal file
211
lib/bds/embeddings/backends/neural.ex
Normal file
@@ -0,0 +1,211 @@
|
||||
defmodule BDS.Embeddings.Backends.Neural do
|
||||
@moduledoc """
|
||||
Real on-device neural embedding backend.
|
||||
|
||||
Implements the `RealNeuralModel` and `ModelCaching` invariants from
|
||||
`specs/embedding.allium`: embeddings are produced by the actual
|
||||
multilingual-e5-small transformer (the `intfloat/multilingual-e5-small`
|
||||
weights behind the `Xenova/multilingual-e5-small` identifier) via
|
||||
Bumblebee + EXLA, never by a lexical approximation.
|
||||
|
||||
* Lazy-loaded — the model pipeline is built on the first embedding
|
||||
request, not at application startup.
|
||||
* Model files (~100 MB) are downloaded from the Hugging Face Hub on
|
||||
first use and cached on disk (Bumblebee cache dir), persisting across
|
||||
sessions and project switches.
|
||||
* Text preprocessing follows the e5 convention: every input is prefixed
|
||||
with `"query: "`, pooled with mean pooling over the attention mask, and
|
||||
L2-normalised. This is what makes cross-language semantic similarity
|
||||
work.
|
||||
* Inference is batched. `embed_many/2` runs the model on `batch_size`
|
||||
texts per compiled inference run instead of one at a time, which is the
|
||||
dominant cost when (re)indexing large numbers of posts. The serving is
|
||||
compiled for a fixed `batch_size`/`sequence_length` (configurable);
|
||||
shorter sequences mean less wasted transformer compute.
|
||||
|
||||
Hardware acceleration follows the `NativeAcceleratedExecution` invariant.
|
||||
The serving's defn compiler is chosen at build time:
|
||||
|
||||
* On Apple Silicon (arm64 macOS) with EMLX available, inference runs on the
|
||||
Apple GPU via MLX/Metal (`compiler: EMLX`, params placed on the
|
||||
`EMLX.Backend` GPU device).
|
||||
* Everywhere else — and as a fallback when EMLX is unavailable or explicitly
|
||||
disabled — it runs on optimised native CPU via XLA (`compiler: EXLA`).
|
||||
|
||||
The accelerator can be pinned with `config :bds, :embeddings, accelerator:`
|
||||
to `:auto` (default), `:emlx`, or `:exla`.
|
||||
"""
|
||||
|
||||
@behaviour BDS.Embeddings.Backend
|
||||
|
||||
use GenServer
|
||||
|
||||
@query_prefix "query: "
|
||||
@embed_timeout :timer.minutes(10)
|
||||
|
||||
@default_model_id "Xenova/multilingual-e5-small"
|
||||
@default_model_repo "intfloat/multilingual-e5-small"
|
||||
@default_dimensions 384
|
||||
@default_batch_size 16
|
||||
@default_sequence_length 256
|
||||
@default_accelerator :auto
|
||||
|
||||
def child_spec(opts) do
|
||||
%{id: __MODULE__, start: {__MODULE__, :start_link, [opts]}}
|
||||
end
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@impl BDS.Embeddings.Backend
|
||||
def model_info do
|
||||
config = config()
|
||||
|
||||
%{
|
||||
model_id: Keyword.get(config, :model_id, @default_model_id),
|
||||
dimensions: Keyword.get(config, :dimensions, @default_dimensions)
|
||||
}
|
||||
end
|
||||
|
||||
@impl BDS.Embeddings.Backend
|
||||
def embed(text, _opts) when is_binary(text) do
|
||||
case run([@query_prefix <> text]) do
|
||||
{:ok, [vector]} -> {:ok, vector}
|
||||
{:ok, _other} -> {:error, :unexpected_embedding_result}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
@impl BDS.Embeddings.Backend
|
||||
def embed_many([], _opts), do: {:ok, []}
|
||||
|
||||
def embed_many(texts, _opts) when is_list(texts) do
|
||||
run(Enum.map(texts, &(@query_prefix <> &1)))
|
||||
end
|
||||
|
||||
defp run(prefixed_texts) do
|
||||
GenServer.call(__MODULE__, {:embed, prefixed_texts}, @embed_timeout)
|
||||
catch
|
||||
:exit, reason -> {:error, {:embedding_backend_unavailable, reason}}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def init(_opts), do: {:ok, %{serving: nil}}
|
||||
|
||||
@impl GenServer
|
||||
def handle_call({:embed, texts}, _from, state) do
|
||||
case ensure_serving(state) do
|
||||
{:ok, %{serving: serving} = next_state} ->
|
||||
vectors =
|
||||
texts
|
||||
|> Enum.chunk_every(batch_size())
|
||||
|> Enum.flat_map(&run_chunk(serving, &1))
|
||||
|
||||
{:reply, {:ok, vectors}, next_state}
|
||||
|
||||
{:error, _reason} = error ->
|
||||
{:reply, error, state}
|
||||
end
|
||||
rescue
|
||||
exception ->
|
||||
{:reply, {:error, Exception.message(exception)}, state}
|
||||
end
|
||||
|
||||
defp run_chunk(serving, [single]) do
|
||||
%{embedding: tensor} = Nx.Serving.run(serving, single)
|
||||
[Nx.to_flat_list(tensor)]
|
||||
end
|
||||
|
||||
defp run_chunk(serving, chunk) do
|
||||
serving
|
||||
|> Nx.Serving.run(chunk)
|
||||
|> Enum.map(fn %{embedding: tensor} -> Nx.to_flat_list(tensor) end)
|
||||
end
|
||||
|
||||
defp ensure_serving(%{serving: nil} = state) do
|
||||
case build_serving() do
|
||||
{:ok, serving} -> {:ok, %{state | serving: serving}}
|
||||
{:error, _reason} = error -> error
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_serving(state), do: {:ok, state}
|
||||
|
||||
defp build_serving do
|
||||
repo = {:hf, Keyword.get(config(), :model_repo, @default_model_repo)}
|
||||
accelerator = current_accelerator()
|
||||
maybe_set_default_backend(accelerator)
|
||||
|
||||
with {:ok, model_info} <- Bumblebee.load_model(repo),
|
||||
{:ok, tokenizer} <- Bumblebee.load_tokenizer(repo) do
|
||||
serving =
|
||||
Bumblebee.Text.text_embedding(model_info, tokenizer,
|
||||
output_pool: :mean_pooling,
|
||||
output_attribute: :hidden_state,
|
||||
embedding_processor: :l2_norm,
|
||||
compile: [batch_size: batch_size(), sequence_length: sequence_length()],
|
||||
defn_options: defn_options(accelerator)
|
||||
)
|
||||
|
||||
{:ok, serving}
|
||||
end
|
||||
end
|
||||
|
||||
# Place model params/tensors on the Apple GPU (Metal) when accelerating with
|
||||
# EMLX so the compiled inference pass actually runs on-device. EXLA manages
|
||||
# its own device placement, so nothing to do there.
|
||||
defp maybe_set_default_backend(:emlx),
|
||||
do: Nx.global_default_backend({EMLX.Backend, device: :gpu})
|
||||
|
||||
defp maybe_set_default_backend(:exla), do: :ok
|
||||
|
||||
@doc false
|
||||
@spec defn_options(:emlx | :exla) :: keyword()
|
||||
def defn_options(:emlx), do: [compiler: EMLX]
|
||||
def defn_options(:exla), do: [compiler: EXLA]
|
||||
|
||||
@doc false
|
||||
@spec current_accelerator() :: :emlx | :exla
|
||||
def current_accelerator do
|
||||
select_accelerator(configured_accelerator(), emlx_available?(), apple_silicon?())
|
||||
end
|
||||
|
||||
@doc """
|
||||
Pure accelerator-selection policy for `NativeAcceleratedExecution`.
|
||||
|
||||
Prefer the Apple GPU (EMLX) under `:auto` only when it is both available and
|
||||
running on Apple Silicon; honour an explicit `:emlx`/`:exla` request, but
|
||||
degrade a forced `:emlx` to EXLA when EMLX is not loaded so a misconfigured
|
||||
host still gets working CPU inference instead of crashing.
|
||||
"""
|
||||
@spec select_accelerator(:auto | :emlx | :exla, boolean(), boolean()) :: :emlx | :exla
|
||||
def select_accelerator(:exla, _emlx_available?, _apple_silicon?), do: :exla
|
||||
def select_accelerator(:emlx, true, _apple_silicon?), do: :emlx
|
||||
def select_accelerator(:emlx, false, _apple_silicon?), do: :exla
|
||||
def select_accelerator(:auto, true, true), do: :emlx
|
||||
def select_accelerator(:auto, _emlx_available?, _apple_silicon?), do: :exla
|
||||
|
||||
defp configured_accelerator do
|
||||
config() |> Keyword.get(:accelerator, @default_accelerator)
|
||||
end
|
||||
|
||||
defp emlx_available? do
|
||||
Code.ensure_loaded?(EMLX) and Code.ensure_loaded?(EMLX.Backend)
|
||||
end
|
||||
|
||||
defp apple_silicon? do
|
||||
:os.type() == {:unix, :darwin} and
|
||||
to_string(:erlang.system_info(:system_architecture)) =~ ~r/aarch64|arm/
|
||||
end
|
||||
|
||||
defp batch_size do
|
||||
config() |> Keyword.get(:batch_size, @default_batch_size) |> max(1)
|
||||
end
|
||||
|
||||
defp sequence_length do
|
||||
config() |> Keyword.get(:sequence_length, @default_sequence_length) |> max(1)
|
||||
end
|
||||
|
||||
defp config, do: Application.get_env(:bds, :embeddings, [])
|
||||
end
|
||||
@@ -1,214 +1,573 @@
|
||||
defmodule BDS.Embeddings.Index do
|
||||
@moduledoc false
|
||||
@moduledoc """
|
||||
Per-project approximate-nearest-neighbour index over post embeddings.
|
||||
|
||||
import Ecto.Query
|
||||
Backed by an HNSW graph (hnswlib) per the A1-14b / `specs/embedding.allium`
|
||||
requirement — cosine space, connectivity M=16, efConstruction=128,
|
||||
efSearch=64. This replaces the previous O(n²) brute-force cosine snapshot:
|
||||
building is O(n·log n) and queries are O(log n).
|
||||
|
||||
The process is intentionally **database-free**: callers (running in their own
|
||||
process, e.g. under the test SQL sandbox) read embedding vectors from the DB
|
||||
and hand them in. This GenServer owns only the in-memory HNSW graphs, the
|
||||
`label → post_id` maps, and file persistence.
|
||||
|
||||
Persistence (DebouncedPersistence invariant): the index file
|
||||
(`embeddings.usearch`) plus a small sidecar holding the dimension and the
|
||||
label→post_id map are written behind a 5s debounce, and force-saved on
|
||||
project switch / shutdown. On a cold query the index is lazily reloaded from
|
||||
those files; if they are absent the caller rebuilds from the DB vectors.
|
||||
"""
|
||||
|
||||
use GenServer
|
||||
|
||||
alias BDS.Persistence
|
||||
alias BDS.Embeddings.Key
|
||||
alias BDS.Projects
|
||||
alias BDS.ProgressReporter
|
||||
alias BDS.Repo
|
||||
|
||||
@neighbor_limit 21
|
||||
@debounce_ms 5_000
|
||||
@space :cosine
|
||||
@m 16
|
||||
@ef_construction 128
|
||||
@ef_search 64
|
||||
@meta_key :"$meta"
|
||||
|
||||
# ─── Public API ─────────────────────────────────────────────
|
||||
|
||||
def start_link(opts \\ []) do
|
||||
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
@doc "On-disk path of the HNSW index file for a project."
|
||||
def path(project_id) when is_binary(project_id) do
|
||||
Path.join(Projects.project_cache_dir(project_id), "embeddings.usearch")
|
||||
end
|
||||
|
||||
def rebuild(project_id, opts) when is_binary(project_id) and is_list(opts) do
|
||||
model_id = Keyword.fetch!(opts, :model_id)
|
||||
dimensions = Keyword.fetch!(opts, :dimensions)
|
||||
@doc """
|
||||
(Re)builds the index for a project from the given entries and schedules a
|
||||
debounced save. `entries` is a list of `%{label:, post_id:, vector:}` where
|
||||
`vector` is the packed little-endian Float32 BLOB.
|
||||
"""
|
||||
def put(project_id, dimensions, entries)
|
||||
when is_binary(project_id) and is_integer(dimensions) and is_list(entries) do
|
||||
GenServer.call(__MODULE__, {:put, project_id, dimensions, entries}, :infinity)
|
||||
end
|
||||
|
||||
keys =
|
||||
Repo.all(
|
||||
from key in Key,
|
||||
where: key.project_id == ^project_id,
|
||||
order_by: [asc: key.post_id]
|
||||
@doc """
|
||||
Returns up to `limit` nearest neighbours of `query_vector` (the post's packed
|
||||
BLOB), excluding `query_label`. `{:error, :missing}` if no index is available.
|
||||
"""
|
||||
def neighbors(project_id, query_label, query_vector, limit)
|
||||
when is_binary(project_id) and is_integer(query_label) and is_binary(query_vector) do
|
||||
GenServer.call(
|
||||
__MODULE__,
|
||||
{:neighbors, project_id, query_label, query_vector, limit},
|
||||
:infinity
|
||||
)
|
||||
|
||||
entries =
|
||||
keys
|
||||
|> Enum.map(fn key ->
|
||||
vector = decode_vector(key.vector)
|
||||
|
||||
{key.post_id,
|
||||
%{
|
||||
"label" => key.label,
|
||||
"content_hash" => key.content_hash,
|
||||
"neighbors" => neighbor_entries(keys, key, vector)
|
||||
}}
|
||||
end)
|
||||
|> Map.new()
|
||||
|
||||
payload = %{
|
||||
"project_id" => project_id,
|
||||
"model_id" => model_id,
|
||||
"dimensions" => dimensions,
|
||||
"updated_at" => Persistence.now_ms(),
|
||||
"entries" => entries
|
||||
}
|
||||
|
||||
write_snapshot(path(project_id), payload, project_id)
|
||||
end
|
||||
|
||||
def read(project_id) when is_binary(project_id) do
|
||||
project_id
|
||||
|> candidate_paths()
|
||||
|> read_snapshot_paths()
|
||||
@doc """
|
||||
Finds near-duplicate pairs at/above `threshold` by querying the HNSW graph for
|
||||
each entry's neighbours. `{:error, :missing}` if no index is available.
|
||||
"""
|
||||
def duplicate_pairs(project_id, entries, threshold, opts \\ [])
|
||||
when is_binary(project_id) and is_list(entries) and is_number(threshold) do
|
||||
GenServer.call(
|
||||
__MODULE__,
|
||||
{:duplicate_pairs, project_id, entries, threshold, opts},
|
||||
:infinity
|
||||
)
|
||||
end
|
||||
|
||||
def neighbors(project_id, post_id, limit) when is_binary(project_id) and is_binary(post_id) do
|
||||
with {:ok, snapshot} <- read(project_id),
|
||||
%{} = entry <- get_in(snapshot, ["entries", post_id]) do
|
||||
entry
|
||||
|> Map.get("neighbors", [])
|
||||
|> Enum.take(max(limit, 0))
|
||||
|> Enum.map(fn neighbor ->
|
||||
%{
|
||||
post_id: neighbor["post_id"],
|
||||
score: neighbor["score"]
|
||||
}
|
||||
end)
|
||||
|> then(&{:ok, &1})
|
||||
@doc "Forces a pending save for a project to disk now (e.g. on project switch)."
|
||||
def flush(project_id) when is_binary(project_id) do
|
||||
GenServer.call(__MODULE__, {:flush, project_id}, :infinity)
|
||||
end
|
||||
|
||||
@doc "Forces all pending saves to disk now (e.g. on shutdown)."
|
||||
def flush_all do
|
||||
GenServer.call(__MODULE__, :flush_all, :infinity)
|
||||
end
|
||||
|
||||
@doc "Drops the in-memory index for a project (e.g. on project deletion)."
|
||||
def forget(project_id) when is_binary(project_id) do
|
||||
GenServer.call(__MODULE__, {:forget, project_id}, :infinity)
|
||||
end
|
||||
|
||||
# ─── GenServer ──────────────────────────────────────────────
|
||||
|
||||
@impl true
|
||||
def init(_opts) do
|
||||
Process.flag(:trap_exit, true)
|
||||
{:ok, %{@meta_key => %{flush_all_waiters: []}}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_call({:put, project_id, dimensions, entries}, from, state) do
|
||||
# Cancel any pending debounce for this project first: build_entry/2 returns a
|
||||
# fresh entry with timer: nil, so without this the previous timer would be
|
||||
# orphaned (left to fire a redundant save) instead of coalescing.
|
||||
state = cancel_pending_save(state, project_id)
|
||||
state = start_build(state, project_id, dimensions, entries, from)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
def handle_call({:neighbors, project_id, query_label, query_vector, limit}, _from, state) do
|
||||
case ensure_loaded(state, project_id) do
|
||||
{:ok, %{index: nil}, state} ->
|
||||
{:reply, {:error, :missing}, state}
|
||||
|
||||
{:ok, entry, state} ->
|
||||
{:reply, {:ok, query_neighbors(entry, query_label, query_vector, limit)}, state}
|
||||
|
||||
{:missing, state} ->
|
||||
{:reply, {:error, :missing}, state}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_call({:duplicate_pairs, project_id, entries, threshold, opts}, from, state) do
|
||||
case ensure_loaded(state, project_id) do
|
||||
{:ok, %{index: nil}, state} ->
|
||||
{:reply, {:error, :missing}, state}
|
||||
|
||||
{:ok, entry, state} ->
|
||||
state = start_duplicate_scan(state, project_id, entry, entries, threshold, opts, from)
|
||||
{:noreply, state}
|
||||
|
||||
{:missing, state} ->
|
||||
{:reply, {:error, :missing}, state}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_call({:flush, project_id}, from, state) do
|
||||
case Map.get(state, project_id) do
|
||||
%{build: %{}} = entry ->
|
||||
entry = update_in(entry.build.flush_waiters, &[from | &1])
|
||||
{:noreply, Map.put(state, project_id, entry)}
|
||||
|
||||
_other ->
|
||||
{:reply, :ok, save_now(state, project_id)}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_call(:flush_all, from, state) do
|
||||
if builds_in_progress?(state) do
|
||||
state = update_meta(state, fn meta -> %{meta | flush_all_waiters: [from | meta.flush_all_waiters]} end)
|
||||
{:noreply, state}
|
||||
else
|
||||
_ -> {:error, :missing}
|
||||
state = flush_all_projects(state)
|
||||
{:reply, :ok, state}
|
||||
end
|
||||
end
|
||||
|
||||
def duplicate_pairs(project_id, threshold, opts \\ []) when is_binary(project_id) do
|
||||
with {:ok, snapshot} <- read(project_id) do
|
||||
entries = Map.get(snapshot, "entries", %{})
|
||||
entry_count = map_size(entries)
|
||||
on_progress = progress_callback(opts)
|
||||
def handle_call({:forget, project_id}, _from, state) do
|
||||
{:reply, :ok, forget_project(state, project_id)}
|
||||
end
|
||||
|
||||
:ok = report_scan_started(on_progress, entry_count, "embedding entries")
|
||||
@impl true
|
||||
def handle_info({:save, project_id}, state) do
|
||||
{:noreply, save_now(state, project_id)}
|
||||
end
|
||||
|
||||
def handle_info({ref, built_entry}, state) when is_reference(ref) do
|
||||
case find_build_owner(state, ref) do
|
||||
{:ok, project_id, entry} ->
|
||||
Process.demonitor(ref, [:flush])
|
||||
{:noreply, complete_build(state, project_id, entry, built_entry)}
|
||||
|
||||
:error ->
|
||||
case find_scan_owner(state, ref) do
|
||||
{:ok, project_id, entry, %{from: from}} ->
|
||||
Process.demonitor(ref, [:flush])
|
||||
GenServer.reply(from, {:ok, built_entry})
|
||||
entry = %{entry | scans: Map.delete(entry.scans, ref)}
|
||||
{:noreply, Map.put(state, project_id, entry)}
|
||||
|
||||
:error ->
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info({:DOWN, ref, :process, _pid, reason}, state) when is_reference(ref) do
|
||||
case find_build_owner(state, ref) do
|
||||
{:ok, _project_id, _entry} ->
|
||||
exit({:index_build_failed, reason})
|
||||
|
||||
:error ->
|
||||
case find_scan_owner(state, ref) do
|
||||
{:ok, _project_id, _entry, _scan} -> exit({:duplicate_scan_failed, reason})
|
||||
:error -> {:noreply, state}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info(_message, state), do: {:noreply, state}
|
||||
|
||||
@impl true
|
||||
def terminate(_reason, state) do
|
||||
Enum.each(project_ids(state), &save_now(state, &1))
|
||||
:ok
|
||||
end
|
||||
|
||||
# ─── Build / query ──────────────────────────────────────────
|
||||
|
||||
defp build_entry(dimensions, []), do: %{index: nil, labels: %{}, dim: dimensions, timer: nil}
|
||||
|
||||
defp build_entry(dimensions, entries) do
|
||||
count = length(entries)
|
||||
|
||||
{:ok, index} =
|
||||
HNSWLib.Index.new(@space, dimensions, count, m: @m, ef_construction: @ef_construction)
|
||||
|
||||
:ok = HNSWLib.Index.set_ef(index, @ef_search)
|
||||
|
||||
tensor =
|
||||
entries
|
||||
|> Enum.map(& &1.vector)
|
||||
|> IO.iodata_to_binary()
|
||||
|> Nx.from_binary(:f32)
|
||||
|> Nx.reshape({count, dimensions})
|
||||
|
||||
:ok = HNSWLib.Index.add_items(index, tensor, ids: Enum.map(entries, & &1.label))
|
||||
|
||||
%{
|
||||
index: index,
|
||||
labels: Map.new(entries, &{&1.label, &1.post_id}),
|
||||
dim: dimensions,
|
||||
timer: nil
|
||||
}
|
||||
end
|
||||
|
||||
defp query_neighbors(%{index: index, labels: labels}, query_label, query_vector, limit) do
|
||||
case query(index, query_vector, limit + 1) do
|
||||
[] ->
|
||||
[]
|
||||
|
||||
results ->
|
||||
results
|
||||
|> Enum.reject(fn {label, _score} -> label == query_label end)
|
||||
|> Enum.map(fn {label, score} -> %{post_id: Map.get(labels, label), score: score} end)
|
||||
|> Enum.reject(&is_nil(&1.post_id))
|
||||
|> Enum.take(max(limit, 0))
|
||||
end
|
||||
end
|
||||
|
||||
defp scan_duplicates(%{index: index, labels: labels}, entries, threshold, opts) do
|
||||
on_progress = ProgressReporter.callback(opts)
|
||||
total = length(entries)
|
||||
:ok = report_scan_started(on_progress, total, "embedding entries")
|
||||
|
||||
pairs =
|
||||
entries
|
||||
|> Enum.with_index(1)
|
||||
|> Enum.flat_map(fn {{post_id, entry}, index} ->
|
||||
:ok = report_scan_progress(on_progress, index, entry_count, "embedding entries")
|
||||
|> Enum.flat_map(fn {entry, position} ->
|
||||
:ok = report_scan_progress(on_progress, position, total, "embedding entries")
|
||||
|
||||
entry
|
||||
|> Map.get("neighbors", [])
|
||||
|> Enum.filter(&(&1["score"] >= threshold))
|
||||
|> Enum.map(fn neighbor ->
|
||||
{post_id_a, post_id_b} = sort_pair(post_id, neighbor["post_id"])
|
||||
|
||||
{{post_id_a, post_id_b},
|
||||
%{
|
||||
post_id_a: post_id_a,
|
||||
post_id_b: post_id_b,
|
||||
score: neighbor["score"]
|
||||
}}
|
||||
index
|
||||
|> query(entry.vector, @neighbor_limit)
|
||||
|> Enum.reject(fn {label, _score} -> label == entry.label end)
|
||||
|> Enum.map(fn {label, score} -> {Map.get(labels, label), score} end)
|
||||
|> Enum.filter(fn {post_id, score} -> not is_nil(post_id) and score >= threshold end)
|
||||
|> Enum.map(fn {other_post_id, score} ->
|
||||
{post_id_a, post_id_b} = sort_pair(entry.post_id, other_post_id)
|
||||
{{post_id_a, post_id_b}, %{post_id_a: post_id_a, post_id_b: post_id_b, score: score}}
|
||||
end)
|
||||
end)
|
||||
|> Map.new()
|
||||
|> Map.values()
|
||||
|> Enum.sort_by(& &1.score, :desc)
|
||||
end
|
||||
|
||||
{:ok, pairs}
|
||||
else
|
||||
_ -> {:error, :missing}
|
||||
# Runs a knn query and returns [{label, similarity}] sorted by descending
|
||||
# similarity. Cosine distance is converted to similarity as max(0, 1 - d).
|
||||
defp query(index, query_vector, k) do
|
||||
case HNSWLib.Index.get_current_count(index) do
|
||||
{:ok, count} when count > 0 ->
|
||||
clamped = min(k, count)
|
||||
|
||||
case HNSWLib.Index.knn_query(index, query_vector, k: clamped) do
|
||||
{:ok, labels, distances} ->
|
||||
Enum.zip(
|
||||
Nx.to_flat_list(labels),
|
||||
Enum.map(Nx.to_flat_list(distances), fn distance -> max(0.0, 1.0 - distance) end)
|
||||
)
|
||||
|
||||
{:error, _reason} ->
|
||||
[]
|
||||
end
|
||||
|
||||
_other ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp neighbor_entries(keys, current_key, current_vector) do
|
||||
keys
|
||||
|> Enum.reject(&(&1.post_id == current_key.post_id))
|
||||
|> Enum.map(fn other_key ->
|
||||
%{
|
||||
"post_id" => other_key.post_id,
|
||||
"label" => other_key.label,
|
||||
"score" => cosine_similarity(current_vector, decode_vector(other_key.vector))
|
||||
}
|
||||
end)
|
||||
|> Enum.sort_by(& &1["score"], :desc)
|
||||
|> Enum.take(@neighbor_limit)
|
||||
# ─── Persistence ────────────────────────────────────────────
|
||||
|
||||
defp schedule_save(state, project_id) do
|
||||
entry = Map.fetch!(state, project_id)
|
||||
if is_reference(entry.timer), do: Process.cancel_timer(entry.timer)
|
||||
timer = Process.send_after(self(), {:save, project_id}, @debounce_ms)
|
||||
Map.put(state, project_id, %{entry | timer: timer})
|
||||
end
|
||||
|
||||
defp write_snapshot(snapshot_path, payload, project_id) do
|
||||
:ok = Persistence.atomic_write(snapshot_path, Jason.encode!(payload))
|
||||
legacy_path = legacy_path(snapshot_path)
|
||||
defp cancel_pending_save(state, project_id) do
|
||||
case Map.get(state, project_id) do
|
||||
%{timer: timer} = entry when is_reference(timer) ->
|
||||
Process.cancel_timer(timer)
|
||||
Map.put(state, project_id, %{entry | timer: nil})
|
||||
|
||||
if File.exists?(legacy_path) do
|
||||
File.rm(legacy_path)
|
||||
_other ->
|
||||
state
|
||||
end
|
||||
end
|
||||
|
||||
cleanup_legacy_project_snapshots(project_id, snapshot_path)
|
||||
defp save_now(state, project_id) do
|
||||
case Map.get(state, project_id) do
|
||||
nil ->
|
||||
state
|
||||
|
||||
%{build: %{}} = entry ->
|
||||
Map.put(state, project_id, entry)
|
||||
|
||||
entry ->
|
||||
if is_reference(entry.timer), do: Process.cancel_timer(entry.timer)
|
||||
persist(project_id, entry)
|
||||
Map.put(state, project_id, %{entry | timer: nil})
|
||||
end
|
||||
end
|
||||
|
||||
defp persist(_project_id, %{index: nil}), do: :ok
|
||||
|
||||
defp persist(project_id, %{index: index, labels: labels, dim: dim}) do
|
||||
index_path = path(project_id)
|
||||
File.mkdir_p!(Path.dirname(index_path))
|
||||
HNSWLib.Index.save_index(index, index_path)
|
||||
write_meta(index_path, dim, labels)
|
||||
:ok
|
||||
rescue
|
||||
_exception -> :ok
|
||||
end
|
||||
|
||||
defp candidate_paths(project_id) do
|
||||
current_snapshot_path = path(project_id)
|
||||
legacy_project_snapshot_path = legacy_project_snapshot_path(project_id)
|
||||
defp write_meta(index_path, dim, labels) do
|
||||
payload = %{
|
||||
"dim" => dim,
|
||||
"labels" => Enum.map(labels, fn {label, post_id} -> [label, post_id] end)
|
||||
}
|
||||
|
||||
[
|
||||
current_snapshot_path,
|
||||
legacy_path(current_snapshot_path),
|
||||
legacy_project_snapshot_path,
|
||||
legacy_project_snapshot_path && legacy_path(legacy_project_snapshot_path)
|
||||
]
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.uniq()
|
||||
File.write(meta_path(index_path), Jason.encode!(payload))
|
||||
end
|
||||
|
||||
defp read_snapshot_paths([]), do: {:error, :missing}
|
||||
defp ensure_loaded(state, project_id) do
|
||||
case Map.get(state, project_id) do
|
||||
nil ->
|
||||
case load_from_disk(project_id) do
|
||||
{:ok, entry} ->
|
||||
entry = runtime_entry(entry)
|
||||
{:ok, entry, Map.put(state, project_id, entry)}
|
||||
|
||||
defp read_snapshot_paths([snapshot_path | rest]) do
|
||||
case File.read(snapshot_path) do
|
||||
{:ok, contents} -> {:ok, Jason.decode!(contents)}
|
||||
{:error, :enoent} -> read_snapshot_paths(rest)
|
||||
{:error, reason} -> {:error, reason}
|
||||
:error -> {:missing, state}
|
||||
end
|
||||
|
||||
entry ->
|
||||
{:ok, entry, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp cleanup_legacy_project_snapshots(project_id, snapshot_path) do
|
||||
current_paths = [snapshot_path, legacy_path(snapshot_path)]
|
||||
defp load_from_disk(project_id) do
|
||||
index_path = path(project_id)
|
||||
|
||||
project_id
|
||||
|> legacy_project_snapshot_path()
|
||||
|> then(fn legacy_snapshot_path ->
|
||||
[legacy_snapshot_path, legacy_snapshot_path && legacy_path(legacy_snapshot_path)]
|
||||
with {:ok, %{dim: dim, labels: labels}} <- read_meta(index_path),
|
||||
true <- File.exists?(index_path),
|
||||
{:ok, index} <- HNSWLib.Index.load_index(@space, dim, index_path) do
|
||||
:ok = HNSWLib.Index.set_ef(index, @ef_search)
|
||||
{:ok, %{index: index, labels: labels, dim: dim, timer: nil}}
|
||||
else
|
||||
_other -> :error
|
||||
end
|
||||
rescue
|
||||
_exception -> :error
|
||||
end
|
||||
|
||||
defp read_meta(index_path) do
|
||||
with {:ok, contents} <- File.read(meta_path(index_path)),
|
||||
{:ok, %{"dim" => dim, "labels" => labels}} <- Jason.decode(contents) do
|
||||
{:ok,
|
||||
%{
|
||||
dim: dim,
|
||||
labels: Map.new(labels, fn [label, post_id] -> {label, post_id} end)
|
||||
}}
|
||||
else
|
||||
_other -> :error
|
||||
end
|
||||
end
|
||||
|
||||
defp meta_path(index_path), do: index_path <> ".meta.json"
|
||||
|
||||
defp runtime_entry(entry) do
|
||||
Map.merge(%{timer: nil, build: nil, scans: %{}}, entry)
|
||||
end
|
||||
|
||||
defp start_build(state, project_id, dimensions, entries, from) do
|
||||
entry =
|
||||
state
|
||||
|> Map.get(project_id, runtime_entry(%{index: nil, labels: %{}, dim: dimensions, timer: nil}))
|
||||
|> Map.put(:dim, dimensions)
|
||||
|
||||
case entry.build do
|
||||
nil ->
|
||||
task = start_build_task(project_id, dimensions, entries)
|
||||
build = %{ref: task.ref, pid: task.pid, callers: [from], flush_waiters: [], next_request: nil}
|
||||
Map.put(state, project_id, %{entry | build: build})
|
||||
|
||||
build ->
|
||||
build = %{build | callers: [from | build.callers], next_request: {dimensions, entries}}
|
||||
Map.put(state, project_id, %{entry | build: build})
|
||||
end
|
||||
end
|
||||
|
||||
defp start_build_task(project_id, dimensions, entries) do
|
||||
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
||||
maybe_run_test_hook({:before_build, project_id, self()})
|
||||
build_entry(dimensions, entries)
|
||||
end)
|
||||
|> Enum.filter(&is_binary/1)
|
||||
|> Enum.reject(&(&1 in current_paths))
|
||||
|> Enum.each(fn legacy_snapshot_path ->
|
||||
if File.exists?(legacy_snapshot_path) do
|
||||
File.rm(legacy_snapshot_path)
|
||||
end
|
||||
|
||||
defp complete_build(state, project_id, entry, built_entry) do
|
||||
build = entry.build
|
||||
|
||||
case build.next_request do
|
||||
{next_dimensions, next_entries} ->
|
||||
task = start_build_task(project_id, next_dimensions, next_entries)
|
||||
|
||||
build = %{build | ref: task.ref, pid: task.pid, next_request: nil}
|
||||
Map.put(state, project_id, %{entry | build: build})
|
||||
|
||||
nil ->
|
||||
Enum.each(build.callers, &GenServer.reply(&1, :ok))
|
||||
|
||||
entry = %{runtime_entry(built_entry) | scans: entry.scans}
|
||||
state = Map.put(state, project_id, entry)
|
||||
|
||||
state =
|
||||
if build.flush_waiters == [] do
|
||||
schedule_save(state, project_id)
|
||||
else
|
||||
Enum.each(build.flush_waiters, &GenServer.reply(&1, :ok))
|
||||
save_now(state, project_id)
|
||||
end
|
||||
|
||||
maybe_finish_flush_all_waiters(state)
|
||||
end
|
||||
end
|
||||
|
||||
defp start_duplicate_scan(state, project_id, entry, entries, threshold, opts, from) do
|
||||
task =
|
||||
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
||||
scan_duplicates(entry, entries, threshold, opts)
|
||||
end)
|
||||
|
||||
scans = Map.put(entry.scans, task.ref, %{pid: task.pid, from: from})
|
||||
Map.put(state, project_id, %{entry | scans: scans})
|
||||
end
|
||||
|
||||
defp forget_project(state, project_id) do
|
||||
case Map.get(state, project_id) do
|
||||
nil ->
|
||||
maybe_finish_flush_all_waiters(state)
|
||||
|
||||
entry ->
|
||||
if is_reference(entry.timer), do: Process.cancel_timer(entry.timer)
|
||||
|
||||
if build = entry.build do
|
||||
_ = Task.Supervisor.terminate_child(BDS.Tasks.TaskSupervisor, build.pid)
|
||||
Enum.each(build.callers, &GenServer.reply(&1, :ok))
|
||||
Enum.each(build.flush_waiters, &GenServer.reply(&1, :ok))
|
||||
end
|
||||
|
||||
Enum.each(entry.scans, fn {_ref, %{pid: pid, from: from}} ->
|
||||
_ = Task.Supervisor.terminate_child(BDS.Tasks.TaskSupervisor, pid)
|
||||
GenServer.reply(from, {:error, :missing})
|
||||
end)
|
||||
|
||||
state
|
||||
|> Map.delete(project_id)
|
||||
|> maybe_finish_flush_all_waiters()
|
||||
end
|
||||
end
|
||||
|
||||
defp find_build_owner(state, ref) do
|
||||
Enum.find_value(project_ids(state), :error, fn project_id ->
|
||||
case Map.get(state, project_id) do
|
||||
%{build: %{ref: ^ref}} = entry -> {:ok, project_id, entry}
|
||||
_other -> false
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp legacy_project_snapshot_path(project_id) do
|
||||
case Projects.get_project(project_id) do
|
||||
nil -> nil
|
||||
project -> Path.join(Projects.project_data_dir(project), "embeddings.usearch")
|
||||
defp find_scan_owner(state, ref) do
|
||||
Enum.find_value(project_ids(state), :error, fn project_id ->
|
||||
case Map.get(state, project_id) do
|
||||
%{scans: scans} = entry ->
|
||||
case Map.get(scans, ref) do
|
||||
nil -> false
|
||||
scan -> {:ok, project_id, entry, scan}
|
||||
end
|
||||
|
||||
_other ->
|
||||
false
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp project_ids(state) do
|
||||
state
|
||||
|> Map.keys()
|
||||
|> Enum.filter(&is_binary/1)
|
||||
end
|
||||
|
||||
defp builds_in_progress?(state) do
|
||||
Enum.any?(project_ids(state), fn project_id ->
|
||||
match?(%{build: %{}} , Map.get(state, project_id))
|
||||
end)
|
||||
end
|
||||
|
||||
defp flush_all_projects(state) do
|
||||
Enum.reduce(project_ids(state), state, &save_now(&2, &1))
|
||||
end
|
||||
|
||||
defp maybe_finish_flush_all_waiters(state) do
|
||||
meta = meta(state)
|
||||
|
||||
cond do
|
||||
meta.flush_all_waiters == [] ->
|
||||
state
|
||||
|
||||
builds_in_progress?(state) ->
|
||||
state
|
||||
|
||||
true ->
|
||||
state = flush_all_projects(state)
|
||||
Enum.each(meta.flush_all_waiters, &GenServer.reply(&1, :ok))
|
||||
put_meta(state, %{meta | flush_all_waiters: []})
|
||||
end
|
||||
end
|
||||
|
||||
defp legacy_path(snapshot_path) do
|
||||
Path.join(Path.dirname(snapshot_path), "embeddings.index.json")
|
||||
defp meta(state), do: Map.get(state, @meta_key, %{flush_all_waiters: []})
|
||||
|
||||
defp put_meta(state, meta), do: Map.put(state, @meta_key, meta)
|
||||
|
||||
defp update_meta(state, fun), do: put_meta(state, fun.(meta(state)))
|
||||
|
||||
defp maybe_run_test_hook(event) do
|
||||
case Application.get_env(:bds, :embeddings_index_test_hook) do
|
||||
callback when is_function(callback, 1) -> callback.(event)
|
||||
_other -> :ok
|
||||
end
|
||||
|
||||
defp decode_vector(nil), do: []
|
||||
defp decode_vector(vector), do: Jason.decode!(vector)
|
||||
|
||||
defp cosine_similarity([], _other), do: 0.0
|
||||
defp cosine_similarity(_vector, []), do: 0.0
|
||||
|
||||
defp cosine_similarity(left, right) do
|
||||
Enum.zip(left, right)
|
||||
|> Enum.reduce(0.0, fn {left_value, right_value}, acc -> acc + left_value * right_value end)
|
||||
|> max(0.0)
|
||||
end
|
||||
|
||||
defp sort_pair(post_id_a, post_id_b) when post_id_a <= post_id_b, do: {post_id_a, post_id_b}
|
||||
defp sort_pair(post_id_a, post_id_b), do: {post_id_b, post_id_a}
|
||||
|
||||
defp progress_callback(opts), do: ProgressReporter.callback(opts)
|
||||
|
||||
defp report_scan_started(callback, total, label) do
|
||||
ProgressReporter.report_count_started(callback, total, label,
|
||||
verb: "Scanning",
|
||||
|
||||
@@ -12,7 +12,9 @@ defmodule BDS.Embeddings.Key do
|
||||
belongs_to :project, BDS.Projects.Project, type: :string
|
||||
|
||||
field :content_hash, :string
|
||||
field :vector, :string
|
||||
# Packed little-endian Float32 BLOB (dimensions * 4 bytes), per the
|
||||
# VectorCacheInDb invariant in specs/embedding.allium.
|
||||
field :vector, :binary
|
||||
end
|
||||
|
||||
def changeset(key, attrs) do
|
||||
|
||||
@@ -17,7 +17,9 @@ defmodule BDS.Frontmatter do
|
||||
end
|
||||
|
||||
def parse_document(contents) when is_binary(contents) do
|
||||
case String.split(contents, "\n---\n", parts: 2) do
|
||||
normalized_contents = normalize_newlines(contents)
|
||||
|
||||
case String.split(normalized_contents, "\n---\n", parts: 2) do
|
||||
[frontmatter_with_marker, body] ->
|
||||
frontmatter = String.replace_prefix(frontmatter_with_marker, "---\n", "")
|
||||
|
||||
@@ -163,19 +165,11 @@ defmodule BDS.Frontmatter do
|
||||
end
|
||||
|
||||
defp parse_string("\"" <> rest) do
|
||||
rest
|
||||
|> String.trim_trailing("\"")
|
||||
|> String.replace("\\n", "\n")
|
||||
|> String.replace("\\\"", "\"")
|
||||
|> String.replace("\\\\", "\\")
|
||||
parse_quoted_string(rest, ?")
|
||||
end
|
||||
|
||||
defp parse_string("'" <> rest) do
|
||||
rest
|
||||
|> String.trim_trailing("'")
|
||||
|> String.replace("\\n", "\n")
|
||||
|> String.replace("\\'", "'")
|
||||
|> String.replace("\\\\", "\\")
|
||||
parse_quoted_string(rest, ?')
|
||||
end
|
||||
|
||||
defp parse_string(value), do: value
|
||||
@@ -235,4 +229,46 @@ defmodule BDS.Frontmatter do
|
||||
rendered = to_string(key)
|
||||
String.ends_with?(rendered, "_at") or String.ends_with?(rendered, "At")
|
||||
end
|
||||
|
||||
defp normalize_newlines(contents) do
|
||||
contents
|
||||
|> String.replace("\r\n", "\n")
|
||||
|> String.replace("\r", "\n")
|
||||
end
|
||||
|
||||
defp parse_quoted_string(rest, quote) do
|
||||
quote_binary = <<quote::utf8>>
|
||||
|
||||
if String.ends_with?(rest, quote_binary) do
|
||||
inner = binary_part(rest, 0, byte_size(rest) - byte_size(quote_binary))
|
||||
unescape_quoted_string(inner, quote, "")
|
||||
else
|
||||
quote_binary <> rest
|
||||
end
|
||||
end
|
||||
|
||||
defp unescape_quoted_string(<<>>, _quote, acc), do: acc
|
||||
|
||||
defp unescape_quoted_string("\\" <> rest, quote, acc) do
|
||||
case rest do
|
||||
<<"n", tail::binary>> ->
|
||||
unescape_quoted_string(tail, quote, acc <> "\n")
|
||||
|
||||
<<"\\", tail::binary>> ->
|
||||
unescape_quoted_string(tail, quote, acc <> "\\")
|
||||
|
||||
<<escaped, tail::binary>> when escaped == quote ->
|
||||
unescape_quoted_string(tail, quote, acc <> <<quote::utf8>>)
|
||||
|
||||
<<char::utf8, tail::binary>> ->
|
||||
unescape_quoted_string(tail, quote, acc <> "\\" <> <<char::utf8>>)
|
||||
|
||||
<<>> ->
|
||||
acc <> "\\"
|
||||
end
|
||||
end
|
||||
|
||||
defp unescape_quoted_string(<<char::utf8, tail::binary>>, quote, acc) do
|
||||
unescape_quoted_string(tail, quote, acc <> <<char::utf8>>)
|
||||
end
|
||||
end
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user