Compare commits

...

297 Commits

Author SHA1 Message Date
655069a28a fix: fix issue #23 broken css for link insert 2026-07-18 10:33:15 +02:00
593f1646a1 fix: made the macos app smaller 2026-07-18 10:20:54 +02:00
c77450d497 fix: broken CSS in the bottom panel fixed 2026-07-18 09:05:31 +02:00
23f05b72f7 fix: issue #35 validation expected unrendered .lang translation variant routes; main-language lists now render main-language translations 2026-07-17 22:12:23 +02:00
dd53ca3fbc fix: issue #20 cmd-J panel tab strip visibility and output panel parity 2026-07-17 20:51:42 +02:00
6c9dd9605b fix: updated current line indicator for issue #32 2026-07-17 16:43:57 +02:00
46c239df56 feat: issue #32 markdown editor for TUI 2026-07-17 16:38:46 +02:00
2fd132e827 feat: TUI tags panels (cloud, manage, merge) wired to the shared tags backend (issue #34) 2026-07-17 14:50:25 +02:00
0f3f1efa08 feat: issue #25 implemented - cli 2026-07-17 14:26:14 +02:00
e4fa61ae07 fix: fix for building 2026-07-17 12:04:07 +02:00
8fe18d108f fix: bundle @rpath dylib deps (libsharpyuv) and fail verify_standalone on unresolvable @rpath references 2026-07-17 10:58:51 +02:00
b517672663 fix: insert link/media lands at the cursor and closes the overlay instead of breaking the editor 2026-07-17 10:58:51 +02:00
b016f6d812 fix: update_project_metadata keeps fields absent from attrs instead of resetting them 2026-07-17 08:57:20 +02:00
505527ae4f feat: TUI settings panel on key 6 with per-section preference editors backed by the same GUI settings backends (issue #29) 2026-07-17 08:38:26 +02:00
b6b1d16e54 feat: posts open in the rendered markdown preview by default, ctrl+e toggles to the editor 2026-07-16 22:22:17 +02:00
46fd6c1b85 feat: git sidebar shows the commit history in its lower half like the GUI (issue #30) 2026-07-16 22:10:27 +02:00
5433cb59ac feat: TUI git panel on key 7 with change list, scrollable diff, commit, pull and push (issue #30) 2026-07-16 21:59:59 +02:00
e027364c0e feat: vi-style / search in the TUI sidebar with tag:, category: and ISO-date filters (issue #31) 2026-07-16 21:42:19 +02:00
3675a26407 fix: local tui mode routes console logging to a file and disables download progress bars so stray output cannot scroll the TUI 2026-07-16 18:39:07 +02:00
9a1f301527 feat: bash-style tab completion in the TUI project path prompt (issue #28) 2026-07-16 18:27:09 +02:00
59333ac920 fix: set NO_WX before dependency boot and detect headless sessions per platform so the TUI fallback actually works (issue #33) 2026-07-16 18:13:15 +02:00
381deb417d feat: desktop mode falls back to the TUI when no graphical display is available (issue #33) 2026-07-16 17:46:36 +02:00
5801e49dc1 feat: TUI projects overlay to switch projects and open existing blog folders with automatic database rebuild (issue #28) 2026-07-16 17:26:19 +02:00
a8ca3b643b fix: empty command-palette filter no longer crashes the TUI render (issue #26) 2026-07-15 20:20:30 +02:00
39bb232b57 feat: TUI report panels with whole-report apply for metadata diff and site validation (issue #26) 2026-07-15 20:12:45 +02:00
824313f106 feat: vi-style command prompt in the TUI for menu shell commands (issue #26) 2026-07-15 19:54:30 +02:00
01b90e00a0 feat: word-wrapped markdown preview toggle in the TUI editor (issue #26) 2026-07-15 19:28:10 +02:00
c77686250e fix: ctrl-q now properly exits 2026-07-15 19:17:21 +02:00
48e0903542 docs: server setup, TUI guide, and API doc sync for headless mode (issue #26, phase 6) 2026-07-15 18:45:39 +02:00
df2bdc2041 feat: desktop connect-to-server over SSH tunnel (issue #26, phase 5) 2026-07-15 18:39:04 +02:00
a3ca44640c feat: terminal UI over the shared workbench core (issue #26, phase 4) 2026-07-14 20:33:30 +02:00
3a8fafd2fd refactor: move pure post-editor logic to BDS.UI.PostEditor for TUI reuse (issue #26, phase 3) 2026-07-14 19:41:43 +02:00
16f45d77b2 feat: domain event bus for multi-client sync (issue #26, phase 2) 2026-07-14 19:33:07 +02:00
700ed79c43 feat: headless server mode with ExRatatui SSH daemon (issue #26, phase 1) 2026-07-14 19:17:06 +02:00
6ec61610f1 fix: issue #22 blogmarks not creating drafts 2026-07-13 10:45:22 +02:00
89859684eb fix: issue #19 opening post editor on blogmark 2026-07-08 18:58:06 +02:00
dc35518bad fix: ai preferences crashed and deleted stuff in preferences 2026-07-07 22:43:05 +02:00
4a03fc0ba2 fix: added homebrew to path for git 2026-07-06 21:06:37 +02:00
10fa174388 fix: issue #18 more visible git activities 2026-07-06 15:52:28 +02:00
8ee51c6626 fix: issue #8 title of upload task changed 2026-07-06 11:01:19 +02:00
d31ceb72cb fix: tests for removed assist cards 2026-07-06 10:55:41 +02:00
c8d2c73a72 fix: issue #1 button styling 2026-07-06 09:45:43 +02:00
a772e3abea fix: issue #5 now using full size images in editor 2026-07-06 08:36:49 +02:00
c735c1afa7 fix: issue #2 auto-translate only on manual save now 2026-07-05 19:22:44 +02:00
d85654d4b2 fix: issue #10 incomplete upload 2026-07-05 18:38:07 +02:00
ae2e534fdb fix: issue #9 moved windows menu 2026-07-05 18:13:07 +02:00
ae1c8703e7 fix: issue #16 broken diff visualisation 2026-07-05 17:40:46 +02:00
d944c07838 fix: issue #15 calendar index not properly created, also some smaller bugs 2026-07-05 17:33:48 +02:00
bb82f1d4a1 fix: issue #14 wrong sorting in category and tag archives 2026-07-05 16:09:33 +02:00
7dfcd78abd fix: issue #7 rebuild media links to get gallery working again 2026-07-05 14:44:47 +02:00
8e60fe31a5 fix: issue #7 with broken macro renderings for photo archive and tag cloud 2026-07-05 14:11:12 +02:00
28398b17f4 fix: issue #11 wrong page titles compared to old app 2026-07-05 12:58:44 +02:00
cf1d4bd46f fix: issue #6 had missing title suppression 2026-07-05 12:39:59 +02:00
7fa6e6232c fix: issue #6 post bodies missing for asides 2026-07-05 12:24:30 +02:00
fcc574428c fix: issue #3 slow rendering 2026-07-05 10:26:21 +02:00
1917043c10 fix: allow greater concurrency 2026-07-02 21:59:36 +02:00
4b52ac6156 fix: better progress tracking for full site render 2026-07-02 21:52:20 +02:00
1487c0337e fix: incremental rendering more on par with bDS 2026-07-02 18:31:17 +02:00
43f04dcd86 fix: finally fix the cursor key bug in monaco 2026-07-02 14:52:24 +02:00
c07ba9a4d8 fix: workover of the monaco update 2026-07-02 14:14:31 +02:00
ef6c970654 chore: updated monaco and tools to update monaco 2026-07-02 12:50:03 +02:00
e7d5cba01b chore: mark downloaded JS files as such for gitea 2026-07-02 10:27:57 +02:00
82c69a2c35 fix: close language picker on translation quick action 2026-06-30 22:33:05 +02:00
b11be9b521 fix: quick action translate and validate apply now use tasks 2026-06-30 22:28:05 +02:00
1b0bae37bf fix: update sidebar on post changes (status like publishing) 2026-06-30 22:02:06 +02:00
c2b2b1ff11 fix: fixed tag suggestions 2026-06-30 21:48:15 +02:00
5f77641f3f fix: monaco editor glitches fixed 2026-06-30 21:24:08 +02:00
ba9634c478 fix: fixed bds2: protocol handling 2026-06-30 21:07:26 +02:00
49675a49d2 chore: removed old TODO 2026-06-26 16:34:25 +02:00
d1acfd4d71 fix: reworking some code smell issues 2026-06-26 16:34:07 +02:00
68de265137 chore: removed old todo 2026-06-25 15:51:22 +02:00
57bdcef990 fix: more refactorings for cleaning code 2026-06-25 15:50:08 +02:00
52809b39b1 fix: part of refactoring 2026-06-25 15:42:42 +02:00
e3e94ac2d9 fix: refactorings to remove anti-patterns 2026-06-25 15:42:32 +02:00
d78cb5c07b fix: mac bundle now proper arm64 2026-06-22 15:02:08 +02:00
211271efca fix: fixed outdated assets in bundle task 2026-06-22 14:06:43 +02:00
c2cecd9383 fix: app bundling correcrted 2026-06-22 13:44:55 +02:00
d0e1ac5559 fix: fixed bundle building with referenced libraries 2026-06-22 12:23:03 +02:00
cea47f10e2 feat: ccloseout of changes for audit 2026-06-22 11:17:03 +02:00
e1abe457e8 feat: close out of AUDIT implementation 2026-06-22 11:16:47 +02:00
562eed2b98 Replace abandoned earmark with mdex for markdown rendering 2026-06-21 16:07:44 +02:00
7c38061c1c Bump earmark and serialize test suite to clear validation gate 2026-06-21 15:46:16 +02:00
67a1f8a5f0 Replace exact blank_to_nil helpers 2026-06-21 14:31:29 +02:00
d3c21247e0 Replace local maybe_put helpers 2026-06-21 14:30:40 +02:00
cd4a01a2c8 Record wrapper audit verdicts 2026-06-21 14:28:58 +02:00
b93b650689 Inline desktop external links 2026-06-21 14:28:31 +02:00
e284f030ae Fold scripting runtime into Lua 2026-06-21 14:27:15 +02:00
4c9c432d33 Delete empty code entity editor 2026-06-21 14:26:20 +02:00
64ca5d4637 Use Bandit child spec directly 2026-06-21 14:26:04 +02:00
a89175e230 Use Agent for job store 2026-06-21 14:25:32 +02:00
26f06f3298 Optimize bounded atom lookups 2026-06-21 14:25:00 +02:00
6a7a8c4288 Replace local attr helpers 2026-06-21 14:24:05 +02:00
62f87b8ab4 Record automation audit verdict 2026-06-21 14:22:40 +02:00
ff777e590f Dispatch sidebar filter events 2026-06-21 14:22:20 +02:00
7f1dbc390c Merge scripting callback wrappers 2026-06-21 14:21:15 +02:00
59c236d871 Simplify secret backend fallback 2026-06-21 14:20:08 +02:00
c3a8a8a755 Simplify secret backend fallback 2026-06-21 14:15:55 +02:00
2e61059568 Simplify secret key fallbacks 2026-06-21 14:10:00 +02:00
936a20a03e Deduplicate one-shot request flow 2026-06-21 14:06:50 +02:00
0da149e105 Merge AI runtime request paths 2026-06-21 14:03:41 +02:00
dc6fc4c631 Simplify chat tool round flow 2026-06-21 13:54:35 +02:00
d3e2397217 Deduplicate chat tool query patterns 2026-06-21 13:51:44 +02:00
3b14c1ecf3 Simplify MCP HTTP responses 2026-06-21 13:24:47 +02:00
bb58f98afd Deduplicate MCP tool descriptors 2026-06-21 13:22:12 +02:00
66c24b565f Unify generation render fallbacks 2026-06-21 13:17:44 +02:00
ccfaa4afe6 Deduplicate generation path prefixes 2026-06-21 13:15:02 +02:00
f46239188a Extract generation snapshot loaders 2026-06-21 13:12:17 +02:00
4a8a420c62 Deduplicate sitemap URL entry builders 2026-06-21 13:09:35 +02:00
b3d69f0291 Consolidate generation route builders 2026-06-21 13:06:50 +02:00
f8f6a242d6 Deduplicate validation timestamp checks 2026-06-21 13:04:13 +02:00
341c1bf8ee Simplify rendering path builders 2026-06-21 13:00:51 +02:00
4f6f7a6e1e Merge metadata menu item builders 2026-06-21 12:58:27 +02:00
72f6cc7d97 Simplify template selection queries 2026-06-21 12:55:57 +02:00
f52e33760e Simplify list archive fallbacks 2026-06-21 12:53:41 +02:00
e87c35e256 Simplify post rendering fallbacks 2026-06-21 12:51:25 +02:00
2d98780007 Deduplicate macro rendering paths 2026-06-21 12:45:23 +02:00
26c481ea1f Record liquid parser audit verdict 2026-06-21 12:42:38 +02:00
65d89a198e Refactor localized month labels 2026-06-21 12:42:23 +02:00
8f073bbebd chore: removed old todo document 2026-06-12 15:26:45 +02:00
4dd4781c5a Rewrite OPML menu parsing on Saxy to stop xmerl atom interning 2026-06-12 15:25:45 +02:00
a00e4b85ac Stabilize preview and sandbox cleanup 2026-06-12 14:40:35 +02:00
caaec98225 Close TD-24 AGENTS test command 2026-06-12 14:12:43 +02:00
4b1557cf6a Close TD-23 silent rescue sweep 2026-06-12 14:12:25 +02:00
985d8b53c2 Close TD-22 chat delete transaction 2026-06-12 14:09:59 +02:00
941db4c6f4 Close TD-21 atomic write hardening 2026-06-12 14:08:42 +02:00
a73af6b44d Close TD-20 sqlite pool alignment 2026-06-12 14:07:28 +02:00
e2054c9c12 Close TD-19 quality gates 2026-06-12 14:07:13 +02:00
28e08451e4 Close TD-18 Oban Lite evaluation 2026-06-12 13:54:59 +02:00
abbcef594a Close TD-17 language detection coverage 2026-06-12 13:31:00 +02:00
8224b3d59f Close TD-16 frontmatter robustness 2026-06-12 13:27:39 +02:00
f7e1662bca Close TD-15 task housekeeping 2026-06-12 13:20:57 +02:00
ae66775cb7 Close TD-14 replace polling with messaging 2026-06-12 13:17:01 +02:00
741979fc39 Close TD-13 publishing GenServer call surface 2026-06-12 13:13:00 +02:00
4859c9708a Close TD-12 non-blocking embeddings index work 2026-06-12 12:47:01 +02:00
cd72998a13 Move preview rendering out of the BDS.Preview GenServer 2026-06-12 12:39:16 +02:00
f088cfb77b Close TD-10 git command timeouts 2026-06-12 12:31:23 +02:00
bad656924b fix: implemented TD-09, supervised workers now receive shutdowna nd can run cooperative cleanup 2026-06-12 12:19:25 +02:00
8ee2b9a7f7 fix: unit test for TD-08 2026-06-12 12:14:43 +02:00
8d245b3492 fix: implement TD-08, remove test sandbox scaffolding from production code 2026-06-12 12:14:30 +02:00
66938c23f2 fix: implemented TD-07, chat await path with deadline 2026-06-12 12:08:27 +02:00
2e633922f9 fix: finalized TD-05 implementation 2026-06-12 11:54:46 +02:00
e3a1010ae9 fix: implement TD-05, replacement of XML parser 2026-06-12 11:48:44 +02:00
eac6d543d2 fix: tests for ai json parser 2026-06-11 22:29:04 +02:00
8546080a3d fix: fix airplane mode for AI usage and qwen 3.6 one-shot parsing 2026-06-11 22:28:44 +02:00
d8b24c9b72 fix: implemented TD-04, embedding indexes flush to disk on shutdown 2026-06-11 21:34:26 +02:00
63e35d19e3 fix: implemented TD-03, InFlight ETS table now owned by a supervised GenServer 2026-06-11 16:59:17 +02:00
9325de2db4 fix: implemented TD-06 real SSE implementation 2026-06-11 16:37:08 +02:00
a5391e8e25 fix: implement TD-02. 2026-06-11 16:18:09 +02:00
284637970f fix: fix flaky test 2026-06-11 16:05:03 +02:00
21b11ef87e fix: fixed TD-01 and TD-25 2026-06-11 12:13:14 +02:00
e6a2055e18 chore: added technical debts document to work on 2026-06-11 10:49:05 +02:00
Hermes Agent
bf9340352c chore: added hint for xvfb-run mix test 2026-06-01 16:43:26 +00:00
db98944d10 fix: added css rules for git sidebar 2026-05-31 17:43:12 +02:00
5e99cb7a09 fix: fixed behaviour of bundled app for decrypt and ai chat 2026-05-31 17:26:50 +02:00
040b5db37b fix: a2ui is much closer to bDS parity than before 2026-05-31 14:32:15 +02:00
a33131ddea A2UI parity: restore chart-type guidance in system prompt, add detailed schemas for form fields, card actions, tab content, and mindmap nodes 2026-05-31 14:05:13 +02:00
ef6f8a54b2 chore: remove done todo 2026-05-30 22:20:42 +02:00
70d2342274 fix: pretty-print json on serialisation to filesystem 2026-05-30 21:58:31 +02:00
c1b7ceae6c fix: proper mac bundling with code signing 2026-05-30 21:47:03 +02:00
1d17b6e884 feat: pipeline to create a full mac app 2026-05-30 21:21:07 +02:00
360a8d971a D4-7: add UI tests for translation validation, find duplicates, git diff, menu toolbar, import analysis 2026-05-30 20:41:06 +02:00
c30757b3b7 D4-6: tag editor UI tests + delete confirmation overlay 2026-05-30 20:30:39 +02:00
f6e1b679f0 Spec gap D4-5: add TemplateEditor LiveComponent tests for save/validate/delete events 2026-05-30 20:20:03 +02:00
63d6c9f215 D4-4: add script editor UI tests for save, run, check syntax, delete 2026-05-30 20:10:13 +02:00
2ba8be2fc6 Resolve D4-3: add WelcomeScreen/CSP/chart-surface tests for editor_chat.allium 2026-05-30 20:02:36 +02:00
4731bc0cd2 D4-2: add 56 UI tests for editor_settings (MCP agents, style/theme, search filter, categories CRUD) 2026-05-30 19:52:23 +02:00
8bc371eb3f Fix D4-1: add standalone delete_media_translation tests and MediaDetectLanguage rule integration test 2026-05-30 19:40:49 +02:00
b65c2be29b D3: close out partial test coverage gaps with new tests + execute_macro degrade-to-empty fix 2026-05-30 19:33:52 +02:00
ee4d0dd33f D2-10/D2-12/D2-15/D2-16: close out remaining D2 spec gaps with tests + validate_media implementation 2026-05-30 19:25:20 +02:00
8c71ece887 D2-9: add max_posts_per_page 1..500 constraint tests 2026-05-30 19:17:09 +02:00
b1438d5222 Fix D2-8: add width/height assertions to ConditionalMediaFields nil-fields-absent test 2026-05-30 19:15:50 +02:00
87f2f22241 fill D2-7: test nil excerpt/author/language absent from frontmatter 2026-05-30 19:14:06 +02:00
60acda3fee fix: add SidecarRoundtrip tests for D2-6 2026-05-30 19:10:40 +02:00
ab6a03dc54 D2-5: add FrontmatterRoundtrip test 2026-05-30 19:08:09 +02:00
0afb017e43 Implement create_and_publish_script/1 and add tests for D2-3/D2-4 spec gaps 2026-05-30 19:02:20 +02:00
cf553e2f78 Add create_and_publish_template/1 (D2-2 spec gap) 2026-05-30 14:51:44 +02:00
cb658aba1a clean up unused alias warning in CSM013 test 2026-05-30 14:47:33 +02:00
544ff65e3b D2-1: add RemoveCategory tests covering state/files/DB cleanup and no-op for non-existent 2026-05-30 14:44:20 +02:00
08eb9e4ea1 B2-1..B2-9: distill minor code behaviors into specs (post/project/template/media_processing/generation/dashboard) 2026-05-30 14:39:11 +02:00
723a7ec1f7 B1-5..B1-20: distill remaining code behaviors into specs (rendering.allium, post/media/task/generation/editor specs) 2026-05-30 14:33:19 +02:00
dfb2f8870b chore: some stupid whitelistings for claude code dumbness 2026-05-30 14:21:55 +02:00
f0919f24a5 B1-4: distill Style as its own :style singleton tab in editor_settings spec 2026-05-30 14:21:28 +02:00
72f2c829ca B1-3: distill Technology, MCP, and Data Maintenance settings sections into editor_settings spec 2026-05-30 14:17:59 +02:00
7c7f629dd2 B1-2: distill auto-translation system into translation.allium spec 2026-05-30 14:14:02 +02:00
dd760d0f2b B1-1: distill 9 inline surface types into editor_chat.allium spec 2026-05-30 13:39:57 +02:00
fb794ae833 Fix C-1: add cache_read_tokens/cache_write_tokens to schema.allium ChatMessage 2026-05-30 13:35:06 +02:00
df0ae6a41b D1-18: HomeItemProtection — Home menu item cannot be moved, reordered, or deleted 2026-05-30 13:33:40 +02:00
e515cfacc6 D1-17: add tests for protected categories deletion rejection (article/aside/page/picture) 2026-05-30 13:25:42 +02:00
7e9cc72e1f fix: D1-16 cancel orphaned debounce timer so index saves coalesce; add tests 2026-05-30 09:48:32 +02:00
257a06e5d1 feat: D1-15 implement drag-and-drop image chain (import+thumbnails+link+insert) with tests 2026-05-30 09:34:41 +02:00
1b37f1fcec test: D1-14 cover ReplaceMediaFileSideEffects file replace + sync thumbnail regen 2026-05-30 09:18:36 +02:00
56caa653bb test: D1-13 cover DiscardPostChangesSideEffects FTS re-sync after discard 2026-05-30 09:15:15 +02:00
925fe97007 test: D1-12 enforce BoundedToolLoop via config.chat_max_tool_rounds 2026-05-30 09:13:03 +02:00
d688c61b0e test: D1-11 cover ChatContextTruncation invariant in chat requests 2026-05-30 09:08:51 +02:00
8db7bcf357 test: D1-10 cover TransformPipelineContinuation with first-transform failure 2026-05-30 09:03:09 +02:00
2bed225133 style: fix pre-existing formatting drift across codebase 2026-05-30 09:00:29 +02:00
7045b10738 fix: A1-17 route bds2://new-post deep links into transform pipeline and draft creation 2026-05-30 08:58:22 +02:00
ebf6136d2f fix: blogmark bookmarklet uses bds2:// scheme to avoid legacy bds:// clash 2026-05-29 22:46:26 +02:00
ae6659bcf3 docs: track A1-17 unimplemented blogmark deep-link handler in SPECGAPS 2026-05-29 22:43:30 +02:00
8bfc509472 fix: D1-9 implement ExecuteTransform pipeline with ordering and toast budget 2026-05-29 22:41:34 +02:00
e89a061d8f test: D1-8 enforce MacroTimeout, macro times out within wall-clock budget 2026-05-29 22:31:55 +02:00
d606d9b26b test: D1-7 enforce LiquidOperatorSubset, reject unsupported comparison operators at publish 2026-05-29 22:25:06 +02:00
a9740207cc fix: D1-6 enforce LiquidFilterSubset, reject unsupported filters at publish 2026-05-29 22:21:47 +02:00
535ab81082 test: D1-5 enforce LiquidTagSubset via restricted parser, reject unsupported tags 2026-05-29 22:13:01 +02:00
0ce90e96e5 test: D1-4 cover UserTemplateDirectoryOverridesBundledDefaults 2026-05-29 22:04:32 +02:00
8cb6d238b9 test: D1-3 cover BundledDefaultTemplatesExistOutsideProjectData with no Template rows 2026-05-29 22:02:19 +02:00
cf8b0af15f fix: A1-16 keep public project content out of repo via per-user content location and machine-local project registry 2026-05-29 21:58:46 +02:00
9d5764b251 fix: added things around project folder pollution from program runs 2026-05-29 21:45:15 +02:00
3a77761f96 fix: D1-2 correct post translation unique_constraint name so duplicate-language violations return a changeset error 2026-05-29 21:15:40 +02:00
aff4b63188 chore: added some command allowances 2026-05-29 21:14:33 +02:00
91b0ffe4c5 fix: D1-1 correct media translation unique_constraint name so duplicate-language violations return a changeset error 2026-05-29 21:13:18 +02:00
84b91750fb fix: A1-14c run embedding model on Apple GPU via EMLX with EXLA-CPU fallback 2026-05-29 16:26:33 +02:00
d03d033548 fix: fixed shutdown race 2026-05-29 16:16:33 +02:00
74ceaeb971 fix: force full re-embed on explicit rebuild and degrade gracefully when embedding model is unavailable 2026-05-29 15:49:53 +02:00
61ff2a77c0 perf: A1-14b replace O(n^2) embedding snapshot with hnswlib HNSW index and debounced persistence 2026-05-29 15:36:13 +02:00
744f7543d7 perf: batch CPU embedding inference and add A1-14c Apple GPU (EMLX) spec gap 2026-05-29 14:43:39 +02:00
a1004d72bf fix: A1-14 real neural embeddings via Bumblebee multilingual-e5-small with Float32 BLOB vector cache 2026-05-29 14:04:51 +02:00
489d787306 fix: A1-13 wire git sidebar to BDS.Git with branch, changes, history, and actions 2026-05-29 13:25:32 +02:00
babae1838d fix: A1-12 functional client-side search with real PagefindUI and fragment index 2026-05-29 10:29:42 +02:00
5b619f492a fix: A1-11 graceful preview shutdown drains inflight requests before stopping 2026-05-29 09:49:54 +02:00
b3434b3054 fix: A1-10 write template file to disk on create instead of leaving file_path empty 2026-05-29 09:43:18 +02:00
5b21dcb17d fix: A1-9 replace native color input with 17-preset colour picker popover + custom hex 2026-05-29 09:28:57 +02:00
1f645f6e5e fix: stabilize atom-leak test by checking specific keys instead of global atom count 2026-05-29 09:19:13 +02:00
99d36e6e2f fix: A1-8 add Liquid/Lua validation gates before template and script publish 2026-05-29 09:16:07 +02:00
d7e30b94cb chore: cleanup of tmp files for test 2026-05-28 22:48:48 +02:00
f1265ee326 fix: broken CSS for metadata diff 2026-05-28 22:41:57 +02:00
c5e09e7316 fix: A1-7 implement 4-level template lookup cascade (post→tag→category→default) 2026-05-28 22:38:35 +02:00
1ae6152da7 fix: A1-15 add PreviewDraftOverlay and GenerationPublishedOnly invariants to specs 2026-05-28 22:27:08 +02:00
0305d80051 fix: A1-6 preview prefers draft content over published files for on-demand rendering 2026-05-28 22:24:07 +02:00
a021fc45cd chore: ignore tmp/ folder 2026-05-28 22:21:47 +02:00
fceb995c7c chore: update allium spec for clearer wording towards embedding model 2026-05-28 22:21:36 +02:00
e58d68e73e fix: A1-6 implement on-demand rendering in preview server per spec 2026-05-28 22:20:39 +02:00
0f30221907 fix: A1-5 implement post editor auto-save after 3000ms idle, on tab switch, and on unmount 2026-05-28 21:45:42 +02:00
d423b6db98 chore: claude md and gitignore for tmp 2026-05-28 21:41:37 +02:00
3adb4407a0 chore: removed tmp/ 2026-05-28 21:41:21 +02:00
05923f255b fix: A1-4 omit doNotTranslate from frontmatter when false per spec 2026-05-28 21:36:10 +02:00
ff89d78ab4 fix: A1-3 publish_post deletes old file when post path changes 2026-05-28 21:15:58 +02:00
e2c92cb90d fix: A1-2 delete translation files from disk when parent post is deleted 2026-05-28 18:47:27 +02:00
82ce445c44 fix: A1-1 implement archived→draft/published transitions, wire archive/unarchive into post editor quick actions, complete all i18n translations 2026-05-28 18:39:52 +02:00
f99e139fa5 feat: added a gallery quick action and fleshed out builtin macros 2026-05-28 17:19:49 +02:00
1914b05f39 chore: tend to allium spec to align with code 2026-05-28 13:36:55 +02:00
b09b14cc03 fix: fixes to rendering in the AI chat 2026-05-28 11:21:03 +02:00
721b1ae626 fix: styling for a2ui surfaces was missing 2026-05-28 11:03:22 +02:00
f7a4a9512c fix: persist a2ui surfaces in the database for chats to re-hydrate on
opening an old chat, unless manually dismissed
2026-05-27 20:13:33 +02:00
141c2bfc89 removed fixed codesmell document 2026-05-27 19:20:43 +02:00
a5ac74db91 fix(style): add missing @impl true to all handle_call clauses in Publishing GenServer (CSM-036) 2026-05-27 19:19:50 +02:00
beca4d992f fix(docs): document UILocale process-dict invariant and add enforcement tests (CSM-035) 2026-05-27 19:16:42 +02:00
9e6d93a4b3 fix(safety): replace File.read! with File.read and error-tuple handling in preview_assets and templates (CSM-034) 2026-05-27 19:10:13 +02:00
e29dfb490a fix(perf): replace Enum.each + individual inserts with preloaded keys and batch upsert in embeddings (CSM-033) 2026-05-27 19:03:21 +02:00
f2b340ba86 fix(style): replace Map.get with dot access and pattern matching where keys are guaranteed (CSM-032) 2026-05-27 18:33:42 +02:00
d18e0ef7f2 fix(rendering): replace inline try/rescue with with-chains and safe_liquex_render helpers (CSM-031) 2026-05-27 18:12:23 +02:00
2d796cee83 updated specgaps for A3-1 2026-05-27 17:56:19 +02:00
b052d59376 fix(fs): handle File.mkdir_p errors and remove bang variants in sidecars and release packaging (CSM-030) 2026-05-11 20:25:06 +02:00
4a089b0856 fix(perf): bind length/1 to variables before loop bodies in route paths and sidebar (CSM-029) 2026-05-11 20:18:56 +02:00
2632649cdc fix(rendering): replace raising read_template_file with try_read in macro templates (CSM-028) 2026-05-11 20:09:49 +02:00
782511d523 fix(templates): replace equality check with pattern matching in rewrite_template_file (CSM-027) 2026-05-11 20:06:15 +02:00
1cb59d7a78 chore: update the spec gaps with decisionm points 2026-05-11 12:19:16 +02:00
9844f3555a chore: analyse specs against code 2026-05-11 11:56:34 +02:00
99dc1c2216 chore: remove redundant export-only tests, add test audit procedure
Deleted chat_editor_test.exs and import_editor_test.exs which only
checked function_exported?/Code.ensure_loaded? without exercising any
behavior — both components are already tested via LiveView rendering
in shell_live_test.exs and import_shell_live_test.exs respectively.

Added TESTAUDIT.md documenting the procedure for periodic test suite
audits to catch non-behavioral tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 10:35:24 +02:00
71fb99af16 chore: unit tests adaption to idea of gate test 2026-05-11 10:21:00 +02:00
0808b27057 chore: moved from delay-based tests to deterministic gated server tests
for chat
2026-05-11 10:20:46 +02:00
ac4f5a3580 fix: eliminate TOCTOU race in template file system by reading directly instead of checking existence (CSM-026) 2026-05-11 09:26:54 +02:00
43a435f35d fix: derive pagefind language prefixes from project settings (CSM-025)
Replace hardcoded ["de/", "fr/", "it/", "es/"] with prefixes computed
from plan.blog_languages, so arbitrary language codes work correctly.
Also mark CSM-024 as fixed (done in CSM-005).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 09:19:31 +02:00
7b383d31ab fix: decompose monolithic functions into focused helpers (CSM-023)
Break up SRP violations in Templates.do_update_template/2 and
Capabilities.for_project/2 by extracting domain-specific builder
functions and single-responsibility private helpers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 09:15:48 +02:00
09df925e9b chore: allow git push 2026-05-11 09:10:08 +02:00
a4ecbabc21 fix: return error tuples instead of silent {:ok, ""} in execute_macro (CSM-022)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 09:07:44 +02:00
2be43ca06d fix: replace cond blocks with pattern matching and case (CSM-021)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 09:04:56 +02:00
d231f42363 chore: allowed git add and ecto migrate 2026-05-10 12:37:24 +02:00
7c00279b9d fix: flatten nested case blocks with with chains (CSM-020)
Replace deeply nested case expressions with flat with chains in
import_definitions, publishing, and templates modules. Also replaced
Repo.update!() with Repo.update() in the publishing update_job handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 12:36:42 +02:00
b6f9cf58e1 fix: add @spec to all public functions across 24 modules (CSM-019)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 11:40:42 +02:00
3f77488e33 fix: add @moduledoc to all public modules (CSM-018)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-10 11:30:08 +02:00
5c17751d55 fix: fixed CSM-017 2026-05-09 17:33:51 +02:00
e4452ca504 chore: unit tests for CSM-016 2026-05-09 17:18:43 +02:00
ce80f28e60 fix: fix CSM-016 for real (previous commit was 015) 2026-05-09 17:18:32 +02:00
f1de11a205 fix: fixed CSM-016 2026-05-09 16:57:59 +02:00
ff219fd110 chore: unit test for csm-014 2026-05-09 16:38:18 +02:00
de7ea12c9c fix: fixed CSM-014 2026-05-09 16:38:00 +02:00
1beffe6b07 chore: unit test for csm-013 2026-05-09 15:10:24 +02:00
999632dbe7 fix: fixed CSM-013 2026-05-09 15:10:04 +02:00
44b88056e3 fix: fix CSM-012 2026-05-09 15:04:10 +02:00
35b3818d58 chore: unit test for CSM-011 2026-05-09 14:52:48 +02:00
37db52c024 fix: fixed CSM-011 2026-05-09 14:52:29 +02:00
f1445120fc fix: fix CSM-010 2026-05-09 14:31:44 +02:00
14dfbd8829 fix: fixed CSM-009 2026-05-09 14:22:56 +02:00
24e9e9a022 fix: fixed CSM-008 2026-05-09 14:17:32 +02:00
88c689ee55 fix: fixed CSM-007 2026-05-09 10:41:28 +02:00
e5429f7265 fix: implement CSM-006 2026-05-08 20:39:50 +02:00
93a4159c31 fix: fix CSM-006 2026-05-08 20:26:08 +02:00
06d80e2924 fix: tests for CSM-005 2026-05-08 20:09:18 +02:00
291dff697c fix: fix CSM-005 2026-05-08 20:09:02 +02:00
9944b70ab1 fix: fixed CSM-004 2026-05-08 19:52:59 +02:00
723b8c6433 fix: worked on CSM-003 2026-05-07 21:49:59 +02:00
92334256cf fix: fix CSM-002 2026-05-07 16:52:53 +02:00
d3f45ba0dd fix: CSM-001 done 2026-05-06 19:33:54 +02:00
3ce6010b87 updated codesmell file with new analysis 2026-05-06 18:48:00 +02:00
f704aba288 chore: added a new code smell todo 2026-05-05 20:44:58 +02:00
5282fcd241 chore: update docs 2026-05-04 13:11:23 +02:00
602 changed files with 64378 additions and 32846 deletions

11
.claude/launch.json Normal file
View File

@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "phoenix",
"runtimeExecutable": "mix",
"runtimeArgs": ["phx.server"],
"port": 4000
}
]
}

View File

@@ -0,0 +1,2 @@
- [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

View File

@@ -0,0 +1,13 @@
---
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 must be fixed, even if they appear unrelated to current changes. The test suite was clean before, so any failure is my responsibility.
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.
**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.

View File

@@ -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.

View File

@@ -0,0 +1,31 @@
{
"permissions": {
"allow": [
"Bash(mix compile *)",
"Bash(mix test *)",
"Bash(mix dialyzer *)",
"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
View 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}
]
}
]
}

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Downloaded JS — exclude from language detection
priv/**/*.js linguist-vendored=true

9
.gitignore vendored
View File

@@ -3,10 +3,17 @@
/deps/ /deps/
/dist/ /dist/
/doc/ /doc/
/tmp/
/.elixir_ls/ /.elixir_ls/
/erl_crash.dump /erl_crash.dump
/node_modules/ /node_modules/
/priv/data/*.db /priv/data/*.db
/priv/data/*.db-shm /priv/data/*.db-shm
/priv/data/*.db-wal /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
View 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

View File

@@ -28,7 +28,9 @@ 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. - 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. - 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 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
- CSS precedence: Tailwind utilities and the ui-* kit (utilities.css) are in @layer and ALWAYS lose to the unlayered hand-written files (shell.css, panel.css, ...). Convention: hand-written CSS owns appearance, Tailwind utilities in templates own layout — never put display/flex-direction/size rules in hand-written CSS for elements whose layout the template controls, and define each selector in exactly one file.
--- ---
@@ -54,7 +56,16 @@ This document provides context and best practices for GitHub Copilot when workin
- Never leave tests failing, even if they appear unrelated to your changes - 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 - 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 - **Always write the full test run to a log file first, then grep that file for failures.** Never run `mix test` in the terminal and try to grep live output — the output is interleaved, truncated, and nearly impossible to parse. Pattern:
```
mix test 2>&1 > /tmp/test_run.log
grep -E "FAILED|^[ ]*1\)" /tmp/test_run.log
grep -A 15 "^[ ]*1\)" /tmp/test_run.log # details for the first failure
```
Work from the log file, not from repeated terminal runs.
> **Zero failing tests. No exceptions.**
- Run the full test suite (`mix test`) before considering any task complete
- If you cannot fix a test, explain why and propose a solution - If you cannot fix a test, explain why and propose a solution
> **Zero failing tests. No exceptions.** > **Zero failing tests. No exceptions.**
@@ -101,11 +112,52 @@ This document provides context and best practices for GitHub Copilot when workin
--- ---
## ⚠️ MANDATORY: macOS Bundle Must Be Fully Standalone
**The built `BDS2.app` MUST run on a Mac without Homebrew.**
- No Mach-O inside the `.app` may reference a Homebrew/local path (`/opt/homebrew`, `/usr/local`)
- Every external dylib reachable from the release's NIFs must be copied into `Contents/Frameworks` and rewritten to `@loader_path`-relative install names (`BDS.MacBundle.Dylibs`)
- The same physical dylib referenced under multiple names must be deduped (one real file + symlinks) so dyld loads a single image — duplicate images cause duplicate Objective-C class / wx event-table registrations
- `BDS.MacBundle.verify_standalone/1` runs after relocation and fails the build if any external reference remains — do not weaken or bypass this gate
> **The bundle must be self-contained. No outside references. No exceptions.**
---
## ⚠️ MANDATORY: macOS Bundle Build Order and Blogmark Verification
**`mix release` copies `priv/static` as-is — it never rebuilds assets.** Always build in this order, or the bundle ships stale JS while the server code is current (this silently broke blogmark deep links in July 2026: the server waited for the client's `shell_ready` event that the stale `app.js` never sent, so every deep link queued forever with no error, no toast, no log):
```
mix assets.deploy
MIX_ENV=prod mix release bds --overwrite
mix bds.bundle.macos
```
**After building a bundle, verify blogmark deep links end-to-end with:**
```
scripts/verify_blogmark_macos.sh [path/to/BDS2.app] # defaults to dist/macos/BDS2.app
```
The script launches the bundle via LaunchServices with an isolated database (`launchctl setenv BDS_DATABASE_PATH ...`), fires `bds2://new-post` links with `open -a` exactly like a browser bookmarklet, and asserts draft posts appear in the database — both for cold start (link launches the app, queued until the shell attaches) and for an already-running app. It refuses to run while any BDS2 instance is up; quit the app first.
Things learned the hard way, encoded in the script — do not "simplify" them away:
- Deep links only reach an app **launched through LaunchServices** (`open -a`). A process started by exec'ing `Contents/MacOS/bds2` from a shell never receives Apple Events, so deep links vanish without a trace. For the same reason `open` does not inherit the shell environment — inject env vars with `launchctl setenv` (and unset them afterwards).
- To introspect a running bundle, launch it with `RELEASE_DISTRIBUTION=name` and `RELEASE_NODE=bds@127.0.0.1` set (via `launchctl setenv`), then use `.../Contents/Resources/rel/bin/bds rpc '<elixir>'` with the same env. The default `-sname` node is unreachable when the hostname resolves to LAN addresses.
- The wx → `Desktop.Env` → `BDS.Desktop.DeepLink` → shell pipeline downstream of the OS can be exercised without LaunchServices via `send(Process.whereis(BDS.Desktop.DeepLink), {:open_url, ~c"bds2://new-post?..."})` over rpc.
> **Assets before release. Verify blogmarks after bundling. No exceptions.**
---
## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text ## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text
**All user-facing text MUST follow proper i18n patterns.** **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 - Store UI copy in language resources and resolve text through i18n helpers/hooks
- UI language MUST come from the operating system locale - UI language MUST come from the operating system locale
- Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale - Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale
@@ -113,6 +165,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 - 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 - 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 - 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.** > **No hardcoded user-facing text. No exceptions.**

29
API.md
View File

@@ -40,6 +40,7 @@ local meta = bds.meta.get_project_metadata()
- [app.get_default_project_path](#appget_default_project_path) - [app.get_default_project_path](#appget_default_project_path)
- [app.get_system_language](#appget_system_language) - [app.get_system_language](#appget_system_language)
- [app.get_title_bar_metrics](#appget_title_bar_metrics) - [app.get_title_bar_metrics](#appget_title_bar_metrics)
- [app.log](#applog)
- [app.notify_renderer_ready](#appnotify_renderer_ready) - [app.notify_renderer_ready](#appnotify_renderer_ready)
- [app.open_folder](#appopen_folder) - [app.open_folder](#appopen_folder)
- [app.read_project_metadata](#appread_project_metadata) - [app.read_project_metadata](#appread_project_metadata)
@@ -150,7 +151,7 @@ local result = bds.app.get_default_project_path()
### app.get_system_language ### app.get_system_language
Return the current UI locale. Return the current UI locale (the server-side UI language setting, falling back to the OS locale when unset).
**Parameters** **Parameters**
@@ -202,6 +203,30 @@ nil -- or
local result = bds.app.get_title_bar_metrics() local result = bds.app.get_title_bar_metrics()
``` ```
### app.log
Append a line to the script output stream. Multiple arguments are joined with spaces. Output appears in the desktop app's Output panel (and on stdout in the CLI); Lua's global `print` is routed the same way.
**Parameters**
- text (string, required)
**Response specification**
- Return type: `boolean`
**Example response**
```lua
true
```
**Example call**
```lua
local result = bds.app.log("value")
```
### app.notify_renderer_ready ### app.notify_renderer_ready
Notify the host application that the renderer is ready. Notify the host application that the renderer is ready.
@@ -3443,7 +3468,7 @@ local result = bds.meta.sync_on_startup()
### meta.update_project_metadata ### meta.update_project_metadata
Update metadata for the current project. Update metadata for the current project. Keys omitted from updates keep their current values.
**Parameters** **Parameters**

1
CLAUDE.md Normal file
View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -23,6 +23,7 @@
- [Generating and publishing](#generating-and-publishing) - [Generating and publishing](#generating-and-publishing)
- [Typical editorial workflows](#typical-editorial-workflows) - [Typical editorial workflows](#typical-editorial-workflows)
- [Working fully offline](#working-fully-offline) - [Working fully offline](#working-fully-offline)
- [Running a headless server and the terminal UI](#running-a-headless-server-and-the-terminal-ui)
- [Troubleshooting and recovery](#troubleshooting-and-recovery) - [Troubleshooting and recovery](#troubleshooting-and-recovery)
- [Team conventions](#team-conventions) - [Team conventions](#team-conventions)
@@ -443,6 +444,29 @@ When AI is involved, airplane mode determines which automatic actions are allowe
--- ---
## Running a headless server and the terminal UI
bDS2 can run without a window on a server (for example a Linux VPS) and be used from several places at once. Start it with `BDS_MODE=server`; the same release binary that runs the desktop app then runs headless. All clients connect through one SSH port (default 2222) — the web endpoint itself stays private on the server.
Access is controlled by SSH keys, not passwords. On first start the server creates an `ssh/` folder next to its database (for example `~/Library/Application Support/BDS2/ssh/` on macOS) containing the host key and an empty `authorized_keys` file. Paste the public keys of every machine that may connect into `authorized_keys`, one per line — the same file format OpenSSH uses.
To work in a terminal, connect with plain `ssh -p 2222 user@server`: the terminal UI opens directly. You can browse posts, media, templates, scripts, and tags, create and edit posts, publish, preview images, and run the one-shot AI actions. The status line at the bottom always shows the available keys.
To work in the desktop app against a remote server, use **File → Connect to Server…** and enter `user@host` (or `user@host:port`). The app connects with the SSH key from its own local `ssh/` folder and shows the remote workspace in the window; **File → Disconnect from Server** returns to the local workspace. A plain browser works too, through a manual SSH tunnel (`ssh -p 2222 -L 4010:127.0.0.1:4010 user@server`, then open `http://127.0.0.1:4010`).
Everything you see stays synchronized: when a script, another client, or a pipeline changes content on the server, every connected terminal and window updates. The interface language is a server-side setting — changing it in any client changes it for all of them.
### Key takeaways
- `BDS_MODE=server` runs the same app headless; only the SSH port is exposed.
- Access is public-key only: manage `authorized_keys` in the server's `ssh/` folder.
- `ssh` gives the terminal UI; **File → Connect to Server…** gives the desktop app; both use the same keys.
- All connected clients stay synchronized and share one interface language.
[↑ Back to In this article](#in-this-article)
---
## Troubleshooting and recovery ## Troubleshooting and recovery
If content looks correct locally but is missing for collaborators, the usual cause is that changes were published but not committed and pushed. Check repository status, create a commit, then push to the expected remote. If content looks correct locally but is missing for collaborators, the usual cause is that changes were published but not committed and pushed. Check repository status, create a commit, then push to the expected remote.

298
README.md
View File

@@ -1,52 +1,123 @@
# bDS2 # bDS2
bDS2 is the Elixir rewrite of bDS, the offline-first desktop blogging workspace in [../bDS](/Users/gb/Projects/bDS). The repository now contains a substantial BEAM application: Ecto persistence, filesystem-backed content workflows, rendering/generation/publishing pipelines, AI and MCP integrations, and a bundled desktop shell served by the Elixir runtime. bDS2 is the Elixir rewrite of bDS, the offline-first desktop blogging workspace. It is no longer just a rewrite scaffold: the repository now contains the main desktop runtime, Ecto persistence, filesystem-backed content workflows, rendering and publishing pipelines, Lua scripting, AI and MCP integration, and a Phoenix LiveView shell embedded in a native desktop window.
The Allium specifications in [specs/](/Users/gb/Projects/bDS2/specs) remain the behavioral contract for the rewrite. For current implementation status and the parity roadmap, see [PLAN.md](/Users/gb/Projects/bDS2/PLAN.md). The Allium specs in [specs/](/Users/gb/Projects/bDS2/specs) remain the behavioral contract for the rewrite. For end-user operation, see [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md). For the scripting surface, see [API.md](/Users/gb/Projects/bDS2/API.md).
## Scope ## Current Status
The rewrite aims to preserve the product behavior of bDS while replacing the technical stack. The major architectural rework is in place.
Behaviour that should remain stable includes: - The desktop UI is served by Phoenix LiveView inside the desktop shell rather than by a separate handwritten frontend runtime.
- Assets use Phoenix-default Tailwind and esbuild tooling from [assets/](/Users/gb/Projects/bDS2/assets) into [priv/static/](/Users/gb/Projects/bDS2/priv/static).
- Core editorial flows are implemented in the main application: posts, media, tags, templates, scripts, imports, preview, generation, publishing, maintenance, AI, and MCP.
- Localization is now a first-class architectural concern rather than an afterthought: UI chrome and rendered site output have separate locale flows, and post/media translation workflows are built into the domain model.
- The app boots in three modes: the desktop app, a headless server (SSH-served terminal UI + tunneled GUI), and a local TUI — see [Headless Server Mode And Terminal UI](#headless-server-mode-and-terminal-ui).
- Offline-first editorial workflows. The rewrite still aims to preserve the product behavior of bDS while replacing the technical stack. The contract is product behavior, not the old implementation language or framework choices.
- Filesystem-backed content with stable frontmatter, media sidecars, templates, scripts, and menu formats.
- Project, post, media, translation, tag, template, generation, preview, publishing, AI, and MCP workflows.
- Generated site output, search behavior, metadata synchronization, and rebuild behavior where those are part of the product contract.
The following are intentionally not part of the behavioral contract: ## Architecture Overview
- The implementation language. ### Runtime
- Desktop container or UI framework.
- ORM choice.
- Internal state management, concurrency model, or runtime libraries.
## Scripting Direction [BDS.Application](/Users/gb/Projects/bDS2/lib/bds/application.ex) is the supervision root. It starts the Phoenix endpoint, database, preview and publishing workers, task supervisors, scripting jobs, and the desktop server/window adapters.
bDS2 should use Lua as its user-facing scripting language. At a high level, the stack is:
The reason is host fit, not language fashion: Lua has a better embedding story for the BEAM than Python does, while still being small, expressive, and suitable for user-authored macros, transforms, and utility scripts. The current direction is: - Native windowing through the `:desktop` integration.
- Phoenix endpoint and LiveView shell for the actual app UI.
- Ecto + SQLite for indexed state, editor state, and app data.
- Filesystem-backed project data for published content, media, sidecars, scripts, templates, generated output, and rebuild workflows.
- Lua script files as the persisted user script format. ### Desktop Shell
- A BEAM-hosted execution boundary with explicit host capabilities instead of unrestricted runtime access.
- Bounded but long-running script execution for user-authored code, with explicit progress reporting through host APIs.
The initial runtime baseline in this repository uses a dedicated Elixir scripting boundary with a Luerl-backed Lua adapter. The goal is to keep scripting integration native to the BEAM while making sandboxing and host capability exposure explicit at the application boundary. The desktop workbench lives under [lib/bds/desktop/](/Users/gb/Projects/bDS2/lib/bds/desktop). The main screen is [BDS.Desktop.ShellLive](/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex), with feature-specific editors and sidebar logic under [lib/bds/desktop/shell_live/](/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live).
This keeps the scripting surface lightweight and aligned with the Elixir host application. Python remains a possible integration boundary for specialized tasks, but it is no longer the default scripting model for the rewrite. If you are tracing UI behavior, start there first:
## Repository Layout - LiveView event routing, workbench state, overlays, and menu handling live in the desktop shell modules.
- HEEx templates under the same tree now own most common layout and state styling.
- Monaco is bundled as an ESM bundle via esbuild into `priv/static/assets/monaco.js` and `monaco.css` (source entry at `assets/js/monaco_entry.js`).
- [mix.exs](/Users/gb/Projects/bDS2/mix.exs): Mix project definition. ### Domain Modules
- [config/](/Users/gb/Projects/bDS2/config): Elixir and Ecto configuration.
- [lib/](/Users/gb/Projects/bDS2/lib): application bootstrap and shared runtime modules. Most application behavior lives under [lib/bds/](/Users/gb/Projects/bDS2/lib/bds):
- [priv/repo/](/Users/gb/Projects/bDS2/priv/repo): Ecto migrations.
- [specs/](/Users/gb/Projects/bDS2/specs): Allium specs distilled from the existing bDS product and being normalized for implementation-agnostic use. - posts, media, tags, templates, scripts, and project settings
- metadata, frontmatter, sidecars, rebuild, and maintenance
- rendering, generation, preview, and publishing
- AI runtimes, chat tooling, embeddings, and MCP
- scripting capabilities and generated API docs
The repo has been pushed toward smaller feature-focused modules rather than one large mixed runtime. For new work, prefer finding the owning feature module instead of adding more behavior to broad catch-all files.
### Storage Model
The database is important, but it is not the whole source of truth.
- Ecto models hold app state, indexes, editor state, and workflow data.
- The filesystem holds published content artifacts and sidecar metadata that must stay stable and reviewable.
- Rebuild and metadata-diff flows exist because database state and filesystem state are expected to stay in sync.
When you change persisted behavior, think in both directions: database writes and filesystem writes/readback.
## Localization And i18n
Localization now has two separate layers, and confusing them causes bugs.
### 1. UI Localization
UI chrome, menus, dashboard text, editor labels, and toasts use Gettext through [BDS.Gettext](/Users/gb/Projects/bDS2/lib/bds/gettext.ex) and the `ui` domain. Locale normalization lives in [BDS.I18n](/Users/gb/Projects/bDS2/lib/bds/i18n.ex), and the desktop shell binds the active UI locale through [BDS.Desktop.UILocale](/Users/gb/Projects/bDS2/lib/bds/desktop/ui_locale.ex).
In practice, this is the language of the app itself.
### 2. Render Localization
Rendered site output uses a separate locale flow. Archive labels, pagination text, template-facing render strings, and generated site language handling use the `render` Gettext domain and the project's `main_language` and `blog_languages` settings.
In practice, this is the language of the blog output, not necessarily the UI.
### 3. Content Translation
Posts and media have translation-aware workflows. Post translations and media metadata translations are modeled explicitly, and generation/preview/publishing use the project's configured languages when building output.
Relevant translation resources live under:
- [priv/gettext/](/Users/gb/Projects/bDS2/priv/gettext) for Gettext catalogs
- [priv/i18n/](/Users/gb/Projects/bDS2/priv/i18n) for additional locale data used by the app
If you touch i18n-sensitive behavior, check whether the change belongs to UI locale, render locale, or content translation. They are related, but they are not interchangeable.
## Frontend And Assets
Frontend source now follows the Phoenix asset layout:
- [assets/css/](/Users/gb/Projects/bDS2/assets/css) for Tailwind-based CSS modules
- [assets/js/](/Users/gb/Projects/bDS2/assets/js) for LiveView hooks, bridges, Monaco integration, and UI helpers
- [priv/static/assets/](/Users/gb/Projects/bDS2/priv/static/assets) for generated outputs
The rule of thumb is simple:
- common layout, spacing, state, and typography belong in HEEx and small shared UI primitives
- authored CSS stays for tokens and desktop-specific selectors
- JavaScript stays focused on LiveView hooks, editor integration, drag/drop, and browser APIs
## Repository Map
- [mix.exs](/Users/gb/Projects/bDS2/mix.exs): Mix project definition, aliases, releases, and dependencies
- [config/](/Users/gb/Projects/bDS2/config): runtime, dev, test, and asset configuration
- [lib/bds/](/Users/gb/Projects/bDS2/lib/bds): core application modules
- [lib/bds/desktop/](/Users/gb/Projects/bDS2/lib/bds/desktop): desktop endpoint, shell, menus, controllers, and window integration
- [assets/](/Users/gb/Projects/bDS2/assets): Tailwind and esbuild source
- [priv/repo/](/Users/gb/Projects/bDS2/priv/repo): Ecto migrations and snapshots
- [priv/gettext/](/Users/gb/Projects/bDS2/priv/gettext): UI and render translation catalogs
- [specs/](/Users/gb/Projects/bDS2/specs): Allium behavior specs
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md): end-user guide
- [API.md](/Users/gb/Projects/bDS2/API.md): generated scripting API reference
## macOS Development Setup ## macOS Development Setup
If you are setting up a new macOS machine, start with the toolchain. If you are setting up a new macOS machine, install the toolchain first.
### 1. Install Xcode Command Line Tools ### 1. Install Xcode Command Line Tools
@@ -56,8 +127,6 @@ xcode-select --install
### 2. Install Homebrew ### 2. Install Homebrew
If Homebrew is not already installed:
```bash ```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
``` ```
@@ -69,36 +138,159 @@ brew update
brew install erlang elixir sqlite brew install erlang elixir sqlite
``` ```
Verify the installation: ### 4. Fetch Dependencies And Set Up The App
```bash
elixir --version
mix --version
sqlite3 --version
```
### 4. Fetch Dependencies
```bash ```bash
cd /Users/gb/Projects/bDS2 cd /Users/gb/Projects/bDS2
mix deps.get mix setup
mix assets.setup
``` ```
### 5. Create the Local Database ## Development Workflow
```bash Useful commands:
mix ecto.create
mix ecto.migrate
```
### 6. Run Tests
```bash ```bash
mix compile --warnings-as-errors
mix test mix test
mix dialyzer
mix assets.build
``` ```
## Development Notes ## Headless Server Mode And Terminal UI
- Use `mix test` for validation during development. Since issue #26 the app has three boot modes, selected with the `BDS_MODE`
- The application behavior is defined by the Allium specs in [specs/](/Users/gb/Projects/bDS2/specs). environment variable (any release or dev boot; default is the desktop app):
- Use [PLAN.md](/Users/gb/Projects/bDS2/PLAN.md) for implementation status and the parity roadmap.
- `desktop` — the native desktop app, unchanged.
- `server` — headless: no window, loopback-only HTTP endpoint, CLI sync
watcher, and an SSH daemon serving the terminal UI. Works on macOS,
Windows, and Linux (no display needed).
- `tui` — the headless server plus a TUI attached to the launching terminal.
### Build and run a headless server
```bash
MIX_ENV=prod mix release bds --overwrite
BDS_MODE=server _build/prod/rel/bds/bin/bds start
```
The SSH daemon listens on port `2222` (`BDS_SSH_PORT` or
`config :bds, :server, ssh_port:` to change). Authentication is public-key
only: on first boot the server generates a host key and an empty
`authorized_keys` (mode 600) in `ssh/` next to the database (default
`~/Library/Application Support/BDS2/ssh/`, or under `BDS_DATABASE_PATH`).
Add your public key there, then connect from any terminal:
```bash
ssh -p 2222 <server-host> # opens the TUI
```
Each SSH connection gets its own TUI session on the server; only terminal
cells cross the wire. All clients stay synchronized through the domain
event bus (`BDS.Events`) — a post created by a pipeline or another client
appears in every open shell.
### TUI keys
`↑/↓` or `j/k` navigate · `enter` open · `n` new post · `1-5` switch view
(posts/media/templates/scripts/tags) · `:` command prompt (metadata
diff, validate site, force render, rebuilds, …; `:?` lists all) ·
metadata-diff/site-validation reports open as panels — `enter` applies
the whole report (repair from files / incremental apply), `esc` cancels ·
`r` refresh · in the editor:
`ctrl+s` save, `ctrl+p` publish, `ctrl+e` word-wrapped preview,
`ctrl+t` title, `ctrl+l` language, `ctrl+g` AI suggestions
(airplane-mode gated), `esc` back · `ctrl+q` quit.
Images preview inline via the terminal image protocol. The UI language is
the server-side setting shared by every client.
### Remote GUI
The HTTP endpoint stays bound to `127.0.0.1` and is never exposed; GUI
clients tunnel through the same SSH daemon and keys:
```bash
ssh -p 2222 -L 4010:127.0.0.1:4010 -N <server-host> &
open http://127.0.0.1:4010
```
Or use the desktop app directly: **File → Connect to Server…** takes
`user@host[:port]`, connects with the client key from the same local
`ssh/` directory (public-key auth, trust-on-first-use `known_hosts`),
tunnels to the server, and points the window at it. **File → Disconnect
from Server** returns to the local workspace.
## Monaco Editor
Monaco is bundled via esbuild using its ESM entry point (`monaco-editor/esm/vs/editor/editor.main.js`). The
main bundle is built as part of `mix assets.build` and lives at `priv/static/assets/monaco.js` and
`monaco.css`; ESM worker bundles live under `priv/static/assets/monaco/`.
```bash
mix monaco.version # show current monaco-editor version
mix monaco.update 0.55.1 # update to a specific version and rebuild the bundle
```
The update task runs `npm install monaco-editor@<version>`, cleans the old bundle, and rebuilds via esbuild.
See https://www.npmjs.com/package/monaco-editor for available versions.
Notes for developers:
- Specs in [specs/](/Users/gb/Projects/bDS2/specs) define the intended product behavior.
- [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
View 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

View File

@@ -1,577 +0,0 @@
# Tailwind And Phoenix Asset Tooling For bDS2 Desktop
## Purpose
This document describes the target styling architecture for a Tailwind-integrated Phoenix LiveView desktop app in this repository.
It is written as a handoff for a coding agent that will perform the implementation.
This is not a migration guide from a public web app. It is a target-state guide for a Phoenix LiveView desktop shell with local assets, dense editor surfaces, overlays, resizable panes, and desktop-specific titlebar behavior.
## Verified Current State
The current app does not use the default Phoenix asset pipeline.
- CSS is served directly from `priv/ui/app.css`.
- JS is served directly from `priv/ui/live.js`.
- Static assets are served from `priv/ui` in `lib/bds/desktop/endpoint.ex`.
- The root layout links `/assets/app.css` in `lib/bds/desktop/layouts.ex`, but that path is currently backed by `priv/ui/app.css`, not by generated Phoenix assets.
- There is no `:tailwind` or `:esbuild` setup in `mix.exs`.
Relevant files:
- `lib/bds/desktop/endpoint.ex`
- `lib/bds/desktop/layouts.ex`
- `mix.exs`
- `priv/ui/app.css`
- `priv/ui/live.js`
## Official Phoenix Facts
The implementation target should follow Phoenix defaults where they fit the desktop shell.
Official Phoenix references:
- Phoenix Asset Management: https://hexdocs.pm/phoenix/asset_management.html
- Phoenix Components and HEEx: https://hexdocs.pm/phoenix/components.html
- Phoenix.Component reference: https://hexdocs.pm/phoenix_live_view/Phoenix.Component.html
Facts from Phoenix docs and generator templates:
- Phoenix v1.7+ defaults to Tailwind for CSS and esbuild for JS.
- The default CSS source entrypoint is `assets/css/app.css`.
- The default generated CSS output is `priv/static/assets/css/app.css`.
- The default generated JS output is `priv/static/assets/js/app.js`.
- Phoenix promotes function components and HEEx as the main rendering model.
- Phoenix does not prescribe per-component CSS modules. Tailwind-first HEEx plus shared source CSS is a valid Phoenix-default shape.
## Target Outcome
The target outcome is a solid Phoenix-default asset setup with Tailwind as the main styling system and a generated production stylesheet.
The target should look like this:
- Tailwind source lives in `assets/css/app.css` and imported CSS modules.
- The final stylesheet is generated into `priv/static/assets/css/app.css`.
- LiveView JS entrypoint lives in `assets/js/app.js` and builds to `priv/static/assets/js/app.js`.
- The desktop endpoint serves static assets from `priv/static`.
- Layouts reference the generated CSS and JS outputs.
- HEEx markup carries most layout, spacing, typography, responsive, and state classes.
- Authored CSS remains for global tokens, app-region behavior, pseudo-elements, scrollbars, Monaco integration, hard selectors, and overlay mechanics.
## Architecture Rules
### 1. Tailwind Owns The Common Case
Use Tailwind utility classes directly in HEEx for:
- flex and grid layout
- spacing
- typography
- borders and radii
- colors and opacity
- active, selected, disabled, and hover states
- responsive breakpoints
- overflow and truncation
Do not preserve large semantic wrapper classes when they only encode simple layout decisions.
Good examples to move into HEEx classes:
- tab rows
- button rows
- editor header layout
- metadata field layout
- sidebar list row spacing
- status bar item alignment
### 2. Authored CSS Owns The Desktop-Specific Case
Keep authored CSS source files for:
- `app-region` and `-webkit-app-region`
- pseudo-elements used for icons, handles, and active markers
- custom scrollbars
- Monaco/editor iframe or host integration
- absolute overlay stacks and backdrops
- drag and drop affordances
- complex attribute selectors
- hard-to-read repeated combinations that should become shared component classes
Do not force these into giant utility strings if readability drops.
### 3. Keep A Thin Semantic CSS Layer
It is acceptable to keep a small number of semantic component classes when they encode a repeated desktop UI primitive.
Examples of valid semantic classes in the target state:
- `.window-titlebar`
- `.resizable-panel-divider`
- `.overlay-root`
- `.monaco-host`
- `.panel-entry`
- `.btn-base`, `.btn-theme-primary`, `.btn-theme-danger`
These classes should be implemented in Tailwind source CSS using `@layer components` and should remain small, stable, and reusable.
### 4. Tokens Must Be Centralized
The current stylesheet relies heavily on VS Code-like CSS variables. Preserve that idea.
The new `assets/css/app.css` must define the design tokens once, using Tailwind v4 `@theme` plus any required raw CSS custom properties for runtime-driven values.
Examples from the current app that must survive:
- shell/background colors
- tab active/inactive colors
- status bar colors
- focus border color
- input background and border colors
- sidebar width
- assistant width
- font family and font size
### 5. Desktop Layout Constraints Must Stay Intact
The styling rewrite must preserve these runtime constraints:
- the app occupies full window width and height
- the shell uses `overflow: hidden` at the top level
- the main workbench uses `min-height: 0` and `min-width: 0` correctly
- resizable sidebars remain width-variable
- titlebar remains draggable except for interactive controls
- overlays render above the shell without breaking keyboard focus or pointer behavior
## Proposed Asset Layout
Use the standard Phoenix asset layout.
```text
assets/
css/
app.css
shell.css
sidebar.css
tabs.css
editor.css
forms.css
overlays.css
panel.css
assistant.css
menu_editor.css
media_editor.css
utilities.css
js/
app.js
...
priv/
static/
assets/
css/
js/
```
Recommended responsibilities:
- `assets/css/app.css`: Tailwind import, `@source`, tokens, base layer, imports
- `assets/css/shell.css`: app shell, titlebar, activity bar, pane shells, status bar
- `assets/css/sidebar.css`: sidebar filters, search, chips, calendar tree, load more
- `assets/css/tabs.css`: workbench tabs and editor tabs
- `assets/css/editor.css`: common editor frame, toolbar, meta column, shared form shell
- `assets/css/forms.css`: shared input, textarea, tag chip, picker, inline action primitives
- `assets/css/overlays.css`: overlay root, modal backdrop, dialog shells, gallery/lightbox
- `assets/css/panel.css`: panel tabs, panel entry cards, tasks, output, git log
- `assets/css/assistant.css`: assistant sidebar and chat-specific shared surfaces
- `assets/css/menu_editor.css`: menu tree, drag/drop indicators, picker lists
- `assets/css/media_editor.css`: media preview, linked post picker, detail forms
- `assets/css/utilities.css`: a very small set of custom utilities that are truly reused
## Proposed Phoenix Asset Tooling
The implementation should introduce the standard Phoenix aliases and configs.
### mix.exs
Add:
- `{:tailwind, "~> 0.3", runtime: Mix.env() == :dev}`
- `{:esbuild, "~> 0.10", runtime: Mix.env() == :dev}`
Add aliases similar to:
- `assets.setup`
- `assets.build`
- `assets.deploy`
### Versioning (mandatory)
The Elixir `:tailwind` wrapper still defaults to Tailwind v3. The plan in this document assumes **Tailwind v4** syntax (`@import "tailwindcss"`, `@theme`, `@source`, `@layer components`). Pin both tools explicitly in `config/config.exs`:
- `config :tailwind, version: "4.1.14"` (or current 4.1.x)
- `config :esbuild, version: "0.25.4"` (or current 0.25.x)
Without an explicit v4 pin the build will silently install Tailwind v3 and v4 directives will not resolve.
### No Node.js policy
The Elixir `:tailwind` and `:esbuild` wrappers download self-contained binaries and do **not** require Node.js. The implementation MUST stay Node-free unless a third-party Tailwind plugin is later required (in which case the custom `assets/build.js` route from the Phoenix asset_management guide is used). No `package.json` is added under `assets/`.
### config/config.exs
Configure Tailwind input and output paths.
Target output:
- `assets/css/app.css` -> `priv/static/assets/css/app.css`
Configure esbuild for:
- `assets/js/app.js` -> `priv/static/assets/js/app.js`
esbuild profile must include `--bundle --target=es2022 --outdir=../priv/static/assets/js --external:/fonts/* --external:/images/*` and `nodePaths: [Mix.Project.build_path() <> "/../../deps"]` so `phoenix`, `phoenix_html`, and `phoenix_live_view` resolve from `deps/` without an `npm install`.
### config/dev.exs
Add Phoenix watchers for:
- Tailwind `--watch`
- esbuild `--watch`
### No phx.digest in desktop builds
This is a desktop app served through an embedded WebView, not a public web app behind a CDN.
- Do NOT run `mix phx.digest` as part of `assets.deploy`.
- Output filenames stay stable (`app.css`, `app.js`) so the layout can link them by fixed path.
- `assets.deploy` for this repo is: `tailwind default --minify`, `esbuild default --minify`. Nothing else.
### endpoint/layout changes
Update the desktop endpoint and root layout to serve and link generated assets from `priv/static/assets` instead of `priv/ui`.
Specifically:
- Replace the existing `Plug.Static` for `/assets` with `from: {:bds, "priv/static/assets"}` and `only` listing the generated `css` and `js` directories.
- Drop the `/vendor/phoenix` and `/vendor/live_view` `Plug.Static` blocks; those scripts are now bundled by esbuild from `deps/`.
- Add a dedicated `Plug.Static` for `/monaco` pointing at `priv/ui/monaco` (or move it to `priv/static/monaco`). Monaco is a prebuilt vendor drop and MUST NOT be passed through esbuild.
- Remove the `<script src="/vendor/phoenix/...">` and `<script src="/vendor/live_view/...">` tags from `lib/bds/desktop/layouts.ex`. Keep only `/assets/app.css` and `/assets/app.js`.
## JS Pipeline
The JS pipeline mirrors the CSS pipeline.
### Entrypoint
`assets/js/app.js` is the single esbuild entrypoint. It:
- imports `phoenix`, `phoenix_html`, and `phoenix_live_view` (resolved from `deps/` via esbuild `nodePaths`)
- constructs the `LiveSocket` with the desktop CSRF token
- registers all hooks currently defined inline in `priv/ui/live.js`
- wires the native menu / titlebar / shortcut bridges
### Module layout
Split the current `priv/ui/live.js` (1.4k lines) into focused modules under `assets/js/`:
- `assets/js/app.js` - entrypoint, LiveSocket, hook registration
- `assets/js/hooks/` - one file per hook
- `assets/js/bridges/` - native menu, titlebar, shortcut bridges
- `assets/js/monaco/` - thin host glue only; do NOT bundle Monaco itself
### Monaco carve-out
Monaco is loaded as prebuilt assets from `/monaco/...` and is not part of the esbuild graph. The host glue in `assets/js/monaco/` only configures the loader URL and posts messages; it does not `import` Monaco modules.
### Vendor stripping
After the switch:
- `priv/ui/app.css` is deleted (its content has been redistributed under `assets/css/`).
- `priv/ui/live.js` is deleted.
- `priv/ui/monaco/` stays (or moves to `priv/static/monaco/`).
- The `phoenix` and `phoenix_live_view` dep static drops are no longer served.
## Iconography
The app keeps its existing **inline SVG** icon set. Do NOT adopt the Phoenix 1.8 Heroicons-via-Tailwind-plugin pattern.
Reasons:
- The target visual look is defined by the current bDS UI; its icons are part of that look.
- Inline SVG keeps icons under direct control for sizing, stroke, and currentColor styling via Tailwind utility classes.
- Avoiding the Heroicons git dep keeps the build Node-free and dependency-light.
Rules:
- Existing SVG icons stay where they are (HEEx components / inline `<svg>` markup).
- Icon size and color are controlled by Tailwind utilities on the wrapping element (`size-4`, `text-[var(--color-icon)]`, etc.) using `fill="currentColor"` or `stroke="currentColor"`.
- No `:heroicons` mix dep, no Tailwind icon plugin, no `assets/vendor/heroicons.js`.
- If a new icon is needed, add the SVG inline in the appropriate component module.
## HEEx Styling Strategy
### Prefer Utility Classes In Templates
Use utility classes in HEEx for:
- shell flex layout
- editor content spacing
- section stacks
- truncation and overflow
- standard button alignment
- grid column changes at breakpoints
- selected and hovered states that are directly tied to assign state
### Keep Dynamic Class Lists Explicit
LiveView templates should build classes with arrays where state is already in assigns.
Example pattern:
```elixir
class={[
"flex items-center gap-2 px-3 py-2 text-sm",
selected? && "bg-[var(--color-selected-bg)] text-white",
disabled? && "opacity-50 pointer-events-none"
]}
```
### Do Not Hide Structure Behind Giant Custom Class Trees
Avoid re-creating the current `app.css` by keeping every nested selector and merely moving it into `assets/css`.
If a selector exists only because the old CSS owned all layout, move that responsibility into HEEx.
## Tailwind Source Conventions
### app.css
`assets/css/app.css` should use Tailwind v4 import syntax.
`source(none)` disables Tailwind's automatic content detection so only directories listed via `@source` are scanned. Every directory that ships HEEx or class strings MUST be listed. Audit `lib/` for additional renderers (preview, generation, MCP surfaces) and add `@source` lines for any that emit class names consumed at build time.
Runtime-driven values (e.g. resizable panel widths that change via CSS variable assignment from JS) MUST stay as plain CSS custom properties under `:root` or a scoped selector. `@theme` values are baked at build time and are not appropriate for values mutated at runtime. Use `@theme` for stable design tokens (colors, font sizes, spacing scale extensions) that should produce utility classes; use `:root { --foo: … }` for everything that the app writes to at runtime.
Suggested structure:
```css
@import "tailwindcss" source(none);
@source "../css";
@source "../js";
@source "../../lib/bds/desktop";
@theme {
/* app tokens */
}
@layer base {
/* html, body, root shell defaults */
}
@import "./shell.css";
@import "./sidebar.css";
@import "./tabs.css";
@import "./editor.css";
@import "./forms.css";
@import "./panel.css";
@import "./assistant.css";
@import "./overlays.css";
@import "./menu_editor.css";
@import "./media_editor.css";
@import "./utilities.css";
```
### Component CSS Rules
When writing component CSS in imported files:
- use `@layer components` for shared semantic classes
- use `@layer utilities` only for narrowly reusable custom utilities
- keep selectors shallow
- avoid giant descendant chains unless required by generated HTML structure or overlay mechanics
- prefer `@apply` sparingly and only for stable component classes, not as a substitute for writing HEEx utilities
## Current UI Structure Reference
The implementation agent must use the current monolith as a source map, not as the final architecture.
The current stylesheet is `priv/ui/app.css` and is approximately 8.5k lines.
Use the following region map when carrying styling over.
### Current app.css region map
- Lines `1-140`: root variables, base element reset, buttons, top-level shell defaults
- Lines `141-415`: app shell and window titlebar
- `.app`, `.app-main`, `.app-content`
- `.window-titlebar*`
- Lines `416-623`: activity bar and sidebar shells
- `.activity-bar*`
- `.sidebar-shell*`, `.assistant-sidebar-shell*`
- `.sidebar*`, `.assistant-sidebar*`
- `.resizable-panel-divider`
- Lines `624-812`: workbench tab bar
- `.tab-bar*`, `.tab*`
- Lines `813-1100`: shared editor shell and editor tabs
- `.editor-shell`, `.editor-frame`, `.editor-main`, `.editor-meta`
- `.editor-toolbar*`
- `.post-editor .editor-header`, `.editor-tabs`, `.editor-tab*`
- Lines `1100-1700`: post editor forms and metadata/media panel
- `.post-editor .editor-content`
- `.post-editor .editor-field*`
- `.post-editor .post-editor-input`, `.post-editor-textarea`
- `.tag-input*`, `.tag-chip*`
- media insertion and post media list
- Line `1691` onward: first responsive collapse for editor/media layout
- Lines `1736-1833`: early shell/gallery overlay and insert-media grid rules
- Lines `1833+`: more mobile/narrow viewport adjustments
- Lines `1950-2263`: status bar and shell footer controls
- `.status-bar*`
- `.project-selector*`
- Lines `2264-2599`: overlay root and modal system
- `.overlay-root`
- `.ai-suggestions-modal*`
- `.insert-modal*`
- `.language-picker-modal*`
- `.confirm-delete-modal*`
- `.gallery-overlay*`
- Lines `2600-2876`: menu editor
- `.menu-editor-*`
- Lines `2889+`: media editor
- `[data-testid="media-editor"] *`
- Lines `3458+`: style/theme picker surface
- `.style-theme-*`
- Lines `4958`, `6722`, `8268`, `8514`: later breakpoint-specific adjustments for desktop shell and advanced editors
Treat those ranges as source material to be redistributed into the new Tailwind source layout.
## Current HEEx And Component Surface Reference
The styling rewrite needs to move with the component structure, not only with CSS selectors.
Important current rendering surfaces:
- `lib/bds/desktop/shell_live.ex`
- top-level workbench render entry
- `lib/bds/desktop/shell_live/sidebar_components.ex`
- sidebar search, archive tree, tag/category chips, nav/settings lists, load more
- `lib/bds/desktop/shell_live/panel_renderer.ex`
- tasks, output, git log, panel toolbars
- `lib/bds/desktop/shell_live/post_editor.ex`
- post editor render surface
- `lib/bds/desktop/shell_live/media_editor.ex`
- media editor render surface
- `lib/bds/desktop/shell_live/script_editor.ex`
- script editor render surface
- `lib/bds/desktop/shell_live/template_editor.ex`
- template editor render surface
- `lib/bds/desktop/shell_live/chat_editor.ex`
- assistant/chat surface
- `lib/bds/desktop/shell_live/menu_editor.ex`
- menu editor tree surface
Implementation rule:
- move simple layout and state styling into these HEEx/component surfaces
- keep authored CSS for shared primitives and complex desktop behavior
## Desktop-Specific Styling Rules
The app is a desktop shell, not a normal browser page.
The implementation must preserve:
- draggable titlebar regions
- non-draggable controls inside the titlebar
- local, app-like split-pane behavior
- fixed-height titlebar, tabs, and status bar rails
- overlay stacking over the shell
- editor and sidebar widths controlled by CSS variables where appropriate
- visual parity for assistant/sidebar/panel open and closed states
Do not optimize for tiny public web payloads at the expense of shell clarity.
Do optimize for maintainability, explicit component ownership, and predictable desktop behavior.
## Implementation Phases
### Phase 0: Tests And Validation Baseline
- Add a smoke test that requests `/assets/app.css` and `/assets/app.js` and asserts a 200 with non-empty body served from `priv/static/assets`.
- Add a render snapshot test for the top-level shell HEEx so class-string regressions during the rewrite are caught.
- Establish that every phase ends with clean `mix compile --warnings-as-errors`, `mix test`, and `mix dialyzer` runs (per repo AGENTS.md).
### Phase 1: Install Phoenix Asset Tooling
- add Tailwind and esbuild dependencies
- create `assets/css/app.css` and `assets/js/app.js`
- configure `config/config.exs`, `config/dev.exs`, and `mix.exs`
- switch endpoint/layouts to generated assets
- keep current visuals as close as possible
### Phase 2: Split The Monolith Into Source Modules
- move the current `priv/ui/app.css` into the proposed `assets/css/*.css` modules
- keep selectors mostly intact at first
- copy raw selectors only; do NOT rewrite to `@apply` in this phase
- defer all `@apply` and utility-extraction decisions to Phase 3
- verify the desktop shell still renders correctly
### Phase 3: Move Common Layout Into HEEx
- rewrite top-level shell markup, tabs, headers, and common forms to use Tailwind classes directly
- reduce selector nesting
- keep only the thin semantic CSS layer
### Phase 4: Normalize Shared Primitives
- standardize buttons
- standardize inputs and textareas
- standardize tabs and badges
- standardize panel entries and empty states
### Phase 5: Finish Desktop-Specific Surfaces
- overlays
- menu editor drag/drop states
- media preview/detail layouts
- assistant/chat surfaces
- narrow viewport behavior
## Acceptance Criteria
The rewrite is successful when:
- assets are built through Phoenix default tooling
- the desktop endpoint serves generated assets from `priv/static`
- the visual shell matches the existing app closely enough for feature work to continue
- the current `priv/ui/app.css` is no longer the source of truth
- the new styling is split into source modules with clear ownership
- HEEx markup owns common layout and state classes
- authored CSS is limited to tokens, desktop primitives, and complex selectors
- Tailwind v4 and esbuild versions are explicitly pinned in `config/config.exs`
- no Node.js / `package.json` is introduced under `assets/`
- no `mix phx.digest` step exists; generated asset filenames remain stable
- inline SVG iconography from the existing UI is preserved (no Heroicons dep)
- `mix compile --warnings-as-errors`, `mix test`, and `mix dialyzer` are clean after every phase
## Non-Goals
These are not goals of the rewrite:
- blindly replacing every old class with utilities in one pass
- preserving the exact old selector tree
- introducing CSS modules or a React-style styling system
- optimizing for generic web landing-page concerns
- changing product behavior unrelated to styling and asset delivery
## Guidance For The Coding Agent
When implementing this plan:
- start by wiring Phoenix asset tooling, not by rewriting 8k lines of CSS in place
- preserve runtime behavior first, then simplify
- move layout decisions to HEEx only when they become clearer there
- keep desktop-specific mechanics in CSS
- use the current `priv/ui/app.css` region map as a source index, not as a blueprint for the final architecture

87
TESTAUDIT.md Normal file
View 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.

223
TUI_EDITOR.md Normal file
View File

@@ -0,0 +1,223 @@
# TUI Editor — Implementation Plan (Issue #32)
Tracking issue: **#32** — "markdown, liquid and lua code editor with syntax
highlighting in the TUI".
Spec under change: `specs/tui.allium`.
Goal: a syntax-highlighted, word-wrapping TUI editor for posts (markdown
with `[[macro]]` syntax), templates (HTML + Liquid), and scripts (Lua),
replacing the current plain `ExRatatui.Widgets.Textarea` body.
## Decision: pure Elixir — no new Rust
`ExRatatui` already ships everything we need:
- `ExRatatui.CodeBlock.highlight/3` (`deps/ex_ratatui/lib/ex_ratatui/code_block.ex:103`)
returns `[%ExRatatui.Text.Line{}]` of styled spans via the bundled
syntect NIF. Supports Lua, Markdown, HTML, … out of the box.
- `ExRatatui.Widgets.CodeBlock` is read-only; we do **not** use it
directly for editing.
- `ExRatatui.Widget` protocol (`deps/ex_ratatui/lib/ex_ratatui/widget.ex:1`)
lets us implement a custom widget in pure Elixir that composes
primitives every frame. This is the seam we use.
- The editing engine stays Rust-owned
(`ExRatatui.textarea_new/0`, `textarea_handle_key/3`, `textarea_get_value/1`,
`textarea_cursor/1`, `textarea_line_count/1`). The custom widget only
re-renders the buffer through a styling + wrap pipeline.
- `ExRatatui.Widgets.Paragraph` accepts `[%Text.Line{}]` of styled spans
with `wrap: true` and a `scroll: {vertical, horizontal}` offset —
exactly the output shape we need.
The existing comment at `lib/bds/tui.ex:1451-1454` already pre-announces
this approach as "the planned MarkdownEditor custom widget".
Languages supported (matches GUI Monaco registrations in
`assets/js/monaco/languages.js`):
| Entity | Base lexer (NIF) | Elixir overlay |
|-----------------------|------------------|---------------------------------|
| post body | `markdown` | `[[macro …]]` recoloured |
| script body | `lua` | none |
| template body | `html` | `{{ … }}` and `{% … %}` recoloured |
Liquid is always HTML+Liquid in this codebase (no markdown+Liquid
templates exist). Plain text falls back to syntect's plain renderer.
## GUI styling parity (from `assets/js/monaco/theme.js` + `languages.js`)
- **macro** `[[ … ]]``keyword.macro` = `#C586C0`, **bold**.
- inside macro: `attribute.name` (e.g. `names`) → `#9CDCFE`,
`attribute.value` (e.g. `"x"`) → `#CE9178`.
- Liquid uses base vs-dark tokens; terminal approximation uses the same
base theme accent colours. No custom Liquid token colours overridden.
Use `{:rgb,197,134,192}` etc. via `ExRatatui.Style` (already supported
by `ExRatatui.CodeBlock.from_native/1`).
## Architecture
```
state.editor.textarea (unchanged, Rust-owned buffer/cursor/undo)
state.editor.language (:markdown_macros | :liquid | :lua | :text)
state.editor.preview? (unchanged ctrl+e toggle — default false for posts)
body_widget/3 edit branch: %BDS.UI.CodeEditor{textarea:, language:, theme:, wrap:, cursor_style:, line_highlight_style:, block:}
↳ impl ExRatatui.Widget.render:
1. content = ExRatatui.textarea_get_value(textarea)
2. {row, col} = ExRatatui.textarea_cursor(textarea)
3. lines = BDS.UI.CodeEditor.Highlight.highlight(content, language, theme)
4. apply current-line bg style into every span on lines[row]
5. cut the span at byte col on lines[row], splice cursor-style cell
6. top = vertical-scroll offset so cursor visual row stays in window (Latin-width count)
7. return [{%Paragraph{text: lines, wrap: true, scroll: {top,0}, block: block}, rect}]
```
`editor_key/1` in `lib/bds/tui.ex` keeps calling
`ExRatatui.textarea_handle_key/3` — no edit-model rewrite, the
textarea's own undo/redo/clipboard keeps working.
## Phases (test-first, per AGENTS.md)
### Phase 0 — Spec
Edit `specs/tui.allium`:
- `OpenEntry`: posts open in the **editor** (wrap on, highlighting on)
by default — not the rendered Markdown preview. New empty posts still
open in the editor (unchanged).
- Rename `WrappedPreview``EditorMode`: `ctrl+e` toggles editor ↔
read-only rendered-Markdown preview (via `ExRatatui.Widgets.Markdown`).
Drop the "textarea cannot wrap" caveat.
- Add `CodeEditorWrapping` rule: syntax highlighting + auto wrap +
highlighted current line. Explicitly **no line-number gutter**.
- Validate with `allium check specs/tui.allium`.
### Phase 1 — Red tests
New file `test/bds/ui/code_editor_test.exs`:
- `BDS.UI.CodeEditor.Highlight.highlight/3` for `:lua` colours a
`function … end` keyword with the theme's keyword colour.
- `:markdown_macros` recolours `[[gallery names="x"]]`:
- `[[`, `]]` and macro name → `{:rgb,197,134,192}` bold
- `names``{:rgb,156,220,254}`
- `"x"``{:rgb,206,145,120}`
- surrounding markdown still base-styled.
- `:liquid` recolours `{{ x }}` and `{% if x %}` over an HTML base;
`if` keyword distinct from identifiers.
- Widget emits a `%Paragraph{wrap: true, scroll:}`, never a bare `%Textarea`,
when language is one of the highlighted set.
Extended `test/bds/tui_test.exs`:
- Opening an existing post lands in the **editor** (`preview? == false`),
not the rendered preview. Replace the assertion in
`test "opening a post lands in the markdown preview, not the editor"`.
Test name/logic flips.
- Long line wraps across N visual rows in a 40-wide viewport
(assert via `CellSession.take_cells/1`).
- Cursor at the last visual row stays visible when scrolling a small
viewport (assert cursor-style cell appears in snapshot cells).
- Current line's cells have a distinct `bg` from non-current rows on
the same buffer.
- `ctrl+e` flips to preview and back; preview still rendered Markdown.
- `ctrl+s` persists textarea bytes unchanged regardless of highlight/wrap
(existing assertion still covers this — keep it green).
### Phase 2 — `lib/bds/ui/code_editor/highlight.ex` + `overlay.ex`
- `BDS.UI.CodeEditor.Highlight.highlight(content, language, theme)`
`[%ExRatatui.Text.Line{}]`:
- `:lua`, `:text` → pass straight through to
`ExRatatui.CodeBlock.highlight/3`.
- `:markdown_macros` → base highlight as `"markdown"`, then apply
`Overlay.markdown_macros/1` per source line.
- `:liquid` → base highlight as `"html"`, then apply
`Overlay.liquid/1` per source line.
- `BDS.UI.CodeEditor.Overlay`:
- `apply_ranges(line, ranges_with_styles)` — re-splits a `Line`'s
spans into a new span list, preserving the base styling outside
each range.
- `markdown_macro_ranges/1``~r/\[\[\s*[a-zA-Z][\w-]*[^]]*\]\]/s`.
- `macro_inner_ranges/1` — break `attr=` (attr-name) and quoted value
into separate styled sub-ranges inside a macro.
- `liquid_ranges/1``~r/(\{\{.*?\}\}|\{%-?\s*(\w+).*?-?%\})/s`,
with a sub-range for the tag keyword (`if`, `for`, `assign`, …).
- Cache results by `{content_hash, language, theme}` if profiling shows
re-tokenisation overhead on keystroke (Phase 2 stretch goal —
skip until a test says otherwise).
### Phase 3 — `lib/bds/ui/code_editor/widget.ex`
- `%BDS.UI.CodeEditor{}` struct fields: `textarea`, `language`,
`theme` (default `:base16_ocean_dark`, readable in dark terminals),
`wrap: true`, `cursor_style`, `line_highlight_style`, `block`, `scroll`.
- `defimpl ExRatatui.Widget` per the pipeline above.
- Cursor cell: re-style the single codepoint at `col` on source line
`row` into `cursor_style`. Paragraph wraps the same line; the styled
cell rides the wrap.
- Scroll math: count display rows by wrapping each source line into
`width` columns (Latin-width assumption — 1 char = 1 cell). Note the
limitation explicitly; CJK width is a follow-up if/when needed.
- Line highlight: apply `line_highlight_style` (a `bg:` colour) to
every span on the cursor's source line before cursor cut-in.
### Phase 4 — Wire into `lib/bds/tui.ex`
- `build_editor/4` (around `lib/bds/tui.ex:1968`): default
`preview? = false` for existing posts. New posts already open in
editor — unchanged.
- `body_widget/3` edit branch (`lib/bds/tui.ex:1466`): emit
`%BDS.UI.CodeEditor{}` instead of `%ExRatatui.Widgets.Textarea{}` for
entities that have a syntax highlighter (`:lua`, `:markdown_macros`,
`:liquid`). Plain text can keep `%Textarea{}` or feed `:text` through
the new widget — pick whichever keeps the test surface smaller.
- Remove the "wrap limitation" comment block at
`lib/bds/tui.ex:1451-1454`.
- `cycle_language/1` continues per post; cycle through the active
set (`:markdown_macros`, `:liquid`, `:lua`, `:text`) matching the
existing GUI editor language options.
- Preview branch (`body_widget/3` preview arm at `lib/bds/tui.ex:1455`)
stays on `ExRatatui.Widgets.Markdown` — unchanged.
- No new user-facing strings: the existing body title already flows
through `dgettext("ui", …)`. If a new toolbar/hint is added later,
it MUST go through gettext and provide de/fr/it/es translations.
### Phase 5 — Verify
- `mix test` (write to `/tmp/test_run.log` first, grep for failures —
per AGENTS.md).
- `mix credo --strict`
- `mix deps.audit --ignore-file .mix_audit.ignore`
- `mix dialyzer` — treat warnings as errors.
No bundle rebuild, no asset change, no NIF rebuild. Monaco still drives
the GUI editor; this change is TUI-only.
## Risks / limits
- **Per-frame NIF cost:** re-tokenises the whole buffer on every
keystroke. For typical post sizes (<50 KB) syntect is sub-ms. Add the
`{content_hash, language, theme}` cache only if a test shows it.
- **CJK cell width:** wrap math assumes 1 char = 1 cell. Fine for
de/fr/it/es. Note as TODO; not in scope for this phase.
- **Liquid mis-lexing inside HTML** is cosmetically possible (an HTML
attribute containing `{{`). Overlay wins after the base lexer; the
result is visually tolerable. A custom `.sublime-syntax` in the
dep would be the real fix — out of scope here per "stay on Elixir".
- **Cursor in a wrapped paragraph** rides the styled cell. Equivalent
to a cell-caret; GUI uses a real caret — acceptable TUI parity.
## Files touched (expected)
- `specs/tui.allium` — edited.
- `lib/bds/ui/code_editor/highlight.ex` — new.
- `lib/bds/ui/code_editor/overlay.ex` — new.
- `lib/bds/ui/code_editor/widget.ex` — new.
- `lib/bds/tui.ex` — edited (`build_editor`, `body_widget`, comment
removal, default `preview?`).
- `test/bds/ui/code_editor_test.exs` — new.
- `test/bds/tui_test.exs` — edited (flip the "opens in preview"
assertion, add wrap/cursor/current-line tests).
No changes to `mix.exs`, no new deps, no Rust, no assets.

View File

@@ -3,10 +3,12 @@
@source "../css"; @source "../css";
@source "../js"; @source "../js";
@source "../../lib/bds/desktop"; @source "../../lib/bds/desktop";
@source "../../deps/live_toast/lib";
@import "./tokens.css"; @import "./tokens.css";
@import "./shell.css"; @import "./shell.css";
@import "./sidebar.css"; @import "./sidebar.css";
@import "./git_sidebar.css";
@import "./tabs.css"; @import "./tabs.css";
@import "./editor.css"; @import "./editor.css";
@import "./forms.css"; @import "./forms.css";
@@ -16,4 +18,5 @@
@import "./menu_editor.css"; @import "./menu_editor.css";
@import "./media_editor.css"; @import "./media_editor.css";
@import "./import_editor.css"; @import "./import_editor.css";
@import "./misc_editor.css";
@import "./utilities.css"; @import "./utilities.css";

View File

@@ -86,10 +86,11 @@
.chat-message { .chat-message {
display: flex; display: flex;
max-width: 100%; max-width: 100%;
margin-bottom: 16px;
} }
.chat-message.user { .chat-message.user {
justify-content: flex-end; flex-direction: row-reverse;
} }
.chat-message-content { .chat-message-content {
@@ -102,10 +103,11 @@
} }
.chat-panel .chat-message.user .chat-message-content { .chat-panel .chat-message.user .chat-message-content {
background: transparent; background: var(--vscode-button-background, var(--accent-color, #007acc));
color: var(--vscode-list-activeSelectionForeground); color: var(--vscode-button-foreground, var(--vscode-list-activeSelectionForeground, #ffffff));
border: 0; border: 1px solid var(--vscode-button-background, var(--accent-color, #007acc));
padding: 6px 12px; border-radius: 6px;
padding: 12px 14px;
line-height: 1.35; line-height: 1.35;
} }
@@ -129,19 +131,482 @@
background: var(--vscode-textCodeBlock-background); 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-panel .chat-input-container {
--chat-input-line-height: 20px; --chat-input-line-height: 22px;
--chat-input-min-height: 20px; --chat-input-min-height: 24px;
border-top: 1px solid var(--vscode-panel-border); border-top: 1px solid var(--vscode-panel-border);
padding: 8px 16px; padding: 12px 16px;
background: var(--vscode-sideBar-background); background: var(--vscode-sideBar-background);
} }
.chat-panel .chat-input-wrapper { .chat-panel .chat-input-wrapper {
min-height: 30px; min-height: 40px;
border: 1px solid var(--vscode-input-border); border: 1px solid var(--vscode-input-border);
border-radius: 6px; border-radius: 8px;
padding: 4px 6px; padding: 6px 8px;
background: var(--vscode-input-background); background: var(--vscode-input-background);
} }
@@ -160,11 +625,16 @@
max-height: 160px; max-height: 160px;
resize: vertical; resize: vertical;
border: 0; border: 0;
outline: none;
background: transparent; background: transparent;
color: var(--vscode-input-foreground); color: var(--vscode-input-foreground);
overflow-y: hidden; overflow-y: hidden;
} }
.chat-panel .chat-input:focus {
outline: none;
}
.chat-panel .chat-input::placeholder { .chat-panel .chat-input::placeholder {
color: var(--vscode-input-placeholderForeground); color: var(--vscode-input-placeholderForeground);
} }
@@ -176,12 +646,6 @@
max-width: 22px; max-width: 22px;
max-height: 22px; max-height: 22px;
padding: 0; padding: 0;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.chat-panel .chat-send-button:hover:not(:disabled) {
background: var(--vscode-button-hoverBackground);
} }
.chat-panel .chat-send-button:disabled { .chat-panel .chat-send-button:disabled {
@@ -221,3 +685,88 @@
padding: 8px 12px; 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;
}

View File

@@ -14,8 +14,7 @@
.editor-main, .editor-main,
.editor-meta, .editor-meta,
.panel-shell, .panel-shell {
.assistant-card {
min-width: 0; min-width: 0;
} }
@@ -50,8 +49,7 @@
border-radius: 3px; border-radius: 3px;
} }
.editor-toolbar-button:hover, .editor-toolbar-button:hover {
.panel-tab:hover {
background: var(--vscode-toolbar-hoverBackground); background: var(--vscode-toolbar-hoverBackground);
} }
@@ -617,6 +615,14 @@
white-space: nowrap; white-space: nowrap;
} }
.post-editor .tag-suggestion-section-label {
padding: 4px 12px;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--vscode-descriptionForeground, #a6a6a6);
}
.post-editor .tag-suggestion.create-new { .post-editor .tag-suggestion.create-new {
border-top: 1px solid var(--vscode-widget-border, #454545); border-top: 1px solid var(--vscode-widget-border, #454545);
margin-top: 4px; margin-top: 4px;
@@ -742,8 +748,7 @@
} }
.post-editor .editor-toolbar { .post-editor .editor-toolbar {
display: grid; display: flex;
grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 8px; margin-bottom: 8px;
@@ -751,23 +756,27 @@
.post-editor .editor-toolbar-left { .post-editor .editor-toolbar-left {
display: flex; display: flex;
flex: 1;
min-width: 0;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
} }
.post-editor .editor-toolbar-center { .post-editor .editor-toolbar-center {
display: flex; display: flex;
flex: none;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
/* ponytail: right group keeps its content width so buttons never wrap or
overlap the mode toggle; the left label shrinks instead */
.post-editor .editor-toolbar-right { .post-editor .editor-toolbar-right {
display: flex; display: flex;
flex: 1 0 auto;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
gap: 6px; gap: 6px;
min-width: 0;
flex-wrap: wrap;
} }
.post-editor .editor-mode-toggle { .post-editor .editor-mode-toggle {
@@ -809,38 +818,6 @@
display: none; display: none;
} }
.post-editor .gallery-button {
padding: 4px 12px;
font-size: 12px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .gallery-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.post-editor .insert-post-link-button,
.post-editor .insert-media-button {
padding: 4px 8px;
font-size: 14px;
border-radius: 4px;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
cursor: pointer;
transition: background-color 0.15s;
}
.post-editor .insert-post-link-button:hover,
.post-editor .insert-media-button:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.post-editor .editor-preview { .post-editor .editor-preview {
flex: 1; flex: 1;
background-color: var(--vscode-input-background); background-color: var(--vscode-input-background);
@@ -929,8 +906,7 @@
.post-editor .editor-header, .post-editor .editor-header,
.scripts-view-shell .ui-editor-header, .scripts-view-shell .ui-editor-header,
.templates-view-shell .ui-editor-header, .templates-view-shell .ui-editor-header,
.post-editor .metadata-toggle-header, .post-editor .metadata-toggle-header {
.post-editor .editor-toolbar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start; align-items: flex-start;
@@ -946,7 +922,6 @@
width: 100%; width: 100%;
} }
.post-editor .editor-toolbar-right,
.post-editor .ui-editor-actions, .post-editor .ui-editor-actions,
.scripts-view-shell .ui-editor-actions, .scripts-view-shell .ui-editor-actions,
.templates-view-shell .ui-editor-actions { .templates-view-shell .ui-editor-actions {

294
assets/css/git_sidebar.css Normal file
View 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);
}

View File

@@ -67,30 +67,12 @@
color: var(--vscode-descriptionForeground); color: var(--vscode-descriptionForeground);
} }
.import-analysis button,
.import-analysis select { .import-analysis select {
border: 1px solid var(--vscode-button-border, transparent); border: 1px solid var(--vscode-button-border, transparent);
border-radius: 6px; border-radius: 6px;
font-size: 12px; font-size: 12px;
} }
.import-analysis button {
background: var(--vscode-button-secondaryBackground, var(--vscode-button-background));
color: var(--vscode-button-secondaryForeground, var(--vscode-button-foreground));
padding: 8px 12px;
cursor: pointer;
}
.import-analysis button:hover:not(:disabled) {
background: var(--vscode-button-secondaryHoverBackground, var(--vscode-button-hoverBackground));
}
.import-analyze-btn,
.import-execute-btn {
background: var(--vscode-button-background) !important;
color: var(--vscode-button-foreground) !important;
}
.import-analysis button:disabled { .import-analysis button:disabled {
opacity: 0.65; opacity: 0.65;
cursor: not-allowed; cursor: not-allowed;
@@ -110,7 +92,7 @@
width: 18px; width: 18px;
height: 18px; height: 18px;
border: 2px solid var(--vscode-descriptionForeground); border: 2px solid var(--vscode-descriptionForeground);
border-top-color: var(--vscode-button-background); border-top-color: var(--vscode-button-background, #0e639c);
border-radius: 50%; border-radius: 50%;
animation: import-spinner-rotate 0.8s linear infinite; animation: import-spinner-rotate 0.8s linear infinite;
flex-shrink: 0; flex-shrink: 0;

View File

@@ -106,8 +106,8 @@
} }
[data-testid="media-editor"] .media-preview-image img { [data-testid="media-editor"] .media-preview-image img {
width: 100%; max-width: 100%;
height: 100%; max-height: 100%;
object-fit: contain; object-fit: contain;
border-radius: 4px; border-radius: 4px;
} }
@@ -128,20 +128,6 @@
align-items: center; align-items: center;
} }
[data-testid="media-editor"] .add-link-btn {
background: var(--vscode-button-secondaryBackground);
border: none;
color: var(--vscode-button-secondaryForeground);
padding: 2px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
}
[data-testid="media-editor"] .add-link-btn:hover {
background: var(--vscode-button-secondaryHoverBackground);
}
[data-testid="media-editor"] .post-picker { [data-testid="media-editor"] .post-picker {
background: var(--vscode-dropdown-background); background: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-dropdown-border); border: 1px solid var(--vscode-dropdown-border);

544
assets/css/misc_editor.css Normal file
View File

@@ -0,0 +1,544 @@
/* ── 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;
}
.monaco-diff-editor-instance {
width: 100%;
height: 100%;
}

View File

@@ -19,6 +19,7 @@
.language-picker-modal-backdrop, .language-picker-modal-backdrop,
.confirm-delete-modal-backdrop, .confirm-delete-modal-backdrop,
.confirm-dialog-overlay, .confirm-dialog-overlay,
.git-run-backdrop,
.gallery-overlay, .gallery-overlay,
.lightbox-overlay { .lightbox-overlay {
position: fixed; position: fixed;
@@ -35,13 +36,86 @@
.language-picker-modal, .language-picker-modal,
.confirm-delete-modal, .confirm-delete-modal,
.confirm-dialog, .confirm-dialog,
.git-run-modal,
.gallery-overlay-content { .gallery-overlay-content {
position: relative;
z-index: 1;
background: #1e1e1e; background: #1e1e1e;
border: 1px solid #3c3c3c; border: 1px solid #3c3c3c;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
} }
.git-run-modal {
width: min(760px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.git-run-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid #3c3c3c;
}
.git-run-title {
margin: 0;
font-size: 14px;
font-family: var(--font-mono, monospace);
}
.git-run-status {
font-size: 12px;
font-weight: 600;
}
.git-run-status.running {
color: #d7ba7d;
}
.git-run-status.ok {
color: #4ec9b0;
}
.git-run-status.fail {
color: #f48771;
}
.git-run-output {
margin: 0;
padding: 12px 16px;
overflow: auto;
flex: 1;
min-height: 160px;
max-height: 60vh;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono, monospace);
font-size: 12px;
line-height: 1.5;
background: #141414;
}
.git-run-stdout {
color: #d4d4d4;
}
.git-run-stderr {
color: #f48771;
}
.git-run-footer {
display: flex;
justify-content: flex-end;
padding: 12px 16px;
border-top: 1px solid #3c3c3c;
}
.ai-suggestions-modal, .ai-suggestions-modal,
.language-picker-modal, .language-picker-modal,
.confirm-delete-modal, .confirm-delete-modal,
@@ -255,8 +329,7 @@
} }
.ai-suggestions-modal-footer, .ai-suggestions-modal-footer,
.confirm-delete-modal-footer, .confirm-delete-modal-footer {
.confirm-dialog-actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: 10px; gap: 10px;
@@ -264,42 +337,12 @@
border-top: 1px solid #3c3c3c; border-top: 1px solid #3c3c3c;
} }
.button-cancel,
.button-delete,
.button-apply,
.confirm-dialog-actions button,
.insert-modal-submit,
.language-picker-row, .language-picker-row,
.shared-popover-entry, .shared-popover-entry,
.colour-swatch { .colour-swatch {
cursor: pointer; cursor: pointer;
} }
.button-cancel,
.confirm-dialog-actions button,
.insert-modal-submit {
border: 1px solid #4c4c4c;
border-radius: 4px;
padding: 8px 14px;
background: transparent;
color: #f0f0f0;
}
.button-apply,
.confirm-dialog-actions .primary,
.insert-modal-submit {
background: #0e639c;
border-color: #0e639c;
}
.button-delete {
border: none;
border-radius: 4px;
padding: 8px 14px;
background: #c73c3c;
color: #ffffff;
}
.insert-modal-tabs { .insert-modal-tabs {
display: flex; display: flex;
margin: 0 -20px; margin: 0 -20px;
@@ -334,3 +377,78 @@
font: inherit; font: inherit;
} }
.insert-modal-results {
overflow: auto;
padding: 8px 0;
}
.insert-modal-result-item {
width: 100%;
display: flex;
flex-direction: column;
gap: 2px;
border: none;
background: transparent;
color: inherit;
padding: 10px 20px;
text-align: left;
cursor: pointer;
font: inherit;
}
.insert-modal-result-item:hover {
background: #252526;
}
.insert-modal-result-title {
color: #ffffff;
font-weight: 600;
}
.insert-modal-result-meta {
color: #9d9d9d;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.insert-modal-status {
padding: 16px 20px;
color: #9d9d9d;
}
.insert-modal-external {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 20px;
}
.insert-modal-external .insert-modal-input {
padding: 8px 10px;
border: 1px solid #3c3c3c;
border-radius: 4px;
background: #252526;
}
.insert-modal-field {
display: flex;
flex-direction: column;
gap: 4px;
}
.insert-modal-label {
color: #9d9d9d;
font-size: 12px;
}
.insert-modal-submit {
align-self: flex-end;
}
/* Alert modal (errors) — reuses .confirm-delete-modal with a tone accent. */
.alert-modal-error {
border-top: 3px solid #ff6b6b;
}

View File

@@ -8,14 +8,11 @@
display: none; display: none;
} }
.panel-tabs {
display: flex;
gap: 8px;
}
.panel-tabs { .panel-tabs {
display: flex; display: flex;
gap: 2px; gap: 2px;
align-items: stretch;
height: 100%;
} }
.panel-tab { .panel-tab {
@@ -116,7 +113,6 @@
} }
.assistant-sidebar-prompt-form, .assistant-sidebar-prompt-form,
.assistant-sidebar-welcome,
.assistant-sidebar-transcript { .assistant-sidebar-transcript {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -142,12 +138,6 @@
.assistant-sidebar-start-button { .assistant-sidebar-start-button {
align-self: flex-start; align-self: flex-start;
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 999px;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
padding: 7px 14px;
cursor: pointer;
} }
.assistant-sidebar-start-button:disabled { .assistant-sidebar-start-button:disabled {
@@ -155,7 +145,6 @@
opacity: 0.55; opacity: 0.55;
} }
.assistant-card,
.assistant-sidebar-message { .assistant-sidebar-message {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -254,6 +243,12 @@
background-color: var(--vscode-sideBar-background); background-color: var(--vscode-sideBar-background);
} }
/* .panel-entry (shell.css) forces flex-direction: column and, being unlayered,
beats the Tailwind row utilities on this button — force the row here. */
.task-group-toggle {
flex-direction: row;
}
.output-entry { .output-entry {
padding: 8px; padding: 8px;
border-bottom: none; border-bottom: none;

View File

@@ -402,24 +402,6 @@
border-bottom: 1px solid var(--vscode-panel-border); border-bottom: 1px solid var(--vscode-panel-border);
} }
.panel-tabs {
display: flex;
align-items: stretch;
height: 100%;
}
.panel-tab {
border: none;
background: transparent;
color: var(--vscode-descriptionForeground);
padding: 0 12px;
cursor: pointer;
}
.panel-tab.active {
color: var(--vscode-tab-activeForeground);
}
.panel-close { .panel-close {
background: transparent; background: transparent;
border: none; border: none;
@@ -446,8 +428,7 @@
padding: 12px 14px; padding: 12px 14px;
} }
.panel-entry, .panel-entry {
.assistant-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
@@ -579,6 +560,13 @@
cursor: pointer; cursor: pointer;
} }
.task-message-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.status-bar-item.theme-badge { .status-bar-item.theme-badge {
border: 1px solid rgba(255, 255, 255, 0.18); border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 3px; border-radius: 3px;
@@ -595,14 +583,16 @@
background: transparent; background: transparent;
color: inherit; color: inherit;
cursor: pointer; cursor: pointer;
opacity: 0.4;
font-size: 13px; font-size: 13px;
padding: 0 4px; padding: 0 4px;
} }
.status-bar-item.offline-badge.active { .status-bar-item.offline-badge.active {
background-color: rgba(255, 196, 0, 0.28); background-color: #e6a800;
color: #000;
font-weight: 600;
opacity: 1; opacity: 1;
border-radius: 3px;
} }
.project-selector { .project-selector {
@@ -770,19 +760,6 @@
padding-left: 18px; padding-left: 18px;
} }
.editor-toolbar {
display: flex;
gap: 10px;
}
.editor-toolbar button {
padding: 9px 14px;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-3);
color: var(--ink);
}
.editor-meta { .editor-meta {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -790,18 +767,7 @@
} }
.editor-meta-card, .editor-meta-card,
.assistant-card,
.panel-entry { .panel-entry {
padding: 16px; padding: 16px;
} }
.sidebar-header,
.assistant-header,
.panel-header {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--line);
}

View File

@@ -148,7 +148,6 @@
gap: 2px; gap: 2px;
} }
.assistant-card span,
.panel-entry span, .panel-entry span,
.editor-meta-row span, .editor-meta-row span,
.editor-subtitle, .editor-subtitle,
@@ -777,8 +776,8 @@
} }
.filter-chip.active { .filter-chip.active {
background-color: var(--vscode-button-background); background-color: var(--vscode-button-background, #0e639c);
color: var(--vscode-button-foreground); color: var(--vscode-button-foreground, #ffffff);
} }
.filter-chip.has-color { .filter-chip.has-color {

View File

@@ -37,6 +37,9 @@
--vscode-editorGroupHeader-tabsBorder: #1e1e1e; --vscode-editorGroupHeader-tabsBorder: #1e1e1e;
--vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31); --vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31);
--vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31); --vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31);
--vscode-button-secondaryBackground: rgba(255, 255, 255, 0.08);
--vscode-button-secondaryForeground: #cccccc;
--vscode-button-secondaryHoverBackground: #4a4d51;
--vscode-foreground: #cccccc; --vscode-foreground: #cccccc;
--vscode-descriptionForeground: #858585; --vscode-descriptionForeground: #858585;
--vscode-panel-border: #80808059; --vscode-panel-border: #80808059;
@@ -130,12 +133,13 @@ button.compact {
} }
button.primary { button.primary {
background-color: var(--vscode-button-background); color: var(--vscode-button-foreground, #ffffff);
background-color: var(--vscode-button-background, #0e639c);
font-weight: 500; font-weight: 500;
} }
button.primary:hover { button.primary:hover {
background-color: var(--vscode-button-hoverBackground); background-color: var(--vscode-button-hoverBackground, #1177bb);
} }
button.success { button.success {

View File

@@ -19,6 +19,7 @@ import {
} from "../utils/shortcuts.js"; } from "../utils/shortcuts.js";
import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js"; import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js";
import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js"; import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js";
import { copyTextToClipboard } from "../utils/clipboard.js";
export const AppShell = { export const AppShell = {
mounted() { mounted() {
@@ -173,6 +174,16 @@ export const AppShell = {
} }
}); });
this.handleEvent("url-state", ({ path }) => {
if (path && window.location.pathname + window.location.search !== path) {
window.history.replaceState({}, "", path);
}
});
this.handleEvent("clipboard", ({ text }) => {
copyTextToClipboard(text || "");
});
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction); window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
window.addEventListener("keydown", this.handleShortcutKeyDown, true); window.addEventListener("keydown", this.handleShortcutKeyDown, true);
this.el.addEventListener("load", this.handleThumbnailLoad, true); this.el.addEventListener("load", this.handleThumbnailLoad, true);
@@ -180,6 +191,9 @@ export const AppShell = {
this.el.addEventListener("change", this.handleChange); this.el.addEventListener("change", this.handleChange);
syncMediaThumbnailState(this.el); syncMediaThumbnailState(this.el);
this.restoreStoredWorkbenchSession(); this.restoreStoredWorkbenchSession();
// Rehydration is done (any stored-session push is ordered before this):
// tell the server it may now deliver queued deep links.
this.pushEvent("shell_ready", {});
}, },
updated() { updated() {

View File

@@ -3,6 +3,9 @@ export const ChatSurface = {
this.stickToBottom = true; this.stickToBottom = true;
this.scrollContainer = null; this.scrollContainer = null;
this._enterKeyHandled = false;
this._prevInputValue = "";
this.autoResize = () => { this.autoResize = () => {
const textarea = this.el.querySelector(".chat-input"); const textarea = this.el.querySelector(".chat-input");
@@ -85,11 +88,34 @@ export const ChatSurface = {
this.stickToBottom = distanceFromBottom < 48; 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) => { this.handleInput = (event) => {
if (!event.target.closest(".chat-input")) { if (!event.target.closest(".chat-input")) {
return; 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.stickToBottom = true;
this.autoResize(); this.autoResize();
}; };
@@ -101,12 +127,8 @@ export const ChatSurface = {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) { if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault(); event.preventDefault();
this._enterKeyHandled = true;
const sendButton = this.el.querySelector("[data-testid='chat-send-button']"); this._submitChat();
if (sendButton && !sendButton.disabled) {
sendButton.click();
}
} }
}; };

View 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);
}
};

View File

@@ -2,16 +2,20 @@ import { AppShell } from "./app_shell.js";
import { SidebarInteractions } from "./sidebar_interactions.js"; import { SidebarInteractions } from "./sidebar_interactions.js";
import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js"; import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js";
import { ChatSurface } from "./chat_surface.js"; import { ChatSurface } from "./chat_surface.js";
import { ColourPicker } from "./colour_picker.js";
import { MenuEditorTree } from "./menu_editor_tree.js"; import { MenuEditorTree } from "./menu_editor_tree.js";
import { MonacoEditor } from "./monaco_editor.js"; import { MonacoEditor } from "./monaco_editor.js";
import { MonacoDiffEditor } from "./monaco_diff_editor.js"; import { MonacoDiffEditor } from "./monaco_diff_editor.js";
import { createLiveToastHook } from "live_toast";
export const Hooks = { export const Hooks = {
LiveToast: createLiveToastHook(),
AppShell, AppShell,
SidebarInteractions, SidebarInteractions,
SettingsSectionScroll, SettingsSectionScroll,
TagsSectionScroll, TagsSectionScroll,
ChatSurface, ChatSurface,
ColourPicker,
MenuEditorTree, MenuEditorTree,
MonacoEditor, MonacoEditor,
MonacoDiffEditor MonacoDiffEditor

View File

@@ -5,6 +5,66 @@ import {
unregisterMonacoEditor unregisterMonacoEditor
} from "../monaco/services.js"; } from "../monaco/services.js";
const WEBKIT_TEXTAREA_ARROW_COMMANDS = {
ArrowLeft: "cursorLeft",
ArrowRight: "cursorRight",
ArrowUp: "cursorUp",
ArrowDown: "cursorDown"
};
const isWebKitEngine = () => {
const userAgent = globalThis.navigator?.userAgent || "";
return userAgent.includes("AppleWebKit") && !userAgent.includes("Chrome") && !userAgent.includes("Chromium");
};
const isMonacoInputArea = (target) =>
target instanceof HTMLTextAreaElement && target.classList.contains("inputarea");
export const webKitTextAreaArrowCommand = (event) => {
if (event?.altKey || event?.ctrlKey || event?.metaKey) {
return null;
}
const command = WEBKIT_TEXTAREA_ARROW_COMMANDS[event?.key];
if (!command) {
return null;
}
return event.shiftKey ? `${command}Select` : command;
};
// Applying a remote value via setValue resets the cursor to the buffer start,
// which then makes follow-up actions (like link inserts targeting the current
// selection) land at position 1,1. Capture and restore the selection so the
// cursor survives server-driven reconciles; Monaco clamps out-of-range
// positions to the new content.
export const applyRemoteEditorValue = (editor, value) => {
const selections = editor.getSelections ? editor.getSelections() : null;
editor.setValue(value);
if (selections && selections.length > 0 && editor.setSelections) {
editor.setSelections(selections);
}
};
export const bridgeWebKitTextAreaArrowKey = (editor, event) => {
if (!editor || !isMonacoInputArea(event.target)) {
return false;
}
const command = webKitTextAreaArrowCommand(event);
if (!command) {
return false;
}
event.preventDefault();
event.stopPropagation();
editor.trigger("keyboard", command, { source: "keyboard" });
return true;
};
export const MonacoEditor = { export const MonacoEditor = {
mounted() { mounted() {
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
@@ -17,6 +77,10 @@ export const MonacoEditor = {
this.isApplyingRemoteUpdate = false; this.isApplyingRemoteUpdate = false;
this.lastKnownValue = this.textarea?.value || ""; this.lastKnownValue = this.textarea?.value || "";
this.handleWebKitTextAreaArrowKey = (event) => {
bridgeWebKitTextAreaArrowKey(this.editor, event);
};
this.syncEditorFromTextarea = () => { this.syncEditorFromTextarea = () => {
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea"); this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
@@ -26,10 +90,28 @@ export const MonacoEditor = {
const value = this.textarea.value || ""; const value = this.textarea.value || "";
// ponytail: ignore the server echoing back our own last-emitted value;
// only a genuinely remote value (translation switch, etc.) should reset
// the editor. Otherwise mid-typing echoes clobber the cursor to offset 0.
if (value === this.lastKnownValue) {
return;
}
// While the user is actively typing, Monaco owns the content. Never let a
// server re-render (autosave, dirty toggle, metadata) yank it out from
// under the cursor — reconcile only once the editor is blurred.
if (this.editor.hasTextFocus()) {
return;
}
if (this.editor.getValue() !== value) { if (this.editor.getValue() !== value) {
this.isApplyingRemoteUpdate = true; this.isApplyingRemoteUpdate = true;
this.editor.setValue(value);
this.isApplyingRemoteUpdate = false; try {
applyRemoteEditorValue(this.editor, value);
} finally {
this.isApplyingRemoteUpdate = false;
}
} }
this.lastKnownValue = value; this.lastKnownValue = value;
@@ -118,6 +200,36 @@ export const MonacoEditor = {
}, 120); }, 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 }) => { this.handleInsert = ({ id, content }) => {
if (!this.editor || !content || String(id) !== String(this.editorId)) { if (!this.editor || !content || String(id) !== String(this.editorId)) {
return; return;
@@ -197,6 +309,15 @@ export const MonacoEditor = {
if (this.insertEvent) { if (this.insertEvent) {
this.handleEvent(this.insertEvent, this.handleInsert); this.handleEvent(this.insertEvent, this.handleInsert);
} }
if (this.dropEvent) {
this.el.addEventListener("dragover", this.handleDragOver);
this.el.addEventListener("drop", this.handleDrop);
}
if (isWebKitEngine()) {
this.host.addEventListener("keydown", this.handleWebKitTextAreaArrowKey, true);
}
}) })
.catch((error) => { .catch((error) => {
console.error("Failed to load Monaco editor", error); console.error("Failed to load Monaco editor", error);
@@ -232,6 +353,14 @@ export const MonacoEditor = {
window.clearTimeout(this.syncTimer); window.clearTimeout(this.syncTimer);
this.visibleSizeObserver?.disconnect(); this.visibleSizeObserver?.disconnect();
this.changeSubscription?.dispose(); this.changeSubscription?.dispose();
if (this.dropEvent) {
this.el.removeEventListener("dragover", this.handleDragOver);
this.el.removeEventListener("drop", this.handleDrop);
}
this.host?.removeEventListener("keydown", this.handleWebKitTextAreaArrowKey, true);
unregisterMonacoEditor(this.editorId || this.el.id); unregisterMonacoEditor(this.editorId || this.el.id);
this.editor?.dispose(); this.editor?.dispose();
} }

View File

@@ -1,11 +1,54 @@
import { loadScript } from "../utils/script_loader.js";
import { ensureMonacoTheme } from "./theme.js"; import { ensureMonacoTheme } from "./theme.js";
import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./languages.js"; import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./languages.js";
let monacoLoaderPromise; let monacoLoaderPromise;
const monacoEditors = new Map(); const monacoEditors = new Map();
const monacoWorkerUrls = {
editor: "/assets/monaco/editor.worker.js",
css: "/assets/monaco/css.worker.js",
html: "/assets/monaco/html.worker.js",
json: "/assets/monaco/json.worker.js",
ts: "/assets/monaco/ts.worker.js"
};
const workerNameForLanguage = (label) => {
switch (label) {
case "css":
case "scss":
case "less":
return "css";
case "html":
case "handlebars":
case "razor":
return "html";
case "json":
return "json";
case "typescript":
case "javascript":
return "ts";
default:
return "editor";
}
};
const ensureMonacoEnvironment = () => {
if (globalThis.MonacoEnvironment?.getWorker) {
return;
}
globalThis.MonacoEnvironment = {
...globalThis.MonacoEnvironment,
getWorker(_workerId, label) {
const workerName = workerNameForLanguage(label);
return new Worker(monacoWorkerUrls[workerName], { name: label, type: "module" });
}
};
};
export const loadMonaco = () => { export const loadMonaco = () => {
ensureMonacoEnvironment();
if (window.monaco?.editor) { if (window.monaco?.editor) {
ensureMonacoTheme(window.monaco); ensureMonacoTheme(window.monaco);
registerLiquidLanguage(window.monaco); registerLiquidLanguage(window.monaco);
@@ -17,20 +60,17 @@ export const loadMonaco = () => {
return monacoLoaderPromise; return monacoLoaderPromise;
} }
monacoLoaderPromise = loadScript("/monaco/vs/loader.js") monacoLoaderPromise = import("/assets/monaco.js")
.then( .then((monaco) => {
() => window.monaco = monaco;
new Promise((resolve, reject) => { if (!monaco.editor) throw new Error("Monaco loaded but monaco.editor is missing");
window.require.config({ paths: { vs: "/monaco/vs" } }); ensureMonacoTheme(monaco);
window.require(["vs/editor/editor.main"], () => { registerLiquidLanguage(monaco);
ensureMonacoTheme(window.monaco); registerMarkdownWithMacrosLanguage(monaco);
registerLiquidLanguage(window.monaco); return monaco;
registerMarkdownWithMacrosLanguage(window.monaco); })
resolve(window.monaco);
}, reject);
})
)
.catch((error) => { .catch((error) => {
console.error("Monaco load failed:", error);
monacoLoaderPromise = null; monacoLoaderPromise = null;
throw error; throw error;
}); });

View File

@@ -0,0 +1,3 @@
// ESM entry point for Monaco. Bundled by esbuild into priv/static/assets/monaco.js
// editor.main.js loads the API AND registers all bundled languages (api.js only exposes the API)
export * from "monaco-editor/esm/vs/editor/editor.main.js";

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/language/css/css.worker.js";

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/editor/editor.worker.js";

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/language/html/html.worker.js";

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/language/json/json.worker.js";

View File

@@ -0,0 +1 @@
import "monaco-editor/esm/vs/language/typescript/ts.worker.js";

View File

@@ -0,0 +1,27 @@
// Copies text to the system clipboard, mirroring the old app's behavior:
// navigator.clipboard when available, with a hidden-textarea fallback for
// webviews that lack the async clipboard API.
export const copyTextToClipboard = async (text) => {
if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
await navigator.clipboard.writeText(text);
return;
}
if (typeof document === "undefined" || typeof document.createElement !== "function") {
return;
}
const textArea = document.createElement("textarea");
textArea.value = text;
textArea.setAttribute("readonly", "");
textArea.style.position = "absolute";
textArea.style.left = "-9999px";
document.body.appendChild(textArea);
textArea.select();
if (typeof document.execCommand === "function") {
document.execCommand("copy");
}
document.body.removeChild(textArea);
};

View File

@@ -1,33 +0,0 @@
export const loadScript = (src) =>
new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${src}"]`);
if (existing) {
if (existing.dataset.loaded === "true") {
resolve();
return;
}
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
once: true
});
return;
}
const script = document.createElement("script");
script.src = src;
script.async = true;
script.addEventListener(
"load",
() => {
script.dataset.loaded = "true";
resolve();
},
{ once: true }
);
script.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), {
once: true
});
document.head.appendChild(script);
});

View File

@@ -4,8 +4,11 @@ config :bds,
ecto_repos: [BDS.Repo] ecto_repos: [BDS.Repo]
config :bds, 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, pool_size: 5,
journal_mode: :wal,
busy_timeout: 15_000, busy_timeout: 15_000,
log: false, log: false,
stacktrace: true, stacktrace: true,
@@ -13,15 +16,22 @@ config :bds, BDS.Repo,
config :bds, BDS.Application, desktop_adapter: :desktop 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, config :bds, :desktop,
port: 4010, port: 4010,
window_size: {1280, 780}, window_size: {1280, 780},
window_min_size: {800, 600}, window_min_size: {800, 600},
title: "Blogging Desktop Server", title: "Blogging Desktop Server"
secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001"
config :bds, :server, ssh_port: 2222
config :bds, BDS.Desktop.Endpoint, config :bds, BDS.Desktop.Endpoint,
url: [host: "127.0.0.1"], url: [host: "127.0.0.1"],
# Any-port loopback origins: the remote-GUI SSH tunnel terminates on an
# ephemeral local port, so the websocket origin port varies per connect.
check_origin: ["//127.0.0.1", "//localhost"],
adapter: Bandit.PhoenixAdapter, adapter: Bandit.PhoenixAdapter,
render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false], render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false],
pubsub_server: BDS.PubSub, pubsub_server: BDS.PubSub,
@@ -50,6 +60,34 @@ config :esbuild,
--external:/images/* --external:/images/*
), ),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)} env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
],
monaco: [
cd: Path.expand("../assets", __DIR__),
args: ~w(
js/monaco_entry.js
--bundle
--target=es2022
--format=esm
--outfile=../priv/static/assets/monaco.js
--loader:.ttf=dataurl
),
env: %{"NODE_PATH" => Path.expand("../node_modules", __DIR__)}
],
monaco_workers: [
cd: Path.expand("../assets", __DIR__),
args: ~w(
js/monaco_workers/editor.worker.js
js/monaco_workers/css.worker.js
js/monaco_workers/html.worker.js
js/monaco_workers/json.worker.js
js/monaco_workers/ts.worker.js
--bundle
--target=es2022
--format=esm
--outdir=../priv/static/assets/monaco
--loader:.ttf=dataurl
),
env: %{"NODE_PATH" => Path.expand("../node_modules", __DIR__)}
] ]
config :bds, :scripting, config :bds, :scripting,
@@ -57,12 +95,44 @@ config :bds, :scripting,
timeout: 300_000, timeout: 300_000,
max_reductions: 5_000_000, max_reductions: 5_000_000,
job_timeout: :infinity, 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, config :bds, :embeddings,
backend: BDS.Embeddings.Backends.InApp, backend: BDS.Embeddings.Backends.Neural,
model_id: "Xenova/multilingual-e5-small", 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, config :logger, :console,
format: "$time $metadata[$level] $message\n", format: "$time $metadata[$level] $message\n",

View File

@@ -1,7 +1,6 @@
import Config import Config
config :bds, BDS.Repo, config :bds, BDS.Repo,
database: Path.expand("../priv/data/bds_prod.db", __DIR__),
pool_size: 5, pool_size: 5,
stacktrace: false, stacktrace: false,
show_sensitive_data_on_connection_error: false show_sensitive_data_on_connection_error: false

View File

@@ -1,11 +1,30 @@
import Config import Config
# Headless boots (BDS_MODE=server/tui, or desktop mode without a graphical
# display — issue #33) must neuter wx before the :desktop dependency
# application starts it and crashes the VM. Local tui mode additionally
# silences all other terminal writers (console logging goes to a file,
# model-download progress bars are disabled) — the TUI owns the terminal
# and every stray line scrolls its rendering up one row. Runtime config is
# the only hook that runs before dependency applications start.
BDS.Server.prepare_boot_env()
if config_env() == :prod do if config_env() == :prod do
database_path = database_path =
System.get_env("BDS_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, config :bds, BDS.Repo,
database: database_path, 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 end

View File

@@ -4,6 +4,24 @@ config :bds, BDS.Repo,
database: Path.expand("../priv/data/bds_test.db", __DIR__), database: Path.expand("../priv/data/bds_test.db", __DIR__),
pool: Ecto.Adapters.SQL.Sandbox, pool: Ecto.Adapters.SQL.Sandbox,
pool_size: 5, pool_size: 5,
busy_timeout: 15_000 journal_mode: :wal,
# Desktop/LiveView tests can leave short-lived reader/writer processes
# draining after assertions. Give SQLite more time to wait out those locks
# during serialized suite runs instead of failing the next test setup.
busy_timeout: 60_000
config :logger, level: :warning 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

View File

@@ -1,5 +1,16 @@
defmodule BDS.AI do defmodule BDS.AI do
@moduledoc false @moduledoc """
Stable public interface for AI features.
This facade owns the app-level AI state that other modules are expected to
depend on directly: endpoint configuration, secret management, airplane-mode
gating, and per-feature model preferences, alongside model catalog access and
dispatching chat and one-shot inference requests.
`BDS.AI.Catalog`, `BDS.AI.OneShot`, and `BDS.AI.Chat` are internal
implementation modules behind this boundary. Callers should treat `BDS.AI` as
the durable surface for AI behavior and configuration.
"""
alias BDS.AI.Catalog alias BDS.AI.Catalog
alias BDS.AI.Chat alias BDS.AI.Chat
@@ -43,13 +54,21 @@ defmodule BDS.AI do
model = MapUtils.attr(attrs, :model) model = MapUtils.attr(attrs, :model)
api_key = MapUtils.attr(attrs, :api_key) api_key = MapUtils.attr(attrs, :api_key)
with :ok <- put_setting("ai.#{kind_key}.url", url), with :ok <- put_or_delete_setting("ai.#{kind_key}.url", url),
:ok <- put_setting("ai.#{kind_key}.model", model), :ok <- put_or_delete_setting("ai.#{kind_key}.model", model),
:ok <- put_secret("ai.#{kind_key}.api_key", api_key, backend) do :ok <- put_or_delete_secret("ai.#{kind_key}.api_key", api_key, backend) do
{:ok, %{kind: kind, url: url, api_key: api_key, model: model}} {:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
end end
end end
# A nil field means "clear it" — writing nil into put_setting/2 would raise a
# FunctionClauseError and crash the caller (the preferences panel).
defp put_or_delete_setting(key, nil), do: delete_setting(key)
defp put_or_delete_setting(key, value) when is_binary(value), do: put_setting(key, value)
defp put_or_delete_secret(key, nil, _backend), do: delete_setting(encrypted_key(key))
defp put_or_delete_secret(key, value, backend), do: put_secret(key, value, backend)
@spec get_endpoint(endpoint_kind(), keyword()) :: @spec get_endpoint(endpoint_kind(), keyword()) ::
{:ok, endpoint() | nil} | {:error, term()} {:ok, endpoint() | nil} | {:error, term()}
def get_endpoint(kind, opts \\ []) when is_atom(kind) and is_list(opts) do def get_endpoint(kind, opts \\ []) when is_atom(kind) and is_list(opts) do
@@ -59,14 +78,12 @@ defmodule BDS.AI do
model = get_setting("ai.#{kind_key}.model") model = get_setting("ai.#{kind_key}.model")
encrypted_api_key = get_setting(encrypted_key("ai.#{kind_key}.api_key")) encrypted_api_key = get_setting(encrypted_key("ai.#{kind_key}.api_key"))
cond do if is_nil(url) and is_nil(model) and is_nil(encrypted_api_key) do
is_nil(url) and is_nil(model) and is_nil(encrypted_api_key) -> {:ok, nil}
{:ok, nil} else
with {:ok, api_key} <- get_secret(encrypted_api_key, backend) do
true -> {:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
with {:ok, api_key} <- get_secret(encrypted_api_key, backend) do end
{:ok, %{kind: kind, url: url, api_key: api_key, model: model}}
end
end end
end end
@@ -110,6 +127,19 @@ defmodule BDS.AI do
end end
end end
@doc """
True when the airplane (local) endpoint has both a URL and a model
configured, so gated AI features can run against the local model.
"""
@spec airplane_endpoint_configured?() :: boolean()
def airplane_endpoint_configured? do
present_setting?(get_setting("ai.airplane.url")) and
present_setting?(get_setting("ai.airplane.model"))
end
defp present_setting?(value) when is_binary(value), do: String.trim(value) != ""
defp present_setting?(_value), do: false
@spec put_model_preference(atom(), String.t()) :: @spec put_model_preference(atom(), String.t()) ::
:ok | {:error, :unknown_model_preference | term()} :ok | {:error, :unknown_model_preference | term()}
def put_model_preference(key, model) when is_atom(key) and is_binary(model) do def put_model_preference(key, model) when is_atom(key) and is_binary(model) do
@@ -185,4 +215,12 @@ defmodule BDS.AI do
@spec cancel_chat(String.t()) :: :ok @spec cancel_chat(String.t()) :: :ok
defdelegate cancel_chat(conversation_id), to: Chat 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 end

View File

@@ -15,6 +15,7 @@ defmodule BDS.AI.Catalog do
alias BDS.AI.Model alias BDS.AI.Model
alias BDS.AI.ModelModality alias BDS.AI.ModelModality
alias BDS.AI.OpenAICompatibleRuntime alias BDS.AI.OpenAICompatibleRuntime
alias BDS.MapUtils
alias BDS.Persistence alias BDS.Persistence
alias BDS.Repo alias BDS.Repo
@@ -211,9 +212,7 @@ defmodule BDS.AI.Catalog do
end end
end end
defp atomize_map_keys(map) do defp atomize_map_keys(map), do: MapUtils.safe_atomize_keys(map)
Enum.into(map, %{}, fn {key, value} -> {String.to_atom(key), value} end)
end
defp persist_catalog(payload) do defp persist_catalog(payload) do
now = Persistence.now_ms() now = Persistence.now_ms()
@@ -313,7 +312,7 @@ defmodule BDS.AI.Catalog do
defp parse_modality("audio"), do: :audio defp parse_modality("audio"), do: :audio
defp parse_modality("file"), do: :file defp parse_modality("file"), do: :file
defp parse_modality("tool"), do: :tool defp parse_modality("tool"), do: :tool
defp parse_modality(other) when is_binary(other), do: String.to_atom(other) defp parse_modality(other) when is_binary(other), do: MapUtils.safe_atomize_key(other)
defp encode_nullable(nil), do: nil defp encode_nullable(nil), do: nil
defp encode_nullable(value), do: Jason.encode!(value) defp encode_nullable(value), do: Jason.encode!(value)

View File

@@ -1,5 +1,11 @@
defmodule BDS.AI.Chat do defmodule BDS.AI.Chat do
@moduledoc false @moduledoc """
Context for AI chat conversations: start, list, fetch, and delete
conversations, persist surface state, and resolve the effective chat model.
Automatic AI activity is gated by the app's airplane (offline) mode; callers
must respect that gating before invoking model-backed operations.
"""
import Ecto.Query import Ecto.Query
require Logger require Logger
@@ -9,6 +15,7 @@ defmodule BDS.AI.Chat do
alias BDS.AI.CatalogProvider alias BDS.AI.CatalogProvider
alias BDS.AI.ChatConversation alias BDS.AI.ChatConversation
alias BDS.AI.ChatMessage alias BDS.AI.ChatMessage
alias BDS.AI.ChatTitleGenerator
alias BDS.AI.ChatTools alias BDS.AI.ChatTools
alias BDS.AI.InFlight alias BDS.AI.InFlight
alias BDS.AI.OpenAICompatibleRuntime alias BDS.AI.OpenAICompatibleRuntime
@@ -24,16 +31,15 @@ defmodule BDS.AI.Chat do
@default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts." @default_system_prompt "You are the bDS AI backend. Be precise, prefer structured JSON when asked, and avoid inventing blog facts."
@default_max_output_tokens 16_384 @default_max_output_tokens 16_384
@title_max_output_tokens 256
@chat_title_max_length 30
@chat_max_tool_rounds 10 @chat_max_tool_rounds 10
@chat_await_timeout_margin_ms 5_000
@default_context_window 128_000 @default_context_window 128_000
@spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()} @spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
def start_chat(attrs \\ %{}) when is_map(attrs) do def start_chat(attrs \\ %{}) when is_map(attrs) do
now = Persistence.now_ms() now = Persistence.now_ms()
model = MapUtils.attr(attrs, :model) model = MapUtils.attr(attrs, :model)
title = MapUtils.attr(attrs, :title) || generated_chat_title(model) title = MapUtils.attr(attrs, :title) || ChatTitleGenerator.generated_chat_title(model)
%ChatConversation{} %ChatConversation{}
|> ChatConversation.changeset(%{ |> ChatConversation.changeset(%{
@@ -62,6 +68,42 @@ defmodule BDS.AI.Chat do
Repo.get(ChatConversation, conversation_id) Repo.get(ChatConversation, conversation_id)
end 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()} @spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
case Repo.get(ChatConversation, conversation_id) do case Repo.get(ChatConversation, conversation_id) do
@@ -69,12 +111,24 @@ defmodule BDS.AI.Chat do
{:error, :not_found} {:error, :not_found}
%ChatConversation{} = conversation -> %ChatConversation{} = conversation ->
Repo.delete_all( Repo.transaction(fn ->
from message in ChatMessage, where: message.conversation_id == ^conversation_id Repo.delete_all(
) from message in ChatMessage, where: message.conversation_id == ^conversation_id
)
case Repo.delete(conversation) do case delete_chat_conversation_test_hook(conversation_id) do
{:ok, _conversation} -> {:ok, :deleted} :ok ->
case Repo.delete(conversation) do
{: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} {:error, reason} -> {:error, reason}
end end
end end
@@ -160,19 +214,13 @@ defmodule BDS.AI.Chat do
}) do }) do
task = task =
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn -> Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
receive do
:sandbox_ready -> :ok
end
do_send_chat_message(conversation, user_message, opts) do_send_chat_message(conversation, user_message, opts)
end) end)
InFlight.register(conversation.id, task.pid) InFlight.register(conversation.id, task.pid)
:ok = allow_repo_sandbox(task.pid)
send(task.pid, :sandbox_ready)
try do try do
await_chat_task(task) await_chat_task(task, chat_await_timeout_ms())
after after
InFlight.unregister(conversation.id) InFlight.unregister(conversation.id)
end end
@@ -375,7 +423,7 @@ defmodule BDS.AI.Chat do
tools, tools,
runtime, runtime,
opts, opts,
@chat_max_tool_rounds chat_max_tool_rounds()
), ),
{:ok, reply} <- {:ok, reply} <-
maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do
@@ -387,11 +435,11 @@ defmodule BDS.AI.Chat do
conversation = Repo.get!(ChatConversation, conversation_id) conversation = Repo.get!(ChatConversation, conversation_id)
cond do cond do
chat_user_message_count(conversation_id) < 1 -> ChatTitleGenerator.chat_user_message_count(conversation_id) < 1 ->
Logger.debug("Chat title generation skipped reason=:no_user_messages") Logger.debug("Chat title generation skipped reason=:no_user_messages")
{:ok, reply} {:ok, reply}
not generated_chat_title?(conversation.title, conversation.model) -> not ChatTitleGenerator.generated_chat_title?(conversation.title, conversation.model) ->
Logger.debug( Logger.debug(
"Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}" "Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}"
) )
@@ -401,7 +449,7 @@ defmodule BDS.AI.Chat do
true -> true ->
Logger.debug("Chat title generation requested conversation_id=#{conversation_id}") Logger.debug("Chat title generation requested conversation_id=#{conversation_id}")
case generate_chat_title(user_content, opts) do case ChatTitleGenerator.generate_chat_title(user_content, opts) do
{:ok, title} when is_binary(title) and title != "" -> {:ok, title} when is_binary(title) and title != "" ->
now = Persistence.now_ms() now = Persistence.now_ms()
@@ -422,87 +470,6 @@ defmodule BDS.AI.Chat do
end end
end end
defp generate_chat_title(user_content, opts) when is_binary(user_content) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
:ok <- Runtime.validate_target(:chat_title, model, mode),
request <- build_chat_title_request(user_content, model),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
title = sanitize_chat_title(Map.get(response, :content))
if title == "" do
Logger.warning("Chat title generation returned an empty title",
model: model,
content: inspect(Map.get(response, :content)),
usage: inspect(Map.get(response, :usage))
)
end
{:ok, title}
else
{:error, reason} = error ->
Logger.warning("Chat title generation failed", reason: inspect(reason))
error
other ->
Logger.warning("Chat title generation failed", reason: inspect(other))
other
end
end
defp build_chat_title_request(user_content, model) do
%{
operation: :chat_title,
model: model,
max_output_tokens: @title_max_output_tokens,
messages: [
%{
"role" => "system",
"content" =>
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text."
},
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
]
}
end
defp sanitize_chat_title(title) when is_binary(title) do
title =
title
|> String.trim()
|> String.trim_leading("\"")
|> String.trim_leading("'")
|> String.trim_trailing("\"")
|> String.trim_trailing("'")
|> String.trim_trailing(".")
|> String.trim_trailing("!")
|> String.trim_trailing("?")
if String.length(title) > @chat_title_max_length do
String.slice(title, 0, @chat_title_max_length - 3) <> "..."
else
title
end
end
defp sanitize_chat_title(_title), do: ""
defp chat_user_message_count(conversation_id) do
Repo.aggregate(
from(message in ChatMessage,
where: message.conversation_id == ^conversation_id and message.role == :user
),
:count,
:id
)
end
defp generated_chat_title?(title, model) do
title in [generated_chat_title(nil), generated_chat_title(model)]
end
defp chat_round( defp chat_round(
_conversation, _conversation,
_messages, _messages,
@@ -529,52 +496,35 @@ defmodule BDS.AI.Chat do
rounds_left rounds_left
) do ) do
request = build_chat_request(conversation, messages, model, project_id, tools) request = build_chat_request(conversation, messages, model, project_id, tools)
generate_opts = put_stream_callback(opts, conversation.id)
with {:ok, response} <- 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, assistant_message} <- persist_assistant_response(conversation.id, response),
:ok <- touch_conversation(conversation.id) do :ok <- touch_conversation(conversation.id) do
if is_binary(Map.get(response, :content)) and String.trim(Map.get(response, :content)) != "" do notify_assistant_content(response, conversation.id, opts)
notify_chat_event(
opts,
{:chat_streaming_content, conversation.id, Map.get(response, :content)}
)
end
tool_calls = decode_tool_calls(Map.get(response, :tool_calls)) response
|> Map.get(:tool_calls)
|> decode_tool_calls()
|> continue_chat_round(
conversation.id,
assistant_message,
endpoint,
model,
project_id,
tools,
runtime,
opts,
rounds_left
)
end
end
Enum.each(tool_calls, fn tool_call -> defp delete_chat_conversation_test_hook(conversation_id) do
notify_chat_event(opts, {:chat_tool_call, conversation.id, tool_call}) case Application.get_env(:bds, :chat_delete_conversation_test_hook) do
end) hook when is_function(hook, 1) -> hook.(conversation_id)
_other -> :ok
cond do
tool_calls != [] and tools != [] ->
with {:ok, tool_messages} <-
execute_tool_calls(conversation.id, tool_calls, project_id, opts),
updated_messages <- load_chat_messages(conversation.id),
{:ok, reply} <-
chat_round(
Repo.get!(ChatConversation, conversation.id),
updated_messages,
endpoint,
model,
project_id,
tools,
runtime,
opts,
rounds_left - 1
) do
{:ok, Map.put(reply, :tool_messages, tool_messages)}
end
true ->
{:ok,
%{
conversation: format_conversation(Repo.get!(ChatConversation, conversation.id)),
assistant_message: format_chat_message(assistant_message),
tool_messages: []
}}
end
end end
end end
@@ -622,6 +572,63 @@ defmodule BDS.AI.Chat do
{:ok, tool_messages} {:ok, tool_messages}
end end
defp continue_chat_round(
tool_calls,
conversation_id,
assistant_message,
endpoint,
model,
project_id,
tools,
runtime,
opts,
rounds_left
) do
Enum.each(tool_calls, fn tool_call ->
notify_chat_event(opts, {:chat_tool_call, conversation_id, tool_call})
end)
if tool_calls != [] and tools != [] do
with {:ok, tool_messages} <- execute_tool_calls(conversation_id, tool_calls, project_id, opts),
updated_messages <- load_chat_messages(conversation_id),
{:ok, reply} <-
chat_round(
Repo.get!(ChatConversation, conversation_id),
updated_messages,
endpoint,
model,
project_id,
tools,
runtime,
opts,
rounds_left - 1
) do
{:ok, Map.put(reply, :tool_messages, tool_messages)}
end
else
final_chat_reply(conversation_id, assistant_message)
end
end
defp final_chat_reply(conversation_id, assistant_message) do
{:ok,
%{
conversation: format_conversation(Repo.get!(ChatConversation, conversation_id)),
assistant_message: format_chat_message(assistant_message),
tool_messages: []
}}
end
defp notify_assistant_content(response, conversation_id, opts) do
content = Map.get(response, :content)
if is_binary(content) and String.trim(content) != "" do
notify_chat_event(opts, {:chat_streaming_content, conversation_id, content})
end
:ok
end
defp build_chat_request(conversation, messages, model, project_id, tools) do defp build_chat_request(conversation, messages, model, project_id, tools) do
system_message = %{"role" => "system", "content" => chat_system_prompt(project_id, tools)} system_message = %{"role" => "system", "content" => chat_system_prompt(project_id, tools)}
@@ -716,6 +723,25 @@ defmodule BDS.AI.Chat do
ChatTools.available_specs(project_id, Catalog.model_capabilities(model)) ChatTools.available_specs(project_id, Catalog.model_capabilities(model))
end 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 defp chat_system_prompt(project_id, tools) do
base = get_setting("ai.system_prompt") || @default_system_prompt base = get_setting("ai.system_prompt") || @default_system_prompt
@@ -742,15 +768,16 @@ defmodule BDS.AI.Chat do
"- Use list_tags, list_categories, and count_posts for taxonomy and grouped analytics questions.", "- 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.", "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:", "Available UI Render Tools (use these to show rich interactive elements):",
"- 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.", "- 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.",
"- Use render_table for tabular data, comparisons, and structured listings.", "- render_table: Show data in a structured table. Use for tabular comparisons and listings.",
"- Use render_form to collect structured user input.", "- render_form: Show an interactive form to collect user input (e.g., metadata edits, settings).",
"- Use render_card for summaries, highlights, or actionable items.", "- render_card: Show an information card with title, body, and action buttons.",
"- Use render_metric for a single KPI or important statistic.", "- render_metric: Show a single KPI or statistic prominently.",
"- Use render_list for bullet lists, checklists, or simple enumerations.", "- render_list: Show a bulleted list of items.",
"- Use render_tabs to organize multiple views into switchable tabs; tab content can contain text, metrics, lists, charts, and tables.", "- 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 render tools over plain text. When building any visualization, render it as soon as you have enough data." "",
"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" "\n"
) )
@@ -779,9 +806,6 @@ defmodule BDS.AI.Chat do
) )
end end
defp generated_chat_title(nil), do: "New Chat"
defp generated_chat_title(model), do: "Chat with #{model}"
defp load_chat_messages(conversation_id) do defp load_chat_messages(conversation_id) do
Repo.all( Repo.all(
from message in ChatMessage, from message in ChatMessage,
@@ -807,7 +831,7 @@ defmodule BDS.AI.Chat do
:ok :ok
end end
defp await_chat_task(task) do defp await_chat_task(task, timeout_ms) do
ref = task.ref ref = task.ref
receive do receive do
@@ -833,6 +857,10 @@ defmodule BDS.AI.Chat do
_other -> _other ->
{:error, :cancelled} {:error, :cancelled}
end end
after
timeout_ms ->
_ = Task.shutdown(task, 100)
{:error, :chat_timeout}
end end
end end
@@ -876,6 +904,26 @@ defmodule BDS.AI.Chat do
end end
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 defp notify_chat_event(opts, event) do
case Keyword.get(opts, :event_target) do case Keyword.get(opts, :event_target) do
pid when is_pid(pid) -> send(pid, event) pid when is_pid(pid) -> send(pid, event)
@@ -890,22 +938,8 @@ defmodule BDS.AI.Chat do
Repo.one(from project in Project, where: project.is_active == true, select: project.id) Repo.one(from project in Project, where: project.is_active == true, select: project.id)
end 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(nil), do: nil
defp encode_nullable(value), do: Jason.encode!(value) defp encode_nullable(value), do: Jason.encode!(value)
defp blank?(value), do: value in [nil, ""] defp blank?(value), do: BDS.Values.blank?(value)
end end

View File

@@ -11,6 +11,7 @@ defmodule BDS.AI.ChatConversation do
title: String.t() | nil, title: String.t() | nil,
model: String.t() | nil, model: String.t() | nil,
copilot_session_id: String.t() | nil, copilot_session_id: String.t() | nil,
surface_state: map() | nil,
created_at: integer() | nil, created_at: integer() | nil,
updated_at: integer() | nil updated_at: integer() | nil
} }
@@ -19,13 +20,16 @@ defmodule BDS.AI.ChatConversation do
field :title, :string field :title, :string
field :model, :string field :model, :string
field :copilot_session_id, :string field :copilot_session_id, :string
field :surface_state, :map
field :created_at, :integer field :created_at, :integer
field :updated_at, :integer field :updated_at, :integer
end end
def changeset(conversation, attrs) do def changeset(conversation, attrs) do
conversation 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] empty_values: [nil]
) )
|> validate_required([:id, :title, :created_at, :updated_at]) |> validate_required([:id, :title, :created_at, :updated_at])

View File

@@ -0,0 +1,99 @@
defmodule BDS.AI.ChatTitleGenerator do
@moduledoc false
require Logger
alias BDS.AI.ChatMessage
alias BDS.AI.OpenAICompatibleRuntime
alias BDS.AI.Runtime
alias BDS.Repo
import Ecto.Query
@title_max_output_tokens 256
@chat_title_max_length 30
def generate_chat_title(user_content, opts) when is_binary(user_content) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
:ok <- Runtime.validate_target(:chat_title, model, mode),
request <- build_chat_title_request(user_content, model),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
title = sanitize_chat_title(Map.get(response, :content))
if title == "" do
Logger.warning("Chat title generation returned an empty title",
model: model,
content: inspect(Map.get(response, :content)),
usage: inspect(Map.get(response, :usage))
)
end
{:ok, title}
else
{:error, reason} = error ->
Logger.warning("Chat title generation failed", reason: inspect(reason))
error
other ->
Logger.warning("Chat title generation failed", reason: inspect(other))
other
end
end
def build_chat_title_request(user_content, model) do
%{
operation: :chat_title,
model: model,
max_output_tokens: @title_max_output_tokens,
messages: [
%{
"role" => "system",
"content" =>
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text."
},
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
]
}
end
def sanitize_chat_title(title) when is_binary(title) do
title =
title
|> String.trim()
|> String.trim_leading("\"")
|> String.trim_leading("'")
|> String.trim_trailing("\"")
|> String.trim_trailing("'")
|> String.trim_trailing(".")
|> String.trim_trailing("!")
|> String.trim_trailing("?")
if String.length(title) > @chat_title_max_length do
String.slice(title, 0, @chat_title_max_length - 3) <> "..."
else
title
end
end
def sanitize_chat_title(_title), do: ""
def chat_user_message_count(conversation_id) do
Repo.aggregate(
from(message in ChatMessage,
where: message.conversation_id == ^conversation_id and message.role == :user
),
:count,
:id
)
end
def generated_chat_title?(title, model) do
title in [generated_chat_title(nil), generated_chat_title(model)]
end
def generated_chat_title(nil), do: "New Chat"
def generated_chat_title(model), do: "Chat with #{model}"
end

View File

@@ -0,0 +1,49 @@
defmodule BDS.AI.ChatToolQueryHelpers do
@moduledoc false
alias BDS.Search
def normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
def normalize_limit(_value), do: 10
def normalize_offset(value) when is_integer(value) and value >= 0, do: value
def normalize_offset(_value), do: 0
def search_filters(arguments) do
%{}
|> BDS.MapUtils.maybe_put(:category, BDS.MapUtils.blank_to_nil(arguments["category"]))
|> BDS.MapUtils.maybe_put(:tags, BDS.MapUtils.blank_to_nil(arguments["tags"]))
|> BDS.MapUtils.maybe_put(:language, BDS.MapUtils.blank_to_nil(arguments["language"]))
|> BDS.MapUtils.maybe_put(
:missing_translation_language,
BDS.MapUtils.blank_to_nil(arguments["missingTranslationLanguage"])
)
|> BDS.MapUtils.maybe_put(:year, arguments["year"])
|> BDS.MapUtils.maybe_put(:month, arguments["month"])
|> BDS.MapUtils.maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|> Map.put(:offset, normalize_offset(arguments["offset"]))
|> Map.put(:limit, normalize_limit(arguments["limit"]))
end
def search_all_counted_posts(project_id, arguments) do
filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
filters = Map.put(filters, :limit, max(total, 1))
{:ok, result} = Search.search_posts(project_id, "", filters)
result
end
def search_result(project_id, query, filters, mapper) do
{:ok, result} = Search.search_posts(project_id, query, filters)
%{
posts: Enum.map(result.posts, mapper),
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.offset + result.limit < result.total
}
end
end

View File

@@ -0,0 +1,350 @@
defmodule BDS.AI.ChatToolSchemas do
@moduledoc false
def tool_spec(name, description, parameters) do
%{
"type" => "function",
"function" => %{
"name" => name,
"description" => description,
"parameters" => parameters
}
}
end
def limit_schema do
%{
"type" => "object",
"properties" => %{
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50}
}
}
end
def post_search_schema(require_query) do
schema = %{
"type" => "object",
"properties" => %{
"query" => %{"type" => "string"},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"language" => %{"type" => "string"},
"missingTranslationLanguage" => %{"type" => "string"},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50},
"offset" => %{"type" => "integer", "minimum" => 0}
}
}
if require_query, do: Map.put(schema, "required", ["query"]), else: schema
end
def count_posts_schema do
%{
"type" => "object",
"properties" => %{
"groupBy" => %{
"type" => "array",
"items" => %{
"type" => "string",
"enum" => ["year", "month", "tag", "category", "status"]
}
},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["groupBy"]
}
end
def post_id_schema do
%{
"type" => "object",
"properties" => %{"postId" => %{"type" => "string"}},
"required" => ["postId"]
}
end
def media_id_schema(extra_properties \\ %{}) do
%{
"type" => "object",
"properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties),
"required" => ["mediaId"]
}
end
def update_post_metadata_schema do
%{
"type" => "object",
"properties" => %{
"postId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"excerpt" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"categories" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["postId"]
}
end
def update_media_metadata_schema do
%{
"type" => "object",
"properties" => %{
"mediaId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"alt" => %{"type" => "string"},
"caption" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["mediaId"]
}
end
def render_table_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional table title"},
"columns" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Column header names"
},
"rows" => %{
"type" => "array",
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
"description" => "Table rows, each row is an array of cell values"
}
}
}
end
def render_chart_schema do
%{
"type" => "object",
"properties" => %{
"chartType" => %{
"type" => "string",
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
"description" =>
"The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations."
},
"title" => %{"type" => "string", "description" => "Optional chart title"},
"series" => %{
"type" => "array",
"description" => "Array of data points.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Data point label"},
"value" => %{"type" => "number", "description" => "Data point value"},
"segments" => %{
"type" => "array",
"description" =>
"Segments within this data point. Required for stacked-bar and heatmap charts.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string"},
"value" => %{"type" => "number"}
},
"required" => ["label", "value"]
}
}
},
"required" => ["label"]
}
}
},
"required" => ["chartType", "series"]
}
end
def render_form_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional form title"},
"fields" => %{
"type" => "array",
"description" => "Form fields to display",
"items" => %{
"type" => "object",
"properties" => %{
"key" => %{"type" => "string", "description" => "Field identifier"},
"label" => %{"type" => "string", "description" => "Field label shown to user"},
"inputType" => %{
"type" => "string",
"enum" => ["text", "textarea", "select", "checkbox", "date", "number"],
"description" => "Type of input control"
},
"placeholder" => %{"type" => "string", "description" => "Placeholder text"},
"defaultValue" => %{"type" => "string", "description" => "Default value"},
"options" => %{
"type" => "array",
"description" => "Options for select fields",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string"},
"value" => %{"type" => "string"}
}
}
},
"required" => %{"type" => "boolean", "description" => "Whether the field is required"}
},
"required" => ["key", "label", "inputType"]
}
},
"submitLabel" => %{"type" => "string", "description" => "Label for the submit button"},
"submitAction" => %{"type" => "string", "description" => "Action to dispatch on submit"}
}
}
end
def render_card_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Card title"},
"subtitle" => %{"type" => "string", "description" => "Optional subtitle"},
"body" => %{"type" => "string", "description" => "Card body text (supports markdown)"},
"actions" => %{
"type" => "array",
"description" => "Optional action buttons on the card",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Button label"},
"action" => %{"type" => "string", "description" => "Action name to dispatch"},
"payload" => %{
"type" => "object",
"description" => "Optional action payload"
}
},
"required" => ["label", "action"]
}
}
}
}
end
def render_metric_schema do
%{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Metric label"},
"value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"}
}
}
end
def render_list_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional list title"},
"items" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "List items"
}
}
}
end
def render_tabs_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional tabs title"},
"tabs" => %{
"type" => "array",
"description" => "Array of tabs",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Tab label"},
"content" => %{
"type" => "array",
"description" => "Content items within the tab",
"items" => %{
"type" => "object",
"properties" => %{
"type" => %{
"type" => "string",
"enum" => ["text", "metric", "list", "chart", "table"],
"description" => "Content type"
},
"text" => %{"type" => "string", "description" => "Text content (for type text)"},
"label" => %{"type" => "string", "description" => "Label (for type metric)"},
"value" => %{"type" => "string", "description" => "Display value (for type metric)"},
"title" => %{"type" => "string", "description" => "Title (for type list, chart, or table)"},
"items" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Items (for type list)"
},
"chartType" => %{
"type" => "string",
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
"description" => "Chart type (for type chart)"
},
"series" => %{
"type" => "array",
"description" => "Data series (for type chart)"
},
"columns" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Column headers (for type table)"
},
"rows" => %{
"type" => "array",
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
"description" => "Table rows (for type table)"
}
},
"required" => ["type"]
}
}
},
"required" => ["label", "content"]
}
}
}
}
end
def render_mindmap_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional mind map title"},
"nodes" => %{
"type" => "array",
"description" => "Flat array of nodes. The first node is the root. Each node references children by ID.",
"items" => %{
"type" => "object",
"properties" => %{
"id" => %{"type" => "string", "description" => "Unique node identifier"},
"label" => %{"type" => "string", "description" => "Node label text"},
"children" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "IDs of child nodes"
}
},
"required" => ["id", "label"]
}
}
}
}
end
end

View File

@@ -4,6 +4,8 @@ defmodule BDS.AI.ChatTools do
import Ecto.Query import Ecto.Query
alias BDS.AI.Chat alias BDS.AI.Chat
alias BDS.AI.ChatToolQueryHelpers, as: QueryHelpers
alias BDS.AI.ChatToolSchemas, as: Schemas
alias BDS.Media, as: MediaContext alias BDS.Media, as: MediaContext
alias BDS.Media.Media alias BDS.Media.Media
alias BDS.MCP.Queries alias BDS.MCP.Queries
@@ -12,7 +14,6 @@ defmodule BDS.AI.ChatTools do
alias BDS.Posts.PostMedia alias BDS.Posts.PostMedia
alias BDS.Projects.Project alias BDS.Projects.Project
alias BDS.Repo alias BDS.Repo
alias BDS.Search
@spec execute(String.t(), map(), String.t() | nil) :: map() @spec execute(String.t(), map(), String.t() | nil) :: map()
def execute("blog_stats", _arguments, project_id) do def execute("blog_stats", _arguments, project_id) do
@@ -58,17 +59,14 @@ defmodule BDS.AI.ChatTools do
def execute("search_posts", arguments, project_id) do def execute("search_posts", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
filters = search_filters(arguments) filters = QueryHelpers.search_filters(arguments)
{:ok, result} = Search.search_posts(project_id, arguments["query"] || "", filters) QueryHelpers.search_result(
project_id,
%{ arguments["query"] || "",
posts: Enum.map(result.posts, &Queries.post_summary/1), filters,
total: result.total, &Queries.post_summary/1
offset: result.offset, )
limit: result.limit,
has_more: result.offset + result.limit < result.total
}
end end
def execute("read_post_by_slug", arguments, project_id) do def execute("read_post_by_slug", arguments, project_id) do
@@ -83,41 +81,28 @@ defmodule BDS.AI.ChatTools do
def execute("read_post", arguments, project_id) do def execute("read_post", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
case Repo.get_by(Post, with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
id: arguments["postId"] || arguments["post_id"], %{post: Queries.post_detail(post)}
project_id: project_id end)
) do
%Post{} = post -> %{post: Queries.post_detail(post)}
nil -> %{success: false, error: "not_found"}
end
end end
def execute("list_posts", arguments, project_id) do def execute("list_posts", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
limit = normalize_limit(arguments["limit"]) limit = QueryHelpers.normalize_limit(arguments["limit"])
offset = normalize_offset(arguments["offset"]) offset = QueryHelpers.normalize_offset(arguments["offset"])
filters = search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset}) filters = QueryHelpers.search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset})
{:ok, result} = Search.search_posts(project_id, "", filters) QueryHelpers.search_result(project_id, "", filters, fn post ->
post
%{ |> Queries.post_summary()
posts: |> Map.put("url", "/posts/#{post.slug}")
Enum.map(result.posts, fn post -> |> Map.put("updated_at", post.updated_at)
post end)
|> Queries.post_summary()
|> Map.put("url", "/posts/#{post.slug}")
|> Map.put("updated_at", post.updated_at)
end),
total: result.total,
offset: result.offset,
limit: result.limit,
has_more: result.offset + result.limit < result.total
}
end end
def execute("list_media", arguments, project_id) do def execute("list_media", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
limit = normalize_limit(arguments["limit"]) limit = QueryHelpers.normalize_limit(arguments["limit"])
Repo.all( Repo.all(
from(media in Media, from(media in Media,
@@ -138,13 +123,9 @@ defmodule BDS.AI.ChatTools do
def execute("get_media", arguments, project_id) do def execute("get_media", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
case Repo.get_by(Media, with_media(arguments, project_id, %{success: false, error: "not_found"}, fn media ->
id: arguments["mediaId"] || arguments["media_id"], %{media: media_summary(media)}
project_id: project_id end)
) do
%Media{} = media -> %{media: media_summary(media)}
nil -> %{success: false, error: "not_found"}
end
end end
def execute("view_image", arguments, project_id) do def execute("view_image", arguments, project_id) do
@@ -220,7 +201,7 @@ defmodule BDS.AI.ChatTools do
def execute("count_posts", arguments, project_id) do def execute("count_posts", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1) group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1)
result = search_all_counted_posts(project_id, arguments) result = QueryHelpers.search_all_counted_posts(project_id, arguments)
groups = groups =
result.posts result.posts
@@ -235,51 +216,33 @@ defmodule BDS.AI.ChatTools do
def execute("get_post_backlinks", arguments, project_id) do def execute("get_post_backlinks", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
case Repo.get_by(Post, with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
id: arguments["postId"] || arguments["post_id"], %{success: true, post_id: post.id, linked_by: Queries.linked_posts(post.id, :incoming)}
project_id: project_id end)
) do
%Post{} = post ->
%{success: true, post_id: post.id, linked_by: Queries.linked_posts(post.id, :incoming)}
nil ->
%{success: false, error: "not_found"}
end
end end
def execute("get_post_outlinks", arguments, project_id) do def execute("get_post_outlinks", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
case Repo.get_by(Post, with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
id: arguments["postId"] || arguments["post_id"], %{success: true, post_id: post.id, links_to: Queries.linked_posts(post.id, :outgoing)}
project_id: project_id end)
) do
%Post{} = post ->
%{success: true, post_id: post.id, links_to: Queries.linked_posts(post.id, :outgoing)}
nil ->
%{success: false, error: "not_found"}
end
end end
def execute("get_post_media", arguments, project_id) do def execute("get_post_media", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
post_id = arguments["postId"] || arguments["post_id"]
case Repo.get_by(Post, id: post_id, project_id: project_id) do with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
%Post{} = post -> %{success: true, post_id: post.id, media: post_media(project_id, post.id)} %{success: true, post_id: post.id, media: post_media(project_id, post.id)}
nil -> %{success: false, error: "not_found"} end)
end
end end
def execute("get_media_posts", arguments, project_id) do def execute("get_media_posts", arguments, project_id) do
project_id = project_id || active_project_id() project_id = project_id || active_project_id()
media_id = arguments["mediaId"] || arguments["media_id"]
case Repo.get_by(Media, id: media_id, project_id: project_id) do with_media(arguments, project_id, %{success: false, error: "not_found"}, fn media ->
%Media{} = media -> %{success: true, media_id: media.id, posts: media_posts(media.id)} %{success: true, media_id: media.id, posts: media_posts(media.id)}
nil -> %{success: false, error: "not_found"} end)
end
end end
def execute("render_table", arguments, _project_id) do def execute("render_table", arguments, _project_id) do
@@ -365,7 +328,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "blog_stats", name: "blog_stats",
spec: spec:
tool_spec("blog_stats", "Return aggregate blog statistics", %{ Schemas.tool_spec("blog_stats", "Return aggregate blog statistics", %{
"type" => "object", "type" => "object",
"properties" => %{} "properties" => %{}
}) })
@@ -373,7 +336,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "get_blog_stats", name: "get_blog_stats",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_blog_stats", "get_blog_stats",
"Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.", "Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.",
%{"type" => "object", "properties" => %{}} %{"type" => "object", "properties" => %{}}
@@ -382,7 +345,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "check_term", name: "check_term",
spec: spec:
tool_spec( Schemas.tool_spec(
"check_term", "check_term",
"Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or list_posts when unsure whether a term is a category or tag.", "Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or list_posts when unsure whether a term is a category or tag.",
%{ %{
@@ -395,16 +358,16 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "search_posts", name: "search_posts",
spec: spec:
tool_spec( Schemas.tool_spec(
"search_posts", "search_posts",
"Search blog posts using full-text search. Can filter by category, tags, language, missing translation language, year, month, or status. Returns paginated concrete post data with titles, slugs, tags, categories, backlinks, and links_to.", "Search blog posts using full-text search. Can filter by category, tags, language, missing translation language, year, month, or status. Returns paginated concrete post data with titles, slugs, tags, categories, backlinks, and links_to.",
post_search_schema(true) Schemas.post_search_schema(true)
) )
}, },
%{ %{
name: "read_post", name: "read_post",
spec: spec:
tool_spec( Schemas.tool_spec(
"read_post", "read_post",
"Read full content and metadata of a specific blog post by ID. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.", "Read full content and metadata of a specific blog post by ID. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
%{ %{
@@ -417,7 +380,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "read_post_by_slug", name: "read_post_by_slug",
spec: spec:
tool_spec( Schemas.tool_spec(
"read_post_by_slug", "read_post_by_slug",
"Read full content and metadata of a specific blog post by slug. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.", "Read full content and metadata of a specific blog post by slug. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
%{ %{
@@ -430,37 +393,37 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "list_posts", name: "list_posts",
spec: spec:
tool_spec( Schemas.tool_spec(
"list_posts", "list_posts",
"List blog posts with optional filtering by status, category, tags, language, year, or month. Returns paginated concrete post data with titles, slugs, URLs, statuses, tags, categories, backlinks, and links_to. Use for recent, latest, top, or title-list requests.", "List blog posts with optional filtering by status, category, tags, language, year, or month. Returns paginated concrete post data with titles, slugs, URLs, statuses, tags, categories, backlinks, and links_to. Use for recent, latest, top, or title-list requests.",
post_search_schema(false) Schemas.post_search_schema(false)
) )
}, },
%{ %{
name: "get_media", name: "get_media",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_media", "get_media",
"Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.", "Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.",
media_id_schema() Schemas.media_id_schema()
) )
}, },
%{ %{
name: "list_media", name: "list_media",
spec: spec:
tool_spec( Schemas.tool_spec(
"list_media", "list_media",
"List concrete media data in the active project, including titles, filenames, MIME types, and update times.", "List concrete media data in the active project, including titles, filenames, MIME types, and update times.",
limit_schema() Schemas.limit_schema()
) )
}, },
%{ %{
name: "view_image", name: "view_image",
spec: spec:
tool_spec( Schemas.tool_spec(
"view_image", "view_image",
"View an image thumbnail as a local data URL for visual inspection. Only works with image media files.", "View an image thumbnail as a local data URL for visual inspection. Only works with image media files.",
media_id_schema(%{ Schemas.media_id_schema(%{
"size" => %{"type" => "string", "enum" => ["small", "medium", "large"]} "size" => %{"type" => "string", "enum" => ["small", "medium", "large"]}
}) })
) )
@@ -468,25 +431,25 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "update_post_metadata", name: "update_post_metadata",
spec: spec:
tool_spec( Schemas.tool_spec(
"update_post_metadata", "update_post_metadata",
"Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.", "Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.",
update_post_metadata_schema() Schemas.update_post_metadata_schema()
) )
}, },
%{ %{
name: "update_media_metadata", name: "update_media_metadata",
spec: spec:
tool_spec( Schemas.tool_spec(
"update_media_metadata", "update_media_metadata",
"Update metadata for a media file: title, alt text, caption, or tags.", "Update metadata for a media file: title, alt text, caption, or tags.",
update_media_metadata_schema() Schemas.update_media_metadata_schema()
) )
}, },
%{ %{
name: "list_tags", name: "list_tags",
spec: spec:
tool_spec( Schemas.tool_spec(
"list_tags", "list_tags",
"List all tags used across blog posts with post counts.", "List all tags used across blog posts with post counts.",
%{ %{
@@ -498,7 +461,7 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "list_categories", name: "list_categories",
spec: spec:
tool_spec( Schemas.tool_spec(
"list_categories", "list_categories",
"List all categories used across blog posts with post counts.", "List all categories used across blog posts with post counts.",
%{"type" => "object", "properties" => %{}} %{"type" => "object", "properties" => %{}}
@@ -507,46 +470,46 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "count_posts", name: "count_posts",
spec: spec:
tool_spec( Schemas.tool_spec(
"count_posts", "count_posts",
"Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.", "Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.",
count_posts_schema() Schemas.count_posts_schema()
) )
}, },
%{ %{
name: "get_post_backlinks", name: "get_post_backlinks",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_post_backlinks", "get_post_backlinks",
"Get all posts that link to a specific post.", "Get all posts that link to a specific post.",
post_id_schema() Schemas.post_id_schema()
) )
}, },
%{ %{
name: "get_post_outlinks", name: "get_post_outlinks",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_post_outlinks", "get_post_outlinks",
"Get all posts that a specific post links to.", "Get all posts that a specific post links to.",
post_id_schema() Schemas.post_id_schema()
) )
}, },
%{ %{
name: "get_post_media", name: "get_post_media",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_post_media", "get_post_media",
"Get media files linked to a specific post.", "Get media files linked to a specific post.",
post_id_schema() Schemas.post_id_schema()
) )
}, },
%{ %{
name: "get_media_posts", name: "get_media_posts",
spec: spec:
tool_spec( Schemas.tool_spec(
"get_media_posts", "get_media_posts",
"Get posts that use a specific media file.", "Get posts that use a specific media file.",
media_id_schema() Schemas.media_id_schema()
) )
} }
] ]
@@ -559,73 +522,73 @@ defmodule BDS.AI.ChatTools do
%{ %{
name: "render_card", name: "render_card",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_card", "render_card",
"Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.", "Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.",
render_card_schema() Schemas.render_card_schema()
) )
}, },
%{ %{
name: "render_table", name: "render_table",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_table", "render_table",
"Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.", "Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.",
render_table_schema() Schemas.render_table_schema()
) )
}, },
%{ %{
name: "render_chart", name: "render_chart",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_chart", "render_chart",
"Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.", "Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.",
render_chart_schema() Schemas.render_chart_schema()
) )
}, },
%{ %{
name: "render_form", name: "render_form",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_form", "render_form",
"Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.", "Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.",
render_form_schema() Schemas.render_form_schema()
) )
}, },
%{ %{
name: "render_metric", name: "render_metric",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_metric", "render_metric",
"Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.", "Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.",
render_metric_schema() Schemas.render_metric_schema()
) )
}, },
%{ %{
name: "render_list", name: "render_list",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_list", "render_list",
"Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.", "Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.",
render_list_schema() Schemas.render_list_schema()
) )
}, },
%{ %{
name: "render_tabs", name: "render_tabs",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_tabs", "render_tabs",
"Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.", "Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.",
render_tabs_schema() Schemas.render_tabs_schema()
) )
}, },
%{ %{
name: "render_mindmap", name: "render_mindmap",
spec: spec:
tool_spec( Schemas.tool_spec(
"render_mindmap", "render_mindmap",
"Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.", "Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.",
render_mindmap_schema() Schemas.render_mindmap_schema()
) )
} }
] ]
@@ -634,268 +597,26 @@ defmodule BDS.AI.ChatTools do
end end
end end
defp tool_spec(name, description, parameters) do defp with_post(arguments, project_id, not_found_result, success_fun) do
%{ case Repo.get_by(Post,
"type" => "function", id: arguments["postId"] || arguments["post_id"],
"function" => %{ project_id: project_id
"name" => name, ) do
"description" => description, %Post{} = post -> success_fun.(post)
"parameters" => parameters nil -> not_found_result
} end
}
end end
defp limit_schema do defp with_media(arguments, project_id, not_found_result, success_fun) do
%{ case Repo.get_by(Media,
"type" => "object", id: arguments["mediaId"] || arguments["media_id"],
"properties" => %{ project_id: project_id
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50} ) do
} %Media{} = media -> success_fun.(media)
} nil -> not_found_result
end
end end
defp post_search_schema(require_query) do
schema = %{
"type" => "object",
"properties" => %{
"query" => %{"type" => "string"},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"language" => %{"type" => "string"},
"missingTranslationLanguage" => %{"type" => "string"},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50},
"offset" => %{"type" => "integer", "minimum" => 0}
}
}
if require_query, do: Map.put(schema, "required", ["query"]), else: schema
end
defp count_posts_schema do
%{
"type" => "object",
"properties" => %{
"groupBy" => %{
"type" => "array",
"items" => %{
"type" => "string",
"enum" => ["year", "month", "tag", "category", "status"]
}
},
"year" => %{"type" => "integer"},
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
"category" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["groupBy"]
}
end
defp post_id_schema do
%{
"type" => "object",
"properties" => %{"postId" => %{"type" => "string"}},
"required" => ["postId"]
}
end
defp media_id_schema(extra_properties \\ %{}) do
%{
"type" => "object",
"properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties),
"required" => ["mediaId"]
}
end
defp update_post_metadata_schema do
%{
"type" => "object",
"properties" => %{
"postId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"excerpt" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
"categories" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["postId"]
}
end
defp update_media_metadata_schema do
%{
"type" => "object",
"properties" => %{
"mediaId" => %{"type" => "string"},
"title" => %{"type" => "string"},
"alt" => %{"type" => "string"},
"caption" => %{"type" => "string"},
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
},
"required" => ["mediaId"]
}
end
defp render_table_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string", "description" => "Optional table title"},
"columns" => %{
"type" => "array",
"items" => %{"type" => "string"},
"description" => "Column header names"
},
"rows" => %{
"type" => "array",
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
"description" => "Table rows, each row is an array of cell values"
}
}
}
end
defp render_chart_schema do
%{
"type" => "object",
"properties" => %{
"chartType" => %{
"type" => "string",
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
"description" =>
"The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations."
},
"title" => %{"type" => "string", "description" => "Optional chart title"},
"series" => %{
"type" => "array",
"description" => "Array of data points.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string", "description" => "Data point label"},
"value" => %{"type" => "number", "description" => "Data point value"},
"segments" => %{
"type" => "array",
"description" =>
"Segments within this data point. Required for stacked-bar and heatmap charts.",
"items" => %{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string"},
"value" => %{"type" => "number"}
},
"required" => ["label", "value"]
}
}
},
"required" => ["label"]
}
}
},
"required" => ["chartType", "series"]
}
end
defp render_form_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"fields" => %{"type" => "array"},
"submitLabel" => %{"type" => "string"},
"submitAction" => %{"type" => "string"}
}
}
end
defp render_card_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"subtitle" => %{"type" => "string"},
"body" => %{"type" => "string"},
"actions" => %{"type" => "array"}
}
}
end
defp render_metric_schema do
%{
"type" => "object",
"properties" => %{
"label" => %{"type" => "string"},
"value" => %{"type" => "string"}
}
}
end
defp render_list_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"items" => %{"type" => "array"}
}
}
end
defp render_tabs_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"tabs" => %{"type" => "array"}
}
}
end
defp render_mindmap_schema do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"nodes" => %{"type" => "array"}
}
}
end
defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
defp normalize_limit(_value), do: 10
defp normalize_offset(value) when is_integer(value) and value >= 0, do: value
defp normalize_offset(_value), do: 0
defp search_filters(arguments) do
%{}
|> maybe_put(:category, arguments["category"])
|> maybe_put(:tags, arguments["tags"])
|> maybe_put(:language, arguments["language"])
|> maybe_put(:missing_translation_language, arguments["missingTranslationLanguage"])
|> maybe_put(:year, arguments["year"])
|> maybe_put(:month, arguments["month"])
|> maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|> Map.put(:offset, normalize_offset(arguments["offset"]))
|> Map.put(:limit, normalize_limit(arguments["limit"]))
end
defp search_all_counted_posts(project_id, arguments) do
filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
filters = Map.put(filters, :limit, max(total, 1))
{:ok, result} = Search.search_posts(project_id, "", filters)
result
end
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, _key, ""), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp counted_terms(project_id, field) do defp counted_terms(project_id, field) do
Repo.all( Repo.all(
from post in Post, where: post.project_id == ^project_id, select: field(post, ^field) from post in Post, where: post.project_id == ^project_id, select: field(post, ^field)
@@ -909,7 +630,11 @@ defmodule BDS.AI.ChatTools do
defp metadata_attrs(arguments, keys) do defp metadata_attrs(arguments, keys) do
Enum.reduce(keys, %{}, fn key, acc -> Enum.reduce(keys, %{}, fn key, acc ->
maybe_put(acc, String.to_atom(key), arguments[key]) BDS.MapUtils.maybe_put(
acc,
BDS.MapUtils.safe_atomize_key(key),
BDS.MapUtils.blank_to_nil(arguments[key])
)
end) end)
end end

View File

@@ -1,58 +1,150 @@
defmodule BDS.AI.HttpClient do defmodule BDS.AI.HttpClient do
@moduledoc false @moduledoc """
Req-based HTTP client for AI endpoints.
def get(url, headers) when is_binary(url) and is_map(headers) do Replaces the previous `:httpc` wrapper with explicit connect/receive
request = timeouts, TLS verification via Req's defaults, and transient retries for
{String.to_charlist(url), idempotent GETs only — POSTs (chat completions) are never retried.
Enum.map(headers, fn {key, value} ->
{String.to_charlist(key), String.to_charlist(value)}
end)}
:inets.start() The response contract is unchanged: `{:ok, %{status, headers, body}}` with
:ssl.start() 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`.
case :httpc.request(:get, request, [], body_format: :binary) do Config (`config :bds, BDS.AI.HttpClient`):
{:ok, {{_version, status, _reason}, response_headers, body}} ->
{:ok,
%{
status: status,
headers: normalize_headers(response_headers),
body: body
}}
{:error, reason} -> * `:connect_timeout_ms` — TCP/TLS connect budget (default 5_000)
{:error, reason} * `:receive_timeout_ms` — response budget (default 120_000; generous
end 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 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
[
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) def post(url, headers, body)
when is_binary(url) and is_map(headers) and is_binary(body) do when is_binary(url) and is_map(headers) and is_binary(body) do
request = [
{String.to_charlist(url), method: :post,
Enum.map(headers, fn {key, value} -> url: url,
{String.to_charlist(key), String.to_charlist(value)} headers: headers,
end), ~c"application/json", body} 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() @doc """
:ssl.start() 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 Never retried (same reasoning as `post/3`), and `accept-encoding` is
{:ok, {{_version, status, _reason}, response_headers, response_body}} -> disabled so event-stream chunks arrive uncompressed. The request runs in
{:ok, the calling process — killing that process aborts the underlying
%{ connection, which is what makes mid-stream chat cancellation work.
status: status, """
headers: normalize_headers(response_headers), @spec post_stream(String.t(), %{String.t() => String.t()}, binary(), acc, (binary(), acc ->
body: response_body 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} ->
{:error, reason} {:error, reason}
end end
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 defp normalize_headers(headers) do
Enum.into(headers, %{}, fn {key, value} -> Map.new(headers, fn {name, values} -> {name, Enum.join(List.wrap(values), ", ")} end)
{key |> to_string() |> String.downcase(), to_string(value)} end
end)
defp config(key, default) do
Application.get_env(:bds, __MODULE__, []) |> Keyword.get(key, default)
end end
end end

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ defmodule BDS.AI.OneShot do
require Logger require Logger
alias BDS.AI.Chat alias BDS.AI.Chat
alias BDS.AI.JsonContent
alias BDS.AI.OpenAICompatibleRuntime alias BDS.AI.OpenAICompatibleRuntime
alias BDS.AI.Runtime alias BDS.AI.Runtime
alias BDS.Media.Media alias BDS.Media.Media
@@ -166,28 +167,13 @@ defmodule BDS.AI.OneShot do
end end
end end
defp run_one_shot(:analyze_image = operation, payload, opts, formatter) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
:ok <- Runtime.validate_target(operation, model, mode),
{:ok, payload} <- resolve_image_data_url(payload),
request <- build_one_shot_request(operation, payload, model, opts),
{:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
{:ok, json} <- extract_json_response(response),
usage <- Chat.normalize_usage(response.usage),
{:ok, result} <- formatter.(json, usage) do
{:ok, result}
end
end
defp run_one_shot(operation, payload, opts, formatter) do defp run_one_shot(operation, payload, opts, formatter) do
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime) runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts), with {:ok, endpoint, model, mode} <- Runtime.resolve_target(operation, opts),
:ok <- Runtime.validate_target(operation, model, mode), :ok <- Runtime.validate_target(operation, model, mode),
request <- build_one_shot_request(operation, payload, model, opts), {:ok, prepared_payload} <- prepare_one_shot_payload(operation, payload),
request <- build_one_shot_request(operation, prepared_payload, model, opts),
{:ok, response} <- {:ok, response} <-
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts), runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
{:ok, json} <- extract_json_response(response), {:ok, json} <- extract_json_response(response),
@@ -197,6 +183,9 @@ defmodule BDS.AI.OneShot do
end end
end end
defp prepare_one_shot_payload(:analyze_image, payload), do: resolve_image_data_url(payload)
defp prepare_one_shot_payload(_operation, payload), do: {:ok, payload}
defp build_one_shot_request(operation, payload, model, opts) do defp build_one_shot_request(operation, payload, model, opts) do
language = Keyword.get(opts, :language) language = Keyword.get(opts, :language)
@@ -213,7 +202,9 @@ defmodule BDS.AI.OneShot do
messages: [ messages: [
%{ %{
"role" => "system", "role" => "system",
"content" => one_shot_system_prompt(operation, language, source_language) "content" =>
one_shot_system_prompt(operation, language, source_language) <>
" Output raw JSON only, without markdown code fences."
}, },
%{ %{
"role" => "user", "role" => "user",
@@ -351,11 +342,11 @@ defmodule BDS.AI.OneShot do
defp extract_json_response(%{json: json}) when is_map(json), do: {:ok, json} defp extract_json_response(%{json: json}) when is_map(json), do: {:ok, json}
defp extract_json_response(%{content: content}) when is_binary(content) do defp extract_json_response(%{content: content}) when is_binary(content) do
case Jason.decode(content) do case JsonContent.decode(content) do
{:ok, json} when is_map(json) -> json when is_map(json) ->
{:ok, json} {:ok, json}
_other -> nil ->
Logger.error( Logger.error(
"AI extract_json_response failed to parse content as JSON. Content: #{String.slice(content, 0, 1000)}" "AI extract_json_response failed to parse content as JSON. Content: #{String.slice(content, 0, 1000)}"
) )
@@ -406,7 +397,9 @@ defmodule BDS.AI.OneShot do
title: media.title || "", title: media.title || "",
alt: media.alt || "", alt: media.alt || "",
caption: media.caption || "", 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, file_path: media.file_path,
project_id: media.project_id, project_id: media.project_id,
language: media.language || "" language: media.language || ""

View File

@@ -4,14 +4,13 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
require Logger require Logger
alias BDS.AI.HttpClient alias BDS.AI.HttpClient
alias BDS.AI.SSE
def list_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do def list_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
http_client = Keyword.get(opts, :http_client, HttpClient) http_client = Keyword.get(opts, :http_client, HttpClient)
url = models_url(endpoint.url) url = models_url(endpoint.url)
headers = headers = request_headers(endpoint.api_key, false)
%{"accept" => "application/json"}
|> maybe_put_auth(endpoint.api_key)
with {:ok, response} <- http_client.get(url, headers), with {:ok, response} <- http_client.get(url, headers),
200 <- response.status do 200 <- response.status do
@@ -22,25 +21,20 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
end end
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) url = completions_url(endpoint.url)
headers = headers = request_headers(endpoint.api_key, true)
%{ payload = base_payload(request)
"content-type" => "application/json",
"accept" => "application/json"
}
|> maybe_put_auth(endpoint.api_key)
payload = if stream?(request, opts) do
%{ generate_streaming(url, headers, payload, request, Keyword.fetch!(opts, :on_stream))
"model" => request.model, else
"messages" => request.messages, generate_blocking(url, headers, payload, request)
"max_tokens" => request.max_output_tokens end
} end
|> maybe_disable_thinking(request.model)
|> maybe_put_tools(Map.get(request, :tools, []))
defp generate_blocking(url, headers, payload, request) do
payload_json = Jason.encode!(payload) payload_json = Jason.encode!(payload)
Logger.debug( Logger.debug(
@@ -69,18 +63,83 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
result result
{:ok, %{status: status, body: body}} -> {:ok, %{status: status, body: body}} ->
Logger.error( log_http_status_error("AI OpenAI-compatible", status, body)
"AI OpenAI-compatible HTTP error status=#{status} body=#{String.slice(body, 0, 2000)}" http_status_error(status, body)
)
{:error, %{kind: :http_error, status: status, body: body}}
{:error, reason} -> {:error, reason} ->
Logger.error("AI OpenAI-compatible HTTP request failed: #{inspect(reason)}") log_http_request_error("AI OpenAI-compatible", reason)
{:error, %{kind: :http_error, reason: reason}} http_request_error(reason)
end end
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 |> streaming_payload() |> 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} ->
log_http_status_error("AI OpenAI-compatible streaming", status, body)
http_status_error(status, body)
{:error, reason} ->
log_http_request_error("AI OpenAI-compatible streaming", reason)
http_request_error(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 defp normalize_response(body) do
with {:ok, payload} <- decode_json_body(body) do with {:ok, payload} <- decode_json_body(body) do
message = get_in(payload, ["choices", Access.at(0), "message"]) || %{} message = get_in(payload, ["choices", Access.at(0), "message"]) || %{}
@@ -88,22 +147,18 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
tool_calls = normalize_tool_calls(message["tool_calls"] || []) tool_calls = normalize_tool_calls(message["tool_calls"] || [])
usage = normalize_usage(payload["usage"] || %{}) usage = normalize_usage(payload["usage"] || %{})
json = {:ok,
case content do %{
nil -> content: content,
nil json: decode_json_content(content),
tool_calls: tool_calls,
value when is_binary(value) -> usage: usage
case Jason.decode(value) do }}
{:ok, decoded} when is_map(decoded) -> decoded
_other -> nil
end
end
{:ok, %{content: content, json: json, tool_calls: tool_calls, usage: usage}}
end end
end end
defp decode_json_content(content), do: BDS.AI.JsonContent.decode(content)
defp completions_url(url) do defp completions_url(url) do
cond do cond do
String.ends_with?(url, "/chat/completions") -> url String.ends_with?(url, "/chat/completions") -> url
@@ -156,6 +211,44 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
end end
end end
defp request_headers(api_key, include_content_type?) do
%{"accept" => "application/json"}
|> maybe_put_content_type(include_content_type?)
|> maybe_put_auth(api_key)
end
defp maybe_put_content_type(headers, true),
do: Map.put(headers, "content-type", "application/json")
defp maybe_put_content_type(headers, false), do: headers
defp base_payload(request) do
%{
"model" => request.model,
"messages" => request.messages,
"max_tokens" => request.max_output_tokens
}
|> maybe_disable_thinking(request.model)
|> maybe_put_tools(Map.get(request, :tools, []))
end
defp streaming_payload(payload) do
payload
|> Map.put("stream", true)
|> Map.put("stream_options", %{"include_usage" => true})
end
defp log_http_status_error(prefix, status, body) do
Logger.error("#{prefix} HTTP error status=#{status} body=#{String.slice(body, 0, 2000)}")
end
defp log_http_request_error(prefix, reason) do
Logger.error("#{prefix} request failed: #{inspect(reason)}")
end
defp http_status_error(status, body), do: {:error, %{kind: :http_error, status: status, body: body}}
defp http_request_error(reason), do: {:error, %{kind: :http_error, reason: reason}}
defp maybe_put_auth(headers, nil), do: headers defp maybe_put_auth(headers, nil), do: headers
defp maybe_put_auth(headers, ""), do: headers defp maybe_put_auth(headers, ""), do: headers

View File

@@ -107,5 +107,5 @@ defmodule BDS.AI.Runtime do
end end
end end
defp blank?(value), do: value in [nil, ""] defp blank?(value), do: BDS.Values.blank?(value)
end end

View File

@@ -1,25 +1,95 @@
defmodule BDS.AI.SecretBackend do 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" @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 def encrypt(value) when is_binary(value) do
key = secret_key() with {:ok, key} <- secret_key() do
iv = :crypto.strong_rand_bytes(12) iv = :crypto.strong_rand_bytes(12)
{ciphertext, tag} = {ciphertext, tag} =
:crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, value, @aad, true) :crypto.crypto_one_time_aead(:aes_256_gcm, key, iv, value, @aad, true)
{:ok, Base.encode64(iv <> tag <> ciphertext)} {:ok, Base.encode64(iv <> tag <> ciphertext)}
end
end end
@spec decrypt(String.t()) :: {:ok, String.t()} | {:error, term()}
def decrypt(encoded) when is_binary(encoded) do def decrypt(encoded) when is_binary(encoded) do
with {:ok, key} <- secret_key() do
decrypt_with_fallback(encoded, key)
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_fallback(encoded, key) do
case decrypt_with(encoded, key) do
{:ok, _plaintext} = ok -> ok
{:error, :invalid_ciphertext} -> decrypt_legacy(encoded)
end
end
defp decrypt_with(encoded, key) do
with {:ok, binary} <- Base.decode64(encoded), with {:ok, binary} <- Base.decode64(encoded),
<<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary, <<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary,
plaintext when is_binary(plaintext) <- plaintext when is_binary(plaintext) <-
:crypto.crypto_one_time_aead( :crypto.crypto_one_time_aead(
:aes_256_gcm, :aes_256_gcm,
secret_key(), key,
iv, iv,
ciphertext, ciphertext,
@aad, @aad,
@@ -33,9 +103,13 @@ defmodule BDS.AI.SecretBackend do
end end
defp secret_key do defp secret_key do
case Application.get_env(:bds, :ai_secret_key) do case SecretKey.fetch() do
key when is_binary(key) and byte_size(key) >= 32 -> binary_part(key, 0, 32) {:ok, key} ->
_other -> :crypto.hash(:sha256, Atom.to_string(node()) <> ":bds:ai") {:ok, key}
{:error, reason} ->
Logger.error("AI secret key unavailable: #{inspect(reason)}")
{:error, :secret_key_unavailable}
end end
end end
end end

232
lib/bds/ai/secret_key.ex Normal file
View File

@@ -0,0 +1,232 @@
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} ->
fallback_to_key_file("AI secret key: macOS Keychain unavailable", inspect(reason))
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} ->
fallback_to_key_file(
"AI secret key: could not store the key in the Keychain",
"status #{status}: #{String.trim(output)}"
)
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
defp fallback_to_key_file(prefix, detail) do
Logger.warning(prefix <> " (" <> detail <> "); falling back to the key file")
resolve_file()
end
end

View 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
View 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

View File

@@ -5,6 +5,27 @@ defmodule BDS.Application do
@compiled_env Application.compile_env(:bds, :current_env, Mix.env()) @compiled_env Application.compile_env(:bds, :current_env, Mix.env())
# Children that depend on the boot mode (issue #26): desktop mode runs the
# wx window shell, server mode runs headless behind the SSH daemon. Both
# start the loopback HTTP endpoint and the CLI sync watcher.
def mode_children(:server, _env) do
[{BDS.Desktop.Server, []}, BDS.CliSync.Watcher, BDS.Server]
end
# CLI mode (issue #25): a one-shot command run against the shared database.
# No HTTP listener, SSH daemon, window, or sync watcher — the CLI is the
# external writer the watcher in a concurrently running app listens for.
def mode_children(:cli, _env), do: []
# Local TUI: the headless server plus a TUI on the launching terminal, so
# a GUI (via tunnel) and the local terminal can work in parallel. Quitting
# this TUI stops the whole VM — the terminal is the app in this mode.
# SSH-served TUI sessions never get this flag and stay session-local.
def mode_children(:tui, env),
do: mode_children(:server, env) ++ [{BDS.TUI, [stop_vm_on_exit: true]}]
def mode_children(:desktop, env), do: desktop_children(env)
def desktop_children(env \\ nil) def desktop_children(env \\ nil)
def desktop_children(:test) do def desktop_children(:test) do
@@ -25,34 +46,62 @@ defmodule BDS.Application do
@impl true @impl true
def start(_type, _args) do def start(_type, _args) do
children = [ children =
{Phoenix.PubSub, name: BDS.PubSub}, [
{BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()}, {Phoenix.PubSub, name: BDS.PubSub},
BDS.Repo, {BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()},
BDS.RepoBootstrap, BDS.Repo,
BDS.Tasks, BDS.RepoBootstrap,
BDS.Preview, BDS.Tasks,
BDS.Publishing, BDS.AI.InFlight,
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor}, BDS.Preview,
{Task.Supervisor, name: BDS.TCP.TaskSupervisor}, BDS.Publishing,
BDS.Scripting.JobStore, {Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor}, {Task.Supervisor, name: BDS.TCP.TaskSupervisor},
BDS.Scripting.JobSupervisor BDS.Scripting.JobStore,
| desktop_children(current_env()) {Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
] BDS.Scripting.JobSupervisor,
BDS.Embeddings.Index
] ++ embedding_children() ++ mode_children(boot_mode(), current_env())
opts = [strategy: :one_for_one, name: BDS.Supervisor] opts = [strategy: :one_for_one, name: BDS.Supervisor]
Supervisor.start_link(children, opts) Supervisor.start_link(children, opts)
end 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 defp current_env do
Application.get_env(:bds, :current_env_override) || @compiled_env Application.get_env(:bds, :current_env_override) || @compiled_env
end end
# Desktop mode without a graphical display boots the TUI instead
# (issue #33); the log line explains the surprise mode switch.
defp boot_mode do
resolved = BDS.Server.mode()
effective = BDS.Server.effective_mode(resolved)
if effective != resolved do
require Logger
Logger.info("No graphical display available, starting the terminal UI instead")
end
effective
end
defp desktop_window_children do defp desktop_window_children do
if desktop_automation?() do if desktop_automation?() do
[] []
else else
disable_auto_window_menu()
window_opts = window_opts =
BDS.Desktop.MainWindow.window_options( BDS.Desktop.MainWindow.window_options(
menubar: BDS.Desktop.MenuBar, menubar: BDS.Desktop.MenuBar,
@@ -62,17 +111,33 @@ defmodule BDS.Application do
[ [
{Desktop.Window, window_opts}, {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, []},
{BDS.Desktop.RemoteConnection, []}
] ]
end end
end end
# wxOSX injects an untracked "Window" menu into the menubar at install time,
# which races elixir-desktop's async menu populate and ends up at an
# unstable position. We disable it and ship our own :window menu group
# (BDS.UI.MenuBar) placed between Blog and Help.
defp disable_auto_window_menu do
if :os.type() == {:unix, :darwin} do
:wx.set_env(Desktop.Env.wx_env())
:wxMenuBar.setAutoWindowMenu(false)
end
end
defp desktop_automation? do defp desktop_automation? do
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
end 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 defp desktop_secret_key_base do
Application.get_env(:bds, :desktop)[:secret_key_base] || Application.get_env(:bds, :desktop)[:secret_key_base] ||
raise "missing :desktop secret_key_base configuration" Base.encode64(:crypto.strong_rand_bytes(48))
end end
end end

262
lib/bds/blogmark.ex Normal file
View File

@@ -0,0 +1,262 @@
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"
# Input-hardening limits mirrored from the legacy bDS app (shared/blogmark.ts).
@max_title_length 200
@max_url_length 2048
@control_chars ~r/[\x00-\x1F\x7F]/
@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`, `categories` and optional `project_id`, 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 """
Returns the `project_id` a deep link targets, or `nil` when the link carries
none or cannot be parsed. Used by the shell to switch to the link's project
before importing.
"""
@spec deep_link_project_id(String.t()) :: String.t() | nil
def deep_link_project_id(url) when is_binary(url) do
case parse_deep_link(url) do
{:ok, %{"project_id" => project_id}} -> project_id
_other -> nil
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.
When the deep link URL carries a `project_id` query parameter, it takes
precedence over the passed `project_id` so the bookmarklet can target a
specific project regardless of which project is currently active.
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),
target_project <- resolve_target_project(project_id, candidate),
candidate <- apply_default_content(candidate),
{:ok, %{data: data, toasts: toasts, errors: errors}} <-
Transforms.run(target_project, candidate, opts),
data <- apply_default_category(target_project, data),
{:ok, post} <- create_draft(target_project, data) do
{:ok, %{post: post, toasts: toasts, errors: errors}}
end
end
defp candidate_from_query(query) do
params = URI.decode_query(query || "")
project_id =
case Map.get(params, "project_id") do
nil -> nil
"" -> nil
id -> id
end
url = sanitize_http_url(Map.get(params, "url"))
%{
"title" => sanitize_title(Map.get(params, "title"), url_host(url)),
"url" => url,
"content" => optional(params, "content"),
"tags" => list_param(params, "tags"),
"categories" => list_param(params, "categories"),
"project_id" => project_id
}
end
# Strip control characters, trim, cap length; fall back to the URL host when
# blank — mirrors bDS sanitizeTitle. Title/url are single-line fields, so
# control-character stripping is safe here (unlike the multi-line body).
defp sanitize_title(raw, fallback_host) do
title =
raw
|> to_string()
|> strip_control()
|> String.trim()
|> String.slice(0, @max_title_length)
if title == "", do: fallback_host || "", else: title
end
# Accept only http(s) URLs, strip credentials and fragment, enforce a length
# cap; anything else collapses to nil so it can never reach the post body.
defp sanitize_http_url(raw) when is_binary(raw) do
trimmed = raw |> strip_control() |> String.trim()
case URI.parse(trimmed) do
%URI{scheme: scheme, host: host} = uri
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
normalized = URI.to_string(%URI{uri | userinfo: nil, fragment: nil})
if String.length(normalized) <= @max_url_length, do: normalized, else: nil
_ ->
nil
end
end
defp sanitize_http_url(_other), do: nil
defp url_host(url) when is_binary(url), do: URI.parse(url).host
defp url_host(_other), do: nil
defp strip_control(value), do: String.replace(value, @control_chars, "")
# If the deep link carries a project_id, route to that project; otherwise
# use the currently active project passed by the caller. This lets the
# bookmarklet target a specific project even when a different one is open.
defp resolve_target_project(_caller_project_id, %{"project_id" => pid}) when is_binary(pid) do
pid
end
defp resolve_target_project(project_id, _candidate), do: project_id
# Match the legacy bDS app: when the bookmarklet supplies no body, seed the
# post content with a markdown link to the bookmarked page so transforms run
# against the same input. Explicit content always wins.
defp apply_default_content(candidate) do
with nil <- optional_string(Map.get(candidate, "content")),
url when is_binary(url) <- optional_string(Map.get(candidate, "url")) do
title = Map.get(candidate, "title", "")
Map.put(candidate, "content", blogmark_markdown_link(title, url))
else
_ -> candidate
end
end
defp blogmark_markdown_link(title, url) do
"[" <> escape_markdown_link_text(title) <> "](" <> url <> ")"
end
# Mirrors bDS escapeMarkdownLinkText: backslash first, then brackets/parens,
# then collapse newlines to spaces.
defp escape_markdown_link_text(value) do
value
|> to_string()
|> String.trim()
|> String.replace("\\", "\\\\")
|> String.replace("[", "\\[")
|> String.replace("]", "\\]")
|> String.replace("(", "\\(")
|> String.replace(")", "\\)")
|> String.replace(~r/\r?\n/, " ")
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

View File

@@ -1,5 +1,8 @@
defmodule BDS.BoundedAtoms do 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.Registry
alias BDS.UI.MenuBar alias BDS.UI.MenuBar
@@ -51,19 +54,22 @@ defmodule BDS.BoundedAtoms do
:fill_missing_translations, :fill_missing_translations,
:find_duplicates, :find_duplicates,
:generate_sitemap, :generate_sitemap,
:force_render_site,
:validate_site, :validate_site,
:upload_site, :upload_site,
:documentation, :documentation,
:api_documentation, :api_documentation,
:close_tab :close_tab
] ]
@menu_actions MenuBar.default_groups(dev_mode?: true) @menu_actions MenuBar.default_groups(dev_mode?: true, window_menu?: true)
|> Enum.flat_map(fn group -> |> Enum.flat_map(fn group ->
Enum.flat_map(group.items, fn Enum.flat_map(group.items, fn
%{separator: true} -> [] %{separator: true} -> []
%{id: id} -> [id] %{id: id} -> [id]
end) end)
end) end)
@sidebar_view_ids Enum.map(Registry.sidebar_views(), & &1.id)
@editor_route_ids Enum.map(Registry.editor_routes(), & &1.id)
def atom(value, allowed, fallback \\ nil) def atom(value, allowed, fallback \\ nil)
@@ -98,13 +104,9 @@ defmodule BDS.BoundedAtoms do
Enum.find(allowed, fallback, &(Atom.to_string(&1) == value)) Enum.find(allowed, fallback, &(Atom.to_string(&1) == value))
end end
defp sidebar_views do defp sidebar_views, do: @sidebar_view_ids
Enum.map(Registry.sidebar_views(), & &1.id)
end
defp editor_routes do defp editor_routes, do: @editor_route_ids
Enum.map(Registry.editor_routes(), & &1.id)
end
defp normalize_menu_kind("category-archive"), do: "category_archive" defp normalize_menu_kind("category-archive"), do: "category_archive"
defp normalize_menu_kind(value), do: value defp normalize_menu_kind(value), do: value

335
lib/bds/cli.ex Normal file
View File

@@ -0,0 +1,335 @@
defmodule BDS.CLI do
@moduledoc """
Command-line interface for managing content in a bDS2 workspace (issue #25).
Runs inside the release VM (`BDS_MODE=cli`) against the same settings and
cache database as the GUI/TUI application; mutations write
`BDS.CliSync.Notification` rows so a concurrently running app picks them
up through `BDS.CliSync.Watcher`.
The launcher script (`cli/bin/bds-cli` in the release, installable to
`~/.local/bin` via `BDS.CLI.Install`) passes the argv via `BDS_CLI_ARGV`
(unit-separator joined) because `bin/bds eval` cannot forward arguments.
"""
alias BDS.CLI.Commands
@argv_env "BDS_CLI_ARGV"
@argv_separator <<0x1F>>
@doc "Release entry point: run the command from `BDS_CLI_ARGV` and halt."
@spec main() :: no_return()
def main do
System.halt(run(env_argv()))
end
@doc "Decodes the unit-separator-joined argv from the launcher script."
@spec env_argv(String.t() | nil) :: [String.t()]
def env_argv(value \\ System.get_env(@argv_env))
def env_argv(nil), do: []
def env_argv(value), do: String.split(value, @argv_separator, trim: true)
@doc "Parses and executes an argv, returning the process exit code."
@spec run([String.t()]) :: non_neg_integer()
def run(argv) when is_list(argv) do
optimus = optimus()
case Optimus.parse(optimus, argv) do
{:ok, %Optimus.ParseResult{}} ->
print_help(optimus, [])
0
{:ok, subcommand_path, parse_result} ->
execute(subcommand_path, parse_result)
:help ->
print_help(optimus, [])
0
{:help, subcommand_path} ->
print_help(optimus, subcommand_path)
0
:version ->
IO.puts(version())
0
{:error, errors} ->
print_errors(optimus, [], errors)
1
{:error, subcommand_path, errors} ->
print_errors(optimus, subcommand_path, errors)
1
end
end
@doc "Executes a parsed subcommand, returning the process exit code."
@spec execute([atom()], Optimus.ParseResult.t()) :: non_neg_integer()
def execute(subcommand_path, parse_result) do
{:ok, _apps} = Application.ensure_all_started(:bds)
subcommand_path
|> Commands.dispatch(parse_result)
|> report()
end
defp report(:ok), do: 0
defp report({:ok, message}) when is_binary(message) do
IO.puts(message)
0
end
defp report({:error, message}) do
IO.puts(:stderr, "Error: " <> format_error(message))
1
end
defp format_error(message) when is_binary(message), do: message
defp format_error(%{message: message}) when is_binary(message), do: message
defp format_error(other), do: inspect(other)
@repair_parts ~w(post-links media-links thumbnails embeddings search)
@doc "The Optimus command definition (public for tests and help rendering)."
def optimus do
Optimus.new!(
name: "bds-cli",
description: "bDS2 workspace CLI",
about:
"Manages content in a bDS2 workspace using the same settings and " <>
"cache database as the desktop application.",
version: version(),
allow_unknown_args: false,
parse_double_dash: true,
subcommands: [
rebuild: [
name: "rebuild",
about: "Rebuild the caching database from the workspace files",
flags: [
incremental: [
long: "--incremental",
help:
"Run a metadata diff and auto-apply every difference from " <>
"file to database instead of a full rebuild"
]
]
],
repair: [
name: "repair",
about: "Run one of the standard repair tasks outside the full rebuild",
args: [
part: [
value_name: "PART",
help: "One of: " <> Enum.join(@repair_parts, ", "),
required: true,
parser: &parse_repair_part/1
]
]
],
render: [
name: "render",
about: "Render the blog from the current content",
flags: [
incremental: [
long: "--incremental",
help: "Validate the generated output and apply only the differences"
],
force: [
long: "--force",
help: "Full re-render ignoring (but updating) stored content hashes"
]
]
],
upload: [
name: "upload",
about: "Upload the rendered site to the configured host (rsync/scp)"
],
push: [
name: "push",
about: "Push the project repository to its origin"
],
pull: [
name: "pull",
about: "Pull the project repository and update the cache database"
],
post: [
name: "post",
about:
"Create a post from parameters or from JSON on stdin " <>
"(keys: title, content, excerpt, author, language, tags, categories, template)",
flags: [
stdin: [long: "--stdin", help: "Read post data as JSON from stdin"],
no_translate: [
long: "--no-translate",
help: "Skip automatic translation of the created post"
]
],
options: post_content_options()
],
media: [
name: "media",
about:
"Import an image with automatically generated title, alt text, " <>
"caption and translations",
args: [
file: [
value_name: "FILE",
help: "Path of the image file to import",
required: true
]
],
options: [
language: [
long: "--language",
value_name: "LANG",
help: "Language for the generated texts (defaults to the project main language)"
]
]
],
gallery: [
name: "gallery",
about:
"Create a gallery post and import all referenced images as new " <>
"media with generated texts and translations",
allow_unknown_args: true,
flags: [
stdin: [
long: "--stdin",
help: "Read gallery data as JSON from stdin (adds an \"images\" key to post keys)"
]
],
options: post_content_options()
],
config: [
name: "config",
about: "Read and write global preference values",
subcommands: [
get: [
name: "get",
about: "Print a preference value",
args: [key: [value_name: "KEY", help: "Preference key", required: true]]
],
set: [
name: "set",
about: "Set a preference value",
args: [
key: [value_name: "KEY", help: "Preference key", required: true],
value: [value_name: "VALUE", help: "New value", required: true]
]
],
list: [
name: "list",
about: "List all preference keys and values"
]
]
],
project: [
name: "project",
about: "Manage the projects registered in the cache database",
subcommands: [
list: [
name: "list",
about: "List all projects"
],
add: [
name: "add",
about: "Open a project folder and add it to the cache database",
args: [
path: [value_name: "PATH", help: "Project data folder", required: true]
],
options: [
name: [
long: "--name",
value_name: "NAME",
help: "Project name (defaults to the folder name)"
]
]
],
switch: [
name: "switch",
about: "Switch the active project",
args: [
project: [
value_name: "PROJECT",
help: "Project id, slug, or name",
required: true
]
]
]
]
],
tui: [
name: "tui",
about: "Open the app in TUI mode for interactive use"
],
lua: [
name: "lua",
about: "Run a utility (long-running task) Lua script from the database",
allow_unknown_args: true,
args: [
script: [
value_name: "SCRIPT",
help: "Script slug in the active project",
required: true
]
]
]
]
)
end
defp post_content_options do
[
title: [long: "--title", value_name: "TITLE", help: "Post title"],
content: [long: "--content", value_name: "MARKDOWN", help: "Post body (markdown)"],
excerpt: [long: "--excerpt", value_name: "TEXT", help: "Post excerpt"],
author: [long: "--author", value_name: "NAME", help: "Post author"],
language: [
long: "--language",
value_name: "LANG",
help: "Post language (auto-detected from the content when omitted)"
],
template: [long: "--template", value_name: "SLUG", help: "Template slug"],
tags: [long: "--tags", value_name: "TAGS", help: "Comma-separated tags"],
categories: [
long: "--categories",
value_name: "CATEGORIES",
help: "Comma-separated categories"
]
]
end
defp parse_repair_part(value) when value in @repair_parts, do: {:ok, value}
defp parse_repair_part(value),
do: {:error, "unknown repair part #{inspect(value)}; expected one of: #{Enum.join(@repair_parts, ", ")}"}
defp print_help(optimus, subcommand_path) do
optimus
|> Optimus.Help.help(subcommand_path, terminal_width())
|> Enum.each(&IO.puts/1)
end
defp print_errors(optimus, [], errors) do
optimus |> Optimus.Errors.format(errors) |> Enum.each(&IO.puts(:stderr, &1))
end
defp print_errors(optimus, subcommand_path, errors) do
optimus
|> Optimus.Errors.format(subcommand_path, errors)
|> Enum.each(&IO.puts(:stderr, &1))
end
defp terminal_width do
case Optimus.Term.width() do
{:ok, width} -> width
_other -> 80
end
end
defp version do
to_string(Application.spec(:bds, :vsn) || "dev")
end
end

696
lib/bds/cli/commands.ex Normal file
View File

@@ -0,0 +1,696 @@
defmodule BDS.CLI.Commands do
@moduledoc """
Implementations of the `bds-cli` subcommands (issue #25).
Every command runs synchronously against the engine modules the GUI uses
and returns `:ok`, `{:ok, message}`, or `{:error, message}`. Mutations
write `BDS.CliSync` notification rows so a concurrently running app
refreshes its state (see `specs/cli_sync.allium`).
"""
import Ecto.Query
alias BDS.AI
alias BDS.CliSync
alias BDS.Desktop.ShellLive.GalleryImport
alias BDS.Generation
alias BDS.Git
alias BDS.Maintenance
alias BDS.Media
alias BDS.Metadata
alias BDS.Posts
alias BDS.Posts.AutoTranslation
alias BDS.Projects
alias BDS.Publishing
alias BDS.Repo
alias BDS.Scripting
alias BDS.Scripts
alias BDS.Scripts.Script
alias BDS.Search
alias BDS.Settings
alias BDS.Tasks
@site_sections [:core, :single, :category, :tag, :date]
@bulk_entity_types ~w(post media script template)
@gallery_concurrency 2
@poll_interval_ms 250
@doc "Dispatches a parsed subcommand path to its implementation."
@spec dispatch([atom()], Optimus.ParseResult.t()) :: :ok | {:ok, String.t()} | {:error, term()}
def dispatch([:rebuild], %{flags: %{incremental: true}}),
do: with_project(&incremental_rebuild/1)
def dispatch([:rebuild], _parse_result), do: with_project(&full_rebuild/1)
def dispatch([:repair], %{args: %{part: part}}),
do: with_project(&repair(part, &1))
def dispatch([:render], %{flags: %{incremental: true, force: true}}),
do: {:error, "--incremental and --force are mutually exclusive"}
def dispatch([:render], %{flags: %{incremental: true}}),
do: with_project(&incremental_render/1)
def dispatch([:render], %{flags: flags}),
do: with_project(&full_render(&1, flags[:force] == true))
def dispatch([:upload], _parse_result), do: with_project(&upload/1)
def dispatch([:push], _parse_result) do
with_project(fn project ->
with {:ok, result} <- Git.push(project.id) do
print_git_output(result)
{:ok, "Pushed"}
end
end)
end
def dispatch([:pull], _parse_result) do
with_project(fn project ->
with {:ok, result} <- Git.pull(project.id) do
print_git_output(result)
# Update the cache database from the pulled files, like the app's
# incremental rebuild: file→db for every difference plus orphans.
incremental_rebuild(project)
end
end)
end
def dispatch([:post], %{flags: flags, options: options}) do
with_project(fn project ->
with {:ok, attrs} <- post_attrs(flags, options),
{:ok, attrs} <- ensure_language(attrs),
{:ok, post} <- create_post(project, attrs) do
unless flags[:no_translate], do: translate_post(post)
{:ok, "Created post #{post.id} (#{post.slug}, #{post.language})"}
end
end)
end
def dispatch([:media], %{args: %{file: file}, options: options}) do
with_project(fn project ->
with {:ok, metadata} <- Metadata.get_project_metadata(project.id),
:ok <- ensure_files_exist([file]) do
language = present(options[:language]) || metadata.main_language
targets = GalleryImport.translate_targets(metadata, language)
case GalleryImport.import_and_enrich(file, project.id, language, targets) do
{:ok, media, title} ->
:ok = notify("media", media.id, :created)
{:ok, "Imported media #{media.id} (#{title})"}
{:error, reason} ->
{:error, format_reason(reason)}
end
end
end)
end
def dispatch([:gallery], %{flags: flags, options: options, unknown: unknown}) do
with_project(fn project ->
with {:ok, attrs, images} <- gallery_attrs(flags, options, unknown),
:ok <- ensure_files_exist(images),
{:ok, attrs} <- ensure_language(attrs),
{:ok, post} <- create_post(project, attrs) do
import_gallery_images(project, post, images)
:ok = notify("media", "*", :created)
:ok = notify("post", post.id, :updated)
unless flags[:no_translate], do: translate_post(post)
{:ok, "Created gallery post #{post.id} (#{post.slug}) with #{length(images)} images"}
end
end)
end
def dispatch([:config, :get], %{args: %{key: key}}) do
case Settings.get_global_setting(key) do
nil -> {:error, "#{key} is not set"}
value -> {:ok, value}
end
end
def dispatch([:config, :set], %{args: %{key: key, value: value}}) do
with :ok <- Settings.put_global_setting(key, value) do
:ok = notify("setting", key, :updated)
{:ok, "#{key} = #{value}"}
end
end
def dispatch([:config, :list], _parse_result) do
Settings.list_global_settings()
|> Enum.each(fn {key, value} -> IO.puts("#{key}=#{value}") end)
:ok
end
def dispatch([:project, :list], _parse_result) do
Enum.each(Projects.list_projects(), fn project ->
marker = if project.is_active, do: "* ", else: " "
IO.puts("#{marker}#{project.id} #{project.slug} #{project.name}")
end)
:ok
end
def dispatch([:project, :add], %{args: %{path: path}, options: options}) do
expanded = Path.expand(path)
if File.dir?(expanded) do
name = present(options[:name]) || Path.basename(expanded)
with {:ok, project} <- Projects.create_project(%{name: name, data_path: expanded}) do
:ok = notify("project", project.id, :created)
{:ok, "Added project #{project.id} (#{project.slug}); switch with: project switch #{project.slug}"}
end
else
{:error, "#{expanded} is not a directory"}
end
end
def dispatch([:project, :switch], %{args: %{project: reference}}) do
with {:ok, project} <- resolve_project(reference),
{:ok, project} <- Projects.set_active_project(project.id) do
:ok = notify("project", project.id, :updated)
{:ok, "Active project: #{project.name}"}
else
{:error, :not_found} -> {:error, "No project matches #{inspect(reference)}"}
{:error, reason} -> {:error, format_reason(reason)}
end
end
def dispatch([:tui], _parse_result) do
{:error,
"The TUI is started by the bds-cli launcher script (BDS_MODE=tui); " <>
"it is not available through a direct BDS.CLI invocation"}
end
def dispatch([:lua], %{args: %{script: slug}, unknown: script_args}) do
with_project(fn project ->
case Repo.one(
from script in Script,
where: script.project_id == ^project.id and script.slug == ^slug
) do
nil ->
{:error, "No script with slug #{inspect(slug)} in the active project"}
%Script{kind: kind} when kind != :utility ->
{:error, "Script #{inspect(slug)} is a #{kind} script; only utility (task) scripts can be run"}
%Script{enabled: false} ->
{:error, "Script #{inspect(slug)} is disabled"}
script ->
run_lua_job(project, script, script_args)
end
end)
end
# ── rebuild ───────────────────────────────────────────────────────────────
defp full_rebuild(project) do
project.id
|> Maintenance.full_rebuild_steps()
|> Enum.reduce_while(:ok, fn step, :ok ->
IO.puts("==> #{step.name}")
case step.work.(progress_printer()) do
{:error, message} -> {:halt, {:error, message}}
_result -> {:cont, :ok}
end
end)
|> case do
:ok ->
notify_bulk_change()
{:ok, "Rebuild complete"}
error ->
error
end
end
defp incremental_rebuild(project) do
reporter = progress_printer()
{:ok, diff} = Maintenance.metadata_diff(project.id, on_progress: reporter)
items =
Enum.map(diff.diff_reports, &%{entity_type: &1.entity_type, entity_id: &1.entity_id})
orphans = Enum.map(diff.orphan_reports, &%{file_path: &1.file_path})
if items != [] do
{:ok, _repair} =
Maintenance.repair_metadata_diff(project.id, :file_to_db, items, on_progress: reporter)
end
if orphans != [] do
{:ok, _import} =
Maintenance.import_metadata_diff_orphans(project.id, orphans, on_progress: reporter)
end
if items != [] or orphans != [], do: notify_bulk_change()
{:ok,
"Applied #{length(items)} differences and imported #{length(orphans)} orphan files from the filesystem"}
end
# ── repair ────────────────────────────────────────────────────────────────
defp repair("post-links", project) do
:ok = Posts.rebuild_post_links(project.id, on_progress: progress_printer())
:ok = notify("post", "*", :updated)
{:ok, "Post links rebuilt"}
end
defp repair("media-links", project) do
{:ok, result} = Media.rebuild_media_links(project.id, on_progress: progress_printer())
:ok = notify("media", "*", :updated)
{:ok, "Media links rebuilt (#{result.links} links)"}
end
defp repair("thumbnails", project) do
result = Media.regenerate_missing_thumbnails(project.id, on_progress: progress_printer())
:ok = notify("media", "*", :updated)
{:ok, "Missing thumbnails regenerated (#{inspect(Map.get(result, :generated, 0))} generated)"}
end
defp repair("embeddings", project) do
case Maintenance.rebuild_embedding_index(project.id, on_progress: progress_printer()) do
{:error, message} -> {:error, message}
result -> {:ok, "Embedding index rebuilt (#{result.rebuilt_count} posts)"}
end
end
defp repair("search", project) do
:ok = Search.reindex_posts(project.id, on_progress: progress_printer())
:ok = Search.reindex_media(project.id, on_progress: progress_printer())
{:ok, "Search text reindexed"}
end
# ── render ────────────────────────────────────────────────────────────────
defp full_render(project, force?) do
render_opts = if force?, do: [force: true], else: []
Enum.each(@site_sections, fn section ->
IO.puts("==> Rendering #{section}")
{:ok, _result} =
Generation.render_site_section(
project.id,
section,
[on_progress: progress_printer()] ++ render_opts
)
end)
IO.puts("==> Building search index")
{:ok, _index} =
Generation.build_site_search_index(
project.id,
[on_progress: progress_printer()] ++ render_opts
)
{:ok, "Site rendered"}
end
defp incremental_render(project) do
{:ok, report} =
Generation.validate_site(project.id, @site_sections, on_progress: progress_printer())
{:ok, applied} =
Generation.apply_validation(project.id, report, on_progress: progress_printer())
{:ok,
"Validation applied (#{applied.rendered_url_count} rendered, " <>
"#{applied.deleted_url_count} deleted)"}
end
# ── upload ────────────────────────────────────────────────────────────────
defp upload(project) do
with {:ok, metadata} <- Metadata.get_project_metadata(project.id),
{:ok, credentials} <- upload_credentials(metadata.publishing_preferences),
{:ok, job} <- Publishing.upload_site(project.id, credentials) do
await_task(job.task_id, "Upload complete")
end
end
defp upload_credentials(prefs) when is_map(prefs) do
credentials = %{
ssh_host: Map.get(prefs, "ssh_host"),
ssh_user: Map.get(prefs, "ssh_user"),
ssh_remote_path: Map.get(prefs, "ssh_remote_path"),
ssh_mode: Map.get(prefs, "ssh_mode")
}
if Enum.all?(
[credentials.ssh_host, credentials.ssh_user, credentials.ssh_remote_path],
&is_binary/1
) do
{:ok, credentials}
else
{:error, "Publishing preferences are incomplete; configure the upload host in Settings"}
end
end
defp upload_credentials(_prefs),
do: {:error, "Publishing preferences are incomplete; configure the upload host in Settings"}
# ── post / gallery ────────────────────────────────────────────────────────
defp post_attrs(%{stdin: true}, _options) do
with {:ok, data} <- read_stdin_json() do
json_post_attrs(data)
end
end
defp post_attrs(_flags, options) do
case present(options[:title]) do
nil ->
{:error, "--title is required (or pass --stdin with JSON post data)"}
title ->
{:ok,
%{
title: title,
content: options[:content] || "",
excerpt: present(options[:excerpt]),
author: present(options[:author]),
language: present(options[:language]),
template_slug: present(options[:template]),
tags: split_list(options[:tags]),
categories: split_list(options[:categories])
}}
end
end
defp gallery_attrs(%{stdin: true}, _options, _unknown) do
with {:ok, data} <- read_stdin_json(),
{:ok, attrs} <- json_post_attrs(data) do
case Map.get(data, "images") do
images when is_list(images) and images != [] -> {:ok, attrs, images}
_other -> {:error, "JSON gallery data needs a non-empty \"images\" array"}
end
end
end
defp gallery_attrs(flags, options, unknown) do
case unknown do
[] ->
{:error, "Pass the image files as arguments (or use --stdin with JSON gallery data)"}
images ->
with {:ok, attrs} <- post_attrs(flags, options) do
{:ok, attrs, images}
end
end
end
defp json_post_attrs(data) when is_map(data) do
case present(Map.get(data, "title")) do
nil ->
{:error, "JSON post data needs a \"title\""}
title ->
{:ok,
%{
title: title,
content: Map.get(data, "content") || "",
excerpt: present(Map.get(data, "excerpt")),
author: present(Map.get(data, "author")),
language: present(Map.get(data, "language")),
template_slug: present(Map.get(data, "template")),
tags: json_list(Map.get(data, "tags")),
categories: json_list(Map.get(data, "categories"))
}}
end
end
defp json_post_attrs(_data), do: {:error, "JSON post data must be an object"}
defp read_stdin_json do
case IO.read(:stdio, :eof) do
:eof ->
{:error, "No JSON data on stdin"}
{:error, reason} ->
{:error, "Could not read stdin: #{inspect(reason)}"}
data ->
case Jason.decode(data) do
{:ok, decoded} -> {:ok, decoded}
{:error, error} -> {:error, "Invalid JSON on stdin: #{Exception.message(error)}"}
end
end
end
# Language auto-detection (issue #25): ask the configured AI endpoint —
# BDS.AI internally routes to the local model in airplane mode — and fall
# back to the offline search heuristic, informing the user (the CLI
# equivalent of the airplane-mode toast).
defp ensure_language(%{language: language} = attrs) when is_binary(language), do: {:ok, attrs}
defp ensure_language(attrs) do
text = String.trim("#{attrs.title}\n#{attrs.content}")
language =
case AI.detect_language(text) do
{:ok, %{language_code: language}} when is_binary(language) ->
language
_other ->
IO.puts("AI language detection unavailable; using the offline heuristic")
Search.detect_language(text)
end
{:ok, %{attrs | language: language}}
end
defp create_post(project, attrs) do
case Posts.create_post(Map.put(attrs, :project_id, project.id)) do
{:ok, post} ->
:ok = notify("post", post.id, :created)
{:ok, post}
{:error, %Ecto.Changeset{} = changeset} ->
{:error, changeset_message(changeset)}
end
end
# Automatic translation mirrors the GUI: schedule the background tasks and
# wait for them, so the CLI exits only when the work is done. When nothing
# gets scheduled (airplane mode without a local model, or a single-language
# blog) the user is told instead of a silent no-op.
defp translate_post(post) do
before_count = length(active_tasks())
:ok = AutoTranslation.maybe_schedule(post)
case length(active_tasks()) - before_count do
queued when queued > 0 ->
IO.puts("Waiting for #{queued} translation task(s)...")
await_active_tasks()
:ok = notify("post", post.id, :updated)
_none ->
IO.puts("Automatic translation was not scheduled (offline, unconfigured AI, or nothing to translate)")
end
:ok
end
defp import_gallery_images(project, post, images) do
{:ok, metadata} = Metadata.get_project_metadata(project.id)
language = post.language || metadata.main_language
parent = self()
runner =
Task.async(fn ->
GalleryImport.start(
images,
project.id,
post.id,
language,
@gallery_concurrency,
parent
)
end)
print_gallery_progress()
Task.await(runner, :infinity)
end
defp print_gallery_progress do
receive do
{:add_image_processed, title} ->
IO.puts("Imported #{title}")
print_gallery_progress()
{:add_image_error, path, reason} ->
IO.puts(:stderr, "Failed to import #{path}: #{format_reason(reason)}")
print_gallery_progress()
{:add_images_complete, count} ->
IO.puts("Processed #{count} image(s)")
:ok
end
end
defp ensure_files_exist(paths) do
case Enum.reject(paths, &File.regular?/1) do
[] -> :ok
missing -> {:error, "No such file: #{Enum.join(missing, ", ")}"}
end
end
# ── lua ───────────────────────────────────────────────────────────────────
# Utility ("task") scripts run with the managed-job execution budget
# (unlimited time/reductions) but synchronously — the CLI is the one
# waiting for the long-running activity.
defp run_lua_job(project, script, args) do
scripting_config = Application.fetch_env!(:bds, :scripting)
case Scripting.execute_project_script(
project.id,
Scripts.resolved_content(script),
script.entrypoint || "main",
args,
timeout: Keyword.get(scripting_config, :job_timeout, :infinity),
max_reductions: Keyword.get(scripting_config, :job_max_reductions, :none),
on_output: fn text -> IO.puts(text) end
) do
{:ok, nil} -> {:ok, "Script finished"}
{:ok, result} -> {:ok, "Script finished: #{format_reason(result)}"}
{:error, reason} -> {:error, "Script failed: #{format_reason(reason)}"}
end
end
# ── shared helpers ────────────────────────────────────────────────────────
defp with_project(fun) do
case Projects.get_active_project() do
nil -> {:error, "No active project selected; use: project switch <project>"}
project -> fun.(project)
end
end
defp resolve_project(reference) do
projects = Projects.list_projects()
found =
Enum.find(projects, fn project ->
reference in [project.id, project.slug, project.name]
end)
if found, do: {:ok, found}, else: {:error, :not_found}
end
defp await_task(task_id, success_message) do
case Tasks.get_task(task_id) do
nil ->
{:error, "Task disappeared before completion"}
%{status: :completed} ->
{:ok, success_message}
%{status: :failed} = task ->
{:error, format_reason(task.error || "task failed")}
%{message: message} = task ->
print_progress(task.progress || 0.0, message)
Process.sleep(@poll_interval_ms)
await_task(task_id, success_message)
end
end
defp active_tasks do
Enum.filter(Tasks.list_tasks(), &(&1.status in [:pending, :running]))
end
defp await_active_tasks do
case active_tasks() do
[] ->
:ok
_tasks ->
Process.sleep(@poll_interval_ms)
await_active_tasks()
end
end
@doc """
A 2-arity progress callback that prints deduplicated `[ 42%] message`
lines to stdout.
"""
def progress_printer do
&print_progress/2
end
defp print_progress(value, message) do
percent = value |> Kernel.*(100) |> round() |> min(100) |> max(0)
line = {percent, message}
if Process.get({__MODULE__, :last_progress}) != line do
Process.put({__MODULE__, :last_progress}, line)
if is_binary(message) and message != "" do
IO.puts("[#{String.pad_leading(Integer.to_string(percent), 3)}%] #{message}")
end
end
:ok
end
defp print_git_output(%{output: output}) when is_binary(output) and output != "" do
IO.puts(String.trim_trailing(output))
end
defp print_git_output(_result), do: :ok
defp notify(entity_type, entity_id, action) do
{:ok, _notification} = CliSync.cli_mutation_performed(entity_type, entity_id, action)
:ok
end
# Bulk maintenance touched an unknown set of rows; a wildcard notification
# per entity type makes a running app reload its lists.
defp notify_bulk_change do
Enum.each(@bulk_entity_types, &notify(&1, "*", :updated))
end
defp split_list(nil), do: []
defp split_list(value) when is_binary(value) do
value
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp json_list(value) when is_list(value), do: Enum.map(value, &to_string/1)
defp json_list(value) when is_binary(value), do: split_list(value)
defp json_list(_value), do: []
defp present(nil), do: nil
defp present(value) when is_binary(value) do
case String.trim(value) do
"" -> nil
trimmed -> trimmed
end
end
defp changeset_message(%Ecto.Changeset{} = changeset) do
changeset
|> Ecto.Changeset.traverse_errors(fn {message, opts} ->
Enum.reduce(opts, message, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", to_string(value))
end)
end)
|> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
end
defp format_reason(reason) when is_binary(reason), do: reason
defp format_reason(%{message: message}) when is_binary(message), do: message
defp format_reason(%{guidance: guidance}) when is_binary(guidance), do: guidance
defp format_reason(reason), do: inspect(reason)
end

57
lib/bds/cli/install.ex Normal file
View File

@@ -0,0 +1,57 @@
defmodule BDS.CLI.Install do
@moduledoc """
Installs the `bds-cli` launcher into `~/.local/bin` (issue #25), used by
the install buttons in the GUI and TUI settings.
The installed file is a two-line shim exec'ing the release's
`cli/bin/bds-cli` launcher (shipped via `rel/overlays`), so the CLI always
runs the same release — and therefore the same settings and cache
database — as the installed application.
"""
@launcher_name "bds-cli"
@doc """
Writes the `~/.local/bin/bds-cli` shim. Returns `{:ok, installed_path}`,
or `{:error, :no_release}` when not running from a packaged release (dev
runs have no launcher to point at).
"""
@spec install(keyword()) :: {:ok, Path.t()} | {:error, :no_release | File.posix()}
def install(opts \\ []) do
bin_dir = Keyword.get(opts, :bin_dir, Path.expand("~/.local/bin"))
with {:ok, launcher} <- launcher_path(Keyword.get(opts, :release_root)) do
installed = Path.join(bin_dir, @launcher_name)
with :ok <- File.mkdir_p(bin_dir),
:ok <- File.write(installed, shim_script(launcher)),
:ok <- File.chmod(installed, 0o755) do
{:ok, installed}
end
end
end
@doc """
The release's CLI launcher script, resolved from `RELEASE_ROOT` (set by
release scripts) or the code root dir (the release root when running a
release). `{:error, :no_release}` when neither contains the launcher.
"""
@spec launcher_path(Path.t() | nil) :: {:ok, Path.t()} | {:error, :no_release}
def launcher_path(release_root \\ nil) do
[release_root, System.get_env("RELEASE_ROOT"), to_string(:code.root_dir())]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.map(&Path.join([&1, "cli", "bin", @launcher_name]))
|> Enum.find(&File.regular?/1)
|> case do
nil -> {:error, :no_release}
path -> {:ok, path}
end
end
defp shim_script(launcher) do
"""
#!/bin/sh
exec "#{launcher}" "$@"
"""
end
end

View File

@@ -54,6 +54,11 @@ defmodule BDS.CliSync do
end)} end)}
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 def prune_notifications(now \\ Persistence.now_ms()) when is_integer(now) do
{processed_count, _} = {processed_count, _} =
Repo.delete_all( Repo.delete_all(

View File

@@ -29,7 +29,12 @@ defmodule BDS.CliSync.Watcher do
Keyword.get(opts, :poll_interval_ms), Keyword.get(opts, :poll_interval_ms),
@default_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)} {:ok, schedule_poll(state)}
@@ -49,18 +54,24 @@ defmodule BDS.CliSync.Watcher do
end end
defp process_notifications(state) do defp process_notifications(state) do
{:ok, notifications} = CliSync.db_file_change_detected() current_data_version = state.data_version_reader.()
{:ok, _pruned} = CliSync.prune_notifications()
Enum.each(notifications, fn notification -> if state.last_data_version == current_data_version do
Phoenix.PubSub.broadcast( %{state | last_data_version: current_data_version}
state.pubsub, else
topic(), {:ok, notifications} = state.notification_fetcher.()
{:entity_changed, notification_payload(notification)} {:ok, _pruned} = state.pruner.()
)
end)
state Enum.each(notifications, fn notification ->
Phoenix.PubSub.broadcast(
state.pubsub,
topic(),
{:entity_changed, notification_payload(notification)}
)
end)
%{state | last_data_version: current_data_version}
end
end end
defp notification_payload(notification) do defp notification_payload(notification) do

View File

@@ -2,6 +2,7 @@ defmodule BDS.Desktop.Automation do
@moduledoc false @moduledoc false
use GenServer use GenServer
require Logger
@ready_timeout 60_000 @ready_timeout 60_000
@request_timeout 30_000 @request_timeout 30_000
@@ -13,7 +14,9 @@ defmodule BDS.Desktop.Automation do
def stop_session(session) do def stop_session(session) do
GenServer.stop(session, :normal, @request_timeout) GenServer.stop(session, :normal, @request_timeout)
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed desktop automation stop_session exit: #{inspect(reason)}")
:ok
end end
def snapshot(session) do def snapshot(session) do
@@ -58,7 +61,6 @@ defmodule BDS.Desktop.Automation do
base_url = BDS.Desktop.url(port) base_url = BDS.Desktop.url(port)
File.mkdir_p!(screenshot_dir) File.mkdir_p!(screenshot_dir)
ensure_http_client_started()
app_port = start_app_process(project_root, port) app_port = start_app_process(project_root, port)
:ok = wait_for_server(base_url) :ok = wait_for_server(base_url)
@@ -221,7 +223,12 @@ defmodule BDS.Desktop.Automation do
defp safe_driver_request(state, payload) do defp safe_driver_request(state, payload) do
driver_request(state, payload) driver_request(state, payload)
rescue rescue
_error -> :ok error ->
Logger.debug(
"swallowed desktop automation safe_driver_request error: #{inspect(error)} payload=#{inspect(payload)}"
)
:ok
end end
defp shutdown_driver(state) do defp shutdown_driver(state) do
@@ -319,8 +326,14 @@ defmodule BDS.Desktop.Automation do
end end
defp do_wait_for_server(base_url, deadline) do defp do_wait_for_server(base_url, deadline) do
case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do case Req.request(
{:ok, {{_, 200, _}, _headers, _body}} -> method: :get,
url: base_url <> "health",
retry: false,
connect_options: [timeout: 1_000],
receive_timeout: 1_000
) do
{:ok, %Req.Response{status: 200}} ->
:ok :ok
_other -> _other ->
@@ -340,12 +353,6 @@ defmodule BDS.Desktop.Automation do
port port
end 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(nil, _timeout), do: :ok
defp await_port_exit(port, timeout) do defp await_port_exit(port, timeout) do
@@ -369,19 +376,15 @@ defmodule BDS.Desktop.Automation do
defp safe_close_port(port) do defp safe_close_port(port) do
Port.close(port) Port.close(port)
catch catch
:error, _reason -> :ok :error, reason ->
Logger.debug("swallowed desktop automation safe_close_port error: #{inspect(reason)}")
:ok
end end
defp normalize_simple_reply("ok"), do: :ok defp normalize_simple_reply("ok"), do: :ok
defp normalize_simple_reply(reply), do: reply defp normalize_simple_reply(reply), do: reply
defp atomize_map(map) when is_map(map) do defp atomize_map(map) when is_map(map), do: BDS.MapUtils.safe_atomize_keys(map)
Enum.into(map, %{}, fn {key, value} ->
normalized_key = if is_binary(key), do: String.to_atom(key), else: key
normalized_value = if is_map(value), do: atomize_map(value), else: value
{normalized_key, normalized_value}
end)
end
defp project_root do defp project_root do
Path.expand("../../..", __DIR__) Path.expand("../../..", __DIR__)

View File

@@ -0,0 +1,117 @@
defmodule BDS.Desktop.DeepLink do
@moduledoc """
Receives OS URL-scheme events for the `bds2://` scheme and routes them to the
live 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`
relays erlang `:wx`'s `{:open_url, Url}` app event (where `Url` is the URL as a
charlist). This GenServer subscribes to those events and forwards recognised
`bds2://` links to the live shell.
Cold start matters here: when the bookmarklet *launches* the app, the deep
link arrives during startup, before the LiveView shell's websocket has
connected. Such links are queued and replayed the moment a shell registers via
`attach/2`; while a shell is connected, links are delivered to it directly.
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
@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
@doc """
Registers the live shell `pid` as the deep-link sink and immediately replays
any links that arrived before a shell was connected. Future links are sent to
this pid until it dies, after which links queue again.
"""
@spec attach(pid(), GenServer.server()) :: :ok
def attach(shell_pid, server \\ __MODULE__) when is_pid(shell_pid) do
GenServer.call(server, {:attach, shell_pid})
end
@impl true
def init(_opts) do
subscribe_to_env()
{:ok, %{shell: nil, pending: []}}
end
@impl true
def handle_call({:attach, pid}, _from, state) do
Process.monitor(pid)
state.pending
|> Enum.reverse()
|> Enum.each(&send(pid, {:blogmark_deep_link, &1}))
{:reply, :ok, %{state | shell: pid, pending: []}}
end
# erlang's :wx delivers macOS app events as `{:open_url, Url}` where `Url` is
# the URL *string itself* — a charlist on current OTP, not a list of URLs
# (see `wx:subscribe_events/0`). Desktop.Env forwards it unchanged, so the
# payload must be normalised to a binary before routing.
@impl true
def handle_info({:open_url, payload}, state) do
{:noreply, route(to_url(payload), state)}
end
def handle_info({:DOWN, _ref, :process, pid, _reason}, %{shell: pid} = state) do
{:noreply, %{state | shell: nil}}
end
def handle_info(_message, state), do: {:noreply, state}
# Accept the charlist wx actually sends, a bare binary, or a [binary | _] list
# (the shape Desktop.Env's docs imply), normalising all to a binary URL.
defp to_url(value) when is_binary(value), do: value
defp to_url([first | _]) when is_binary(first), do: first
defp to_url(value) when is_list(value), do: List.to_string(value)
defp to_url(_other), do: ""
defp route(url, state) do
cond do
not String.starts_with?(url, @scheme) ->
Logger.debug("ignoring non-bds2 deep link: #{inspect(url)}")
state
is_pid(state.shell) and Process.alive?(state.shell) ->
send(state.shell, {:blogmark_deep_link, url})
state
true ->
# No shell connected yet (cold start) — hold the link until one attaches.
%{state | pending: [url | state.pending]}
end
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 ->
Logger.debug("swallowed deep_link Desktop.Env.subscribe exit: #{inspect(reason)}")
:ok
end
end
:ok
end
end

View File

@@ -0,0 +1,52 @@
defmodule BDS.Desktop.Dialogs do
@moduledoc false
# Native single-line text prompt, same osascript approach as
# BDS.Desktop.FilePicker. Returns {:ok, text} | :cancel | {:error, map}.
def prompt_text(message, default \\ "") when is_binary(message) do
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
:cancel
else
case :os.type() do
{:unix, :darwin} -> prompt_text_macos(message, default)
_other -> {:error, %{message: "Dialogs are only supported on macOS desktop"}}
end
end
end
def alert(title, message) when is_binary(title) and is_binary(message) do
if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" or :os.type() != {:unix, :darwin} do
:ok
else
script = "display alert \"#{escape(title)}\" message \"#{escape(message)}\""
_ = System.cmd("osascript", ["-e", script], stderr_to_stdout: true)
:ok
end
end
defp prompt_text_macos(message, default) do
script =
"text returned of (display dialog \"#{escape(message)}\" default answer \"#{escape(default)}\")"
case System.cmd("osascript", ["-e", script], stderr_to_stdout: true) do
{output, 0} -> {:ok, String.trim(output)}
{output, _status} -> normalize_failure(output)
end
end
defp normalize_failure(output) do
message = String.trim(output)
if message == "" or String.contains?(String.downcase(message), "canceled") do
:cancel
else
{:error, %{message: message}}
end
end
defp escape(value) do
value
|> String.replace("\\", "\\\\")
|> String.replace("\"", "\\\"")
end
end

View File

@@ -17,19 +17,14 @@ defmodule BDS.Desktop.Endpoint do
plug(Plug.Static, plug(Plug.Static,
at: "/assets", at: "/assets",
from: {:bds, "priv/static/assets"}, from: {:bds, "priv/static/assets"},
only: ["app.css", "app.js"] only: ["app.css", "app.js", "monaco.css", "monaco.js", "monaco"]
)
plug(Plug.Static,
at: "/monaco",
from: {:bds, "priv/ui/monaco"},
only: ["vs"]
) )
plug(BDS.Desktop.Router) plug(BDS.Desktop.Router)
defp maybe_require_desktop_auth(conn, _opts) do defp maybe_require_desktop_auth(conn, _opts) do
if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] do if System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"] or
not BDS.Server.desktop_auth_required?() do
conn conn
else else
Desktop.Auth.call(conn, []) Desktop.Auth.call(conn, [])

View File

@@ -1,12 +0,0 @@
defmodule BDS.Desktop.ExternalLinks do
@moduledoc false
@github_url "https://github.com/rfc1437/bDS2"
@github_issues_url "#{@github_url}/issues"
@spec github_url() :: String.t()
def github_url, do: @github_url
@spec github_issues_url() :: String.t()
def github_issues_url, do: @github_issues_url
end

View File

@@ -2,9 +2,24 @@ defmodule BDS.Desktop.FilePicker do
@moduledoc false @moduledoc false
def choose_file(prompt) when is_binary(prompt) do def choose_file(prompt) when is_binary(prompt) do
case :os.type() do if System.get_env("BDS_DESKTOP_AUTOMATION") == "1" do
{:unix, :darwin} -> choose_file_macos(prompt) :cancel
_other -> {:error, %{message: "File selection is only supported on macOS desktop"}} else
case :os.type() do
{:unix, :darwin} -> choose_file_macos(prompt)
_other -> {:error, %{message: "File selection is only supported on macOS desktop"}}
end
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
end end
@@ -17,6 +32,50 @@ defmodule BDS.Desktop.FilePicker do
end end
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 defp normalize_picker_failure(output) do
message = String.trim(output) message = String.trim(output)

View File

@@ -13,6 +13,7 @@ defmodule BDS.Desktop.Layouts do
<title><%= @page_title || "Blogging Desktop Server" %></title> <title><%= @page_title || "Blogging Desktop Server" %></title>
<meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} /> <meta name="csrf-token" content={Phoenix.Controller.get_csrf_token()} />
<link phx-track-static rel="stylesheet" href="/assets/app.css" /> <link phx-track-static rel="stylesheet" href="/assets/app.css" />
<link phx-track-static rel="stylesheet" href="/assets/monaco.css" />
</head> </head>
<body> <body>
<%= @inner_content %> <%= @inner_content %>

View File

@@ -4,6 +4,7 @@ defmodule BDS.Desktop.MainWindow do
use GenServer use GenServer
alias Desktop.Window alias Desktop.Window
require Logger
@window_id __MODULE__ @window_id __MODULE__
@server_name BDS.Desktop.MainWindow.Watcher @server_name BDS.Desktop.MainWindow.Watcher
@@ -22,7 +23,9 @@ defmodule BDS.Desktop.MainWindow do
def persist_now(timeout \\ 100) do def persist_now(timeout \\ 100) do
GenServer.call(@server_name, :persist_bounds_now, timeout) GenServer.call(@server_name, :persist_bounds_now, timeout)
catch catch
:exit, _reason -> :ok :exit, reason ->
Logger.debug("swallowed main_window persist_now exit: #{inspect(reason)}")
:ok
end end
def window_options(extra_opts \\ []) do def window_options(extra_opts \\ []) do
@@ -169,10 +172,17 @@ defmodule BDS.Desktop.MainWindow do
end end
end) end)
rescue rescue
ErlangError -> nil error in ErlangError ->
FunctionClauseError -> nil Logger.debug("swallowed main_window restore_bounds error: #{inspect(error)}")
nil
error in FunctionClauseError ->
Logger.debug("swallowed main_window restore_bounds error: #{inspect(error)}")
nil
catch catch
:exit, _reason -> nil :exit, reason ->
Logger.debug("swallowed main_window restore_bounds exit: #{inspect(reason)}")
nil
end end
end end
@@ -190,7 +200,7 @@ defmodule BDS.Desktop.MainWindow do
end end
defp config_dir do 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 when is_list(path) -> List.to_string(path)
path -> path path -> path
end end

View File

@@ -8,16 +8,45 @@ defmodule BDS.Desktop.MediaController do
alias BDS.Projects alias BDS.Projects
alias BDS.Repo alias BDS.Repo
def thumbnail(conn, %{"media_id" => media_id} = params) do @web_image_types ~w(image/jpeg image/png image/webp image/gif image/svg+xml image/avif)
case active_media_thumbnail(media_id, Map.get(params, "size")) do
{:ok, content_type, path} ->
conn
|> Plug.Conn.put_resp_content_type(content_type)
|> Plug.Conn.send_file(200, path)
:error -> def thumbnail(conn, %{"media_id" => media_id} = params) do
send_resp(conn, 404, "not found") send_media(conn, active_media_thumbnail(media_id, Map.get(params, "size")))
end
def file(conn, %{"media_id" => media_id}) do
send_media(conn, active_media_file(media_id))
end
defp send_media(conn, {:ok, content_type, path}) do
conn
|> Plug.Conn.put_resp_content_type(content_type)
|> Plug.Conn.send_file(200, path)
end
defp send_media(conn, :error), do: send_resp(conn, 404, "not found")
# Serves the original image at full resolution; formats browsers cannot
# render inline fall back to the large thumbnail.
defp active_media_file(media_id) do
with %{} = project <- Projects.get_active_project(),
%MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
true <- media.project_id == project.id,
true <- media.mime_type in @web_image_types,
absolute_path = Path.join(Projects.project_data_dir(project), media.file_path),
true <- File.exists?(absolute_path) do
{:ok, media.mime_type, absolute_path}
else
_other -> active_media_thumbnail(media_id, "large")
end end
rescue
error in [Exqlite.Error, DBConnection.OwnershipError] ->
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end
:error
end end
defp active_media_thumbnail(media_id, size) do defp active_media_thumbnail(media_id, size) do

View File

@@ -2,7 +2,7 @@ defmodule BDS.Desktop.MenuBar do
@moduledoc false @moduledoc false
use BDS.Desktop.MenuCompat use BDS.Desktop.MenuCompat
alias BDS.Desktop.{ExternalLinks, ShellData, Shutdown, UILocale} alias BDS.Desktop.{ShellData, Shutdown, UILocale}
alias BDS.UI.Commands alias BDS.UI.Commands
alias BDS.UI.MenuBar, as: ShellMenuBar alias BDS.UI.MenuBar, as: ShellMenuBar
alias Desktop.OS alias Desktop.OS
@@ -25,12 +25,7 @@ defmodule BDS.Desktop.MenuBar do
def mount(menu) do def mount(menu) do
UILocale.put(ShellData.ui_language()) UILocale.put(ShellData.ui_language())
{:ok, {:ok, Desktop.Menu.assign(menu, :groups, groups(native_menu_opts()))}
Desktop.Menu.assign(
menu,
:groups,
groups(dev_mode?: Application.get_env(:bds, :dev_routes, false))
)}
end end
@impl true @impl true
@@ -59,7 +54,7 @@ defmodule BDS.Desktop.MenuBar do
end end
def handle_event("view_on_github", menu) do def handle_event("view_on_github", menu) do
OS.launch_default_browser(ExternalLinks.github_url()) OS.launch_default_browser("https://github.com/rfc1437/bDS2")
{:noreply, menu} {:noreply, menu}
end end
@@ -72,13 +67,60 @@ defmodule BDS.Desktop.MenuBar do
{:noreply, menu} {:noreply, menu}
end end
def handle_event("connect_server", menu) do
prompt =
dgettext("ui", "Server address (user@host or user@host:port), public-key auth:")
with {:ok, target} <- BDS.Desktop.Dialogs.prompt_text(prompt, "user@host"),
{:ok, url} <- BDS.Desktop.RemoteConnection.connect(target) do
Window.show(BDS.Desktop.MainWindow.window_id(), url)
else
:cancel ->
:ok
{:error, reason} ->
BDS.Desktop.Dialogs.alert(
dgettext("ui", "Connect to Server"),
dgettext("ui", "Connection failed: %{reason}",
reason: BDS.Desktop.RemoteConnection.format_reason(reason)
)
)
end
{:noreply, menu}
end
def handle_event("disconnect_server", menu) do
:ok = BDS.Desktop.RemoteConnection.disconnect()
Window.show(BDS.Desktop.MainWindow.window_id(), BDS.Desktop.url())
{:noreply, menu}
end
def handle_event("open_data_folder", menu) do def handle_event("open_data_folder", menu) do
_ = BDS.Desktop.ShellCommands.execute("open_data_folder") _ = BDS.Desktop.ShellCommands.execute("open_data_folder")
{:noreply, menu} {:noreply, menu}
end end
def handle_event("report_issue", menu) do def handle_event("report_issue", menu) do
OS.launch_default_browser(ExternalLinks.github_issues_url()) OS.launch_default_browser("https://github.com/rfc1437/bDS2/issues")
{:noreply, menu}
end
def handle_event("minimize", menu) do
with_frame(&:wxTopLevelWindow.iconize(&1, iconize: true))
{:noreply, menu}
end
def handle_event("zoom", menu) do
with_frame(fn frame ->
:wxTopLevelWindow.maximize(frame, maximize: not :wxTopLevelWindow.isMaximized(frame))
end)
{:noreply, menu}
end
def handle_event("bring_all_to_front", menu) do
with_frame(&:wxWindow.raise/1)
{:noreply, menu} {:noreply, menu}
end end
@@ -91,12 +133,7 @@ defmodule BDS.Desktop.MenuBar do
def handle_info({:set_ui_locale, locale}, menu) do def handle_info({:set_ui_locale, locale}, menu) do
UILocale.put(locale) UILocale.put(locale)
{:noreply, {:noreply, Desktop.Menu.assign(menu, :groups, groups(native_menu_opts()))}
Desktop.Menu.assign(
menu,
:groups,
groups(dev_mode?: Application.get_env(:bds, :dev_routes, false))
)}
end end
def handle_info(_, menu) do def handle_info(_, menu) do
@@ -123,6 +160,32 @@ defmodule BDS.Desktop.MenuBar do
end end
end end
defp native_menu_opts do
[
dev_mode?: Application.get_env(:bds, :dev_routes, false),
window_menu?: OS.type() == MacOS
]
end
defp with_frame(fun) do
case frame() do
nil ->
:ok
frame ->
:wx.set_env(Desktop.Env.wx_env())
fun.(frame)
end
end
defp frame do
try do
Window.frame(BDS.Desktop.MainWindow.window_id())
catch
:exit, _ -> nil
end
end
defp normalize_item(%{separator: true}), do: %{separator: true} defp normalize_item(%{separator: true}), do: %{separator: true}
defp normalize_item(item) do defp normalize_item(item) do
@@ -145,12 +208,15 @@ defmodule BDS.Desktop.MenuBar do
defp group_label(:edit), do: dgettext("ui", "Edit") defp group_label(:edit), do: dgettext("ui", "Edit")
defp group_label(:view), do: dgettext("ui", "View") defp group_label(:view), do: dgettext("ui", "View")
defp group_label(:blog), do: dgettext("ui", "Blog") defp group_label(:blog), do: dgettext("ui", "Blog")
defp group_label(:window), do: dgettext("ui", "Window")
defp group_label(:help), do: dgettext("ui", "Help") defp group_label(:help), do: dgettext("ui", "Help")
defp item_label(:new_post), do: dgettext("ui", "New Post") defp item_label(:new_post), do: dgettext("ui", "New Post")
defp item_label(:import_media), do: dgettext("ui", "Import Media") defp item_label(:import_media), do: dgettext("ui", "Import Media")
defp item_label(:save), do: dgettext("ui", "Save") defp item_label(:save), do: dgettext("ui", "Save")
defp item_label(:open_in_browser), do: dgettext("ui", "Open in Browser") defp item_label(:open_in_browser), do: dgettext("ui", "Open in Browser")
defp item_label(:connect_server), do: dgettext("ui", "Connect to Server…")
defp item_label(:disconnect_server), do: dgettext("ui", "Disconnect from Server")
defp item_label(:open_data_folder), do: dgettext("ui", "Open Data Folder") defp item_label(:open_data_folder), do: dgettext("ui", "Open Data Folder")
defp item_label(:close_tab), do: dgettext("ui", "Close Tab") defp item_label(:close_tab), do: dgettext("ui", "Close Tab")
defp item_label(:quit), do: dgettext("ui", "Quit") defp item_label(:quit), do: dgettext("ui", "Quit")
@@ -188,8 +254,12 @@ defmodule BDS.Desktop.MenuBar do
defp item_label(:fill_missing_translations), do: dgettext("ui", "Fill Missing Translations") defp item_label(:fill_missing_translations), do: dgettext("ui", "Fill Missing Translations")
defp item_label(:find_duplicates), do: dgettext("ui", "Find Duplicate Posts") defp item_label(:find_duplicates), do: dgettext("ui", "Find Duplicate Posts")
defp item_label(:generate_sitemap), do: dgettext("ui", "Generate Site") defp item_label(:generate_sitemap), do: dgettext("ui", "Generate Site")
defp item_label(:force_render_site), do: dgettext("ui", "Force Render Site")
defp item_label(:validate_site), do: dgettext("ui", "Validate Site") defp item_label(:validate_site), do: dgettext("ui", "Validate Site")
defp item_label(:upload_site), do: dgettext("ui", "Upload Site") defp item_label(:upload_site), do: dgettext("ui", "Upload Site")
defp item_label(:minimize), do: dgettext("ui", "Minimize")
defp item_label(:zoom), do: dgettext("ui", "Zoom")
defp item_label(:bring_all_to_front), do: dgettext("ui", "Bring All to Front")
defp item_label(:about), do: dgettext("ui", "About") defp item_label(:about), do: dgettext("ui", "About")
defp item_label(:documentation), do: dgettext("ui", "Documentation") defp item_label(:documentation), do: dgettext("ui", "Documentation")
defp item_label(:api_documentation), do: dgettext("ui", "API Documentation") defp item_label(:api_documentation), do: dgettext("ui", "API Documentation")

View File

@@ -35,7 +35,8 @@ defmodule BDS.Desktop.Overlay do
title: Map.get(context, :insert_media_title, "Insert Media"), title: Map.get(context, :insert_media_title, "Insert Media"),
search_query: "", search_query: "",
results: Enum.map(media, &to_insert_media_result/1), results: Enum.map(media, &to_insert_media_result/1),
all_media: media all_media: media,
post_id: current_id(context)
} }
end end
@@ -48,29 +49,32 @@ defmodule BDS.Desktop.Overlay do
end end
def open(:media, :confirm_delete, context) do 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, kind: :confirm_delete,
title: Map.get(delete_details, :title, "Delete"), title: title,
entity_name: Map.get(delete_details, :entity_name, ""), entity_name: entity_name,
entity_type: Map.get(delete_details, :entity_type, "media"), entity_type: entity_type,
reference_count: length(Map.get(delete_details, :reference_list, [])), reference_count: length(reference_list),
reference_list: Map.get(delete_details, :reference_list, []) reference_list: reference_list
} }
end end
def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context) def open(:tags, :confirm_delete, context), do: open(:media, :confirm_delete, context)
def open(:tags, :confirm_merge, context) do def open(:tags, :confirm_merge, context) do
merge = Map.get(context, :merge_details, %{}) %{title: title, message: message} = context.merge_details
target = Map.get(merge, :target, "")
count = Map.get(merge, :count, 0)
%{ %{
kind: :confirm_dialog, kind: :confirm_dialog,
title: Map.get(merge, :title, "Merge #{count} tags into #{target}?"), title: title,
message: Map.get(merge, :message, "Cannot be undone.") message: message
} }
end end
@@ -115,8 +119,8 @@ defmodule BDS.Desktop.Overlay do
|> Map.get(:all_media, []) |> Map.get(:all_media, [])
|> Enum.filter(fn media -> |> Enum.filter(fn media ->
normalized == "" or normalized == "" or
search_matches?(Map.get(media, :title, ""), normalized) or search_matches?(media.title, normalized) or
search_matches?(Map.get(media, :original_name, ""), normalized) search_matches?(media.original_name, normalized)
end) end)
|> Enum.map(&to_insert_media_result/1) |> 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 def insert_media_result(_overlay, _media_id), do: nil
defp language_picker(context, source_language) do 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 = targets =
context context
|> Map.get(:blog_languages, []) |> Map.get(:blog_languages, [])
|> Enum.uniq() |> Enum.uniq()
|> Enum.reject(&(&1 == source_language)) |> Enum.reject(&(&1 == source_language))
|> Enum.map(fn code -> |> Enum.map(fn code ->
existing_status = Map.get(Map.get(context, :existing_translations, %{}), code) existing_status = Map.get(existing_translations, code)
%{ %{
code: code, code: code,
name: Map.get(Map.get(context, :language_names, %{}), code, String.upcase(code)), name: Map.get(language_names, code, String.upcase(code)),
flag_emoji: Map.get(Map.get(context, :language_flags, %{}), code, code), flag_emoji: Map.get(language_flags, code, code),
has_existing_translation: not is_nil(existing_status), has_existing_translation: not is_nil(existing_status),
existing_status: 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 def set_ai_suggestions_error(overlay, _error_message), do: overlay
defp normalize_ai_fields(fields) do 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, "")), key: to_string(key),
label: Map.get(field, :label, ""), label: label,
current_value: Map.get(field, :current_value, ""), current_value: current,
suggested_value: Map.get(field, :suggested_value, ""), suggested_value: suggested,
accepted: not Map.get(field, :locked, false), accepted: not locked,
locked: Map.get(field, :locked, false), locked: locked,
loading: Map.get(field, :loading, false) loading: Map.get(field, :loading, false)
} }
end) end)
@@ -276,7 +290,7 @@ defmodule BDS.Desktop.Overlay do
end end
defp gallery_images(context) do 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, []) post_media_ids = Map.get(context, :post_media_ids, [])
case Enum.filter(images, &(&1.id in post_media_ids)) do case Enum.filter(images, &(&1.id in post_media_ids)) do
@@ -289,29 +303,29 @@ defmodule BDS.Desktop.Overlay do
%{ %{
post_id: post.id, post_id: post.id,
title: post.title, title: post.title,
status: to_string(Map.get(post, :status, "draft")), status: post.status,
canonical_url: Map.get(post, :canonical_url, "/posts/#{post.id}"), canonical_url: post.canonical_url,
similarity_score: Map.get(post, :similarity_score) similarity_score: nil
} }
end end
defp to_insert_media_result(media) do defp to_insert_media_result(media) do
%{ %{
media_id: media.id, media_id: media.id,
title: Map.get(media, :title, ""), title: media.title,
original_name: Map.get(media, :original_name, media.id), original_name: media.original_name,
is_image: Map.get(media, :is_image, false), is_image: media.is_image,
thumbnail_url: Map.get(media, :thumbnail_url) thumbnail_url: media.thumbnail_url
} }
end end
defp to_gallery_image(media) do defp to_gallery_image(media) do
%{ %{
media_id: media.id, media_id: media.id,
thumbnail_url: Map.get(media, :thumbnail_url), thumbnail_url: media.thumbnail_url,
image_url: Map.get(media, :image_url, Map.get(media, :thumbnail_url)), image_url: media.image_url,
alt_text: Map.get(media, :alt_text), alt_text: media.alt_text,
title: Map.get(media, :title, Map.get(media, :original_name, media.id)) title: media.title
} }
end end

View File

@@ -0,0 +1,180 @@
defmodule BDS.Desktop.RemoteConnection do
@moduledoc """
GUI remote mode (issue #26, phase 5).
Connects the desktop app to a headless bDS2 server over the same SSH
channel (and the same public keys) the TUI uses: `:ssh.connect` with
public-key auth, then an OTP TCP/IP tunnel to the server's loopback HTTP
endpoint. The webview then loads the local tunnel end — the server never
exposes HTTP.
Client key material lives in the same private `ssh/` directory next to
the database (`BDS.Server.ssh_dir/0`): an `id_ed25519`/`id_rsa` identity
and a `known_hosts` written on first connect.
"""
use GenServer
use Gettext, backend: BDS.Gettext
require Logger
@default_ssh_port 2222
@tunnel_timeout_ms 10_000
def start_link(opts \\ []) do
case Keyword.pop(opts, :name, __MODULE__) do
{nil, init_opts} -> GenServer.start_link(__MODULE__, init_opts)
{name, init_opts} -> GenServer.start_link(__MODULE__, init_opts, name: name)
end
end
@doc """
Parses `user@host[:port]` into connection parameters. The port is the
server's SSH port (default #{@default_ssh_port}).
"""
@spec parse_target(String.t()) ::
{:ok, %{user: String.t(), host: String.t(), port: pos_integer()}}
| {:error, :invalid_target}
def parse_target(target) when is_binary(target) do
with [user, rest] when user != "" <- String.split(String.trim(target), "@", parts: 2),
{:ok, host, port} <- parse_host_port(rest) do
{:ok, %{user: user, host: host, port: port}}
else
_other -> {:error, :invalid_target}
end
end
defp parse_host_port(rest) do
case String.split(rest, ":", parts: 2) do
[host] when host != "" ->
{:ok, host, @default_ssh_port}
[host, port] when host != "" ->
case Integer.parse(port) do
{number, ""} when number > 0 -> {:ok, host, number}
_other -> :error
end
_other ->
:error
end
end
@doc "Connects and tunnels; returns the local URL the webview should load."
@spec connect(GenServer.server(), String.t()) :: {:ok, String.t()} | {:error, term()}
def connect(server \\ __MODULE__, target) do
GenServer.call(server, {:connect, target}, @tunnel_timeout_ms + 5_000)
end
@spec disconnect(GenServer.server()) :: :ok
def disconnect(server \\ __MODULE__) do
GenServer.call(server, :disconnect)
end
@spec status(GenServer.server()) :: :disconnected | {:connected, String.t(), String.t()}
def status(server \\ __MODULE__) do
GenServer.call(server, :status)
end
@doc "Human-readable connection error for menu/shell error reporting."
@spec format_reason(term()) :: String.t()
def format_reason(:invalid_target) do
dgettext("ui", "Use the form user@host or user@host:port.")
end
def format_reason(%{message: message}) when is_binary(message), do: message
def format_reason(reason), do: inspect(reason)
## GenServer callbacks
@impl true
def init(opts) do
overrides = Keyword.get(opts, :test_overrides, [])
{:ok,
%{
conn: nil,
monitor: nil,
target: nil,
url: nil,
connect_fun: Keyword.get(overrides, :connect_fun, &:ssh.connect/3),
tunnel_fun: Keyword.get(overrides, :tunnel_fun, &:ssh.tcpip_tunnel_to_server/6),
close_fun: Keyword.get(overrides, :close_fun, &:ssh.close/1),
ssh_dir_fun: Keyword.get(overrides, :ssh_dir_fun, &BDS.Server.ssh_dir/0)
}}
end
@impl true
def handle_call({:connect, target}, _from, state) do
state = close_current(state)
with {:ok, parsed} <- parse_target(target),
{:ok, conn} <- do_connect(state, parsed),
{:ok, local_port} <- do_tunnel(state, conn) do
url = "http://127.0.0.1:#{local_port}/"
{:reply, {:ok, url},
%{
state
| conn: conn,
monitor: Process.monitor(conn),
target: String.trim(target),
url: url
}}
else
{:error, reason} = error ->
Logger.warning("Remote connection to #{inspect(target)} failed: #{inspect(reason)}")
{:reply, error, state}
end
end
def handle_call(:disconnect, _from, state) do
{:reply, :ok, close_current(state)}
end
def handle_call(:status, _from, %{conn: nil} = state) do
{:reply, :disconnected, state}
end
def handle_call(:status, _from, state) do
{:reply, {:connected, state.target, state.url}, state}
end
@impl true
def handle_info({:DOWN, monitor, :process, _pid, reason}, %{monitor: monitor} = state) do
Logger.warning("Remote SSH connection lost: #{inspect(reason)}")
{:noreply, %{state | conn: nil, monitor: nil, target: nil, url: nil}}
end
def handle_info(_message, state), do: {:noreply, state}
## Internal helpers
defp do_connect(state, %{user: user, host: host, port: port}) do
ssh_dir = BDS.Server.ensure_ssh_dir!(state.ssh_dir_fun.())
state.connect_fun.(String.to_charlist(host), port,
user: String.to_charlist(user),
user_dir: String.to_charlist(ssh_dir),
auth_methods: ~c"publickey",
# First connect records the host key in <ssh_dir>/known_hosts;
# later connects verify against it (trust-on-first-use).
silently_accept_hosts: true,
connect_timeout: @tunnel_timeout_ms
)
end
defp do_tunnel(state, conn) do
remote_port = Application.get_env(:bds, :desktop)[:port] || 4010
state.tunnel_fun.(conn, ~c"127.0.0.1", 0, ~c"127.0.0.1", remote_port, @tunnel_timeout_ms)
end
defp close_current(%{conn: nil} = state), do: state
defp close_current(state) do
if state.monitor, do: Process.demonitor(state.monitor, [:flush])
_ = state.close_fun.(state.conn)
%{state | conn: nil, monitor: nil, target: nil, url: nil}
end
end

View File

@@ -19,6 +19,7 @@ defmodule BDS.Desktop.Router do
get("/health", HealthController, :show) get("/health", HealthController, :show)
get("/media-thumbnail/:media_id", MediaController, :thumbnail) get("/media-thumbnail/:media_id", MediaController, :thumbnail)
get("/media-file/:media_id", MediaController, :file)
live_session :desktop_shell, live_session :desktop_shell,
root_layout: {BDS.Desktop.Layouts, :root} do root_layout: {BDS.Desktop.Layouts, :root} do

View File

@@ -1,17 +1,18 @@
defmodule BDS.Desktop.Server do defmodule BDS.Desktop.Server do
@moduledoc false @moduledoc false
use GenServer
def child_spec(opts) do def child_spec(opts) do
%{ Supervisor.child_spec(
id: __MODULE__, {Bandit,
start: {__MODULE__, :start_link, [opts]} Keyword.merge(opts,
} plug: BDS.Desktop.Endpoint,
end scheme: :http,
ip: {127, 0, 0, 1},
def start_link(opts) do port: port(),
GenServer.start_link(__MODULE__, opts, name: __MODULE__) startup_log: false
)},
id: __MODULE__
)
end end
def url do def url do
@@ -24,18 +25,4 @@ defmodule BDS.Desktop.Server do
_other -> Application.get_env(:bds, :desktop)[:port] || 4010 _other -> Application.get_env(:bds, :desktop)[:port] || 4010
end end
end end
@impl true
def init(_opts) do
{:ok, bandit_pid} =
Bandit.start_link(
plug: BDS.Desktop.Endpoint,
scheme: :http,
ip: {127, 0, 0, 1},
port: port(),
startup_log: false
)
{:ok, %{bandit_pid: bandit_pid}}
end
end end

View File

@@ -120,16 +120,7 @@ defmodule BDS.Desktop.ShellCommands do
"rebuild_embedding_index", "rebuild_embedding_index",
"Rebuild Embedding Index", "Rebuild Embedding Index",
"Embeddings", "Embeddings",
fn report -> fn report -> Maintenance.rebuild_embedding_index(project.id, on_progress: report) end
{: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
) )
end end
@@ -205,6 +196,14 @@ defmodule BDS.Desktop.ShellCommands do
end) end)
end end
defp dispatch("rebuild_media_links", project, _params) do
queue_task(project, "rebuild_media_links", "Rebuild Media Links", "Maintenance", fn report ->
{:ok, result} = BDS.Media.rebuild_media_links(project.id, on_progress: report)
report.(1.0, "Media links rebuilt")
%{project_id: project.id, counts: %{media_links: result.links}}
end)
end
defp dispatch("regenerate_missing_thumbnails", project, _params) do defp dispatch("regenerate_missing_thumbnails", project, _params) do
queue_task( queue_task(
project, project,
@@ -222,7 +221,7 @@ defmodule BDS.Desktop.ShellCommands do
defp dispatch("rebuild_database", project, _params) do defp dispatch("rebuild_database", project, _params) do
group_id = task_group_id("rebuild_database") group_id = task_group_id("rebuild_database")
attrs = %{group_id: group_id, group_name: "Maintenance"} attrs = %{group_id: group_id, group_name: "Maintenance"}
[first_step | remaining_steps] = rebuild_database_steps(project) [first_step | remaining_steps] = Maintenance.full_rebuild_steps(project.id)
{:ok, posts_task} = {:ok, posts_task} =
Tasks.submit_task(first_step.name, first_step.work, attrs) Tasks.submit_task(first_step.name, first_step.work, attrs)
@@ -248,18 +247,13 @@ defmodule BDS.Desktop.ShellCommands do
end end
defp dispatch("generate_sitemap", project, _params) do defp dispatch("generate_sitemap", project, _params) do
queue_task(project, "generate_sitemap", "Generate Site", "Generation", fn report -> queue_render_site(project, "generate_sitemap", "Render Site", [])
{:ok, generation} = end
Generation.generate_site(project.id, @site_sections, on_progress: report)
report.(1.0, "Generated site output") # Forced variant of the site render: ignores the stored content hashes and
# rewrites every output file, repairing any drift on disk.
%{ defp dispatch("force_render_site", project, _params) do
project_id: project.id, queue_render_site(project, "force_render_site", "Force Render Site", force: true)
sections: generation.sections,
generated_count: length(generation.generated_files)
}
end)
end end
defp dispatch("validate_site", project, _params) do defp dispatch("validate_site", project, _params) do
@@ -271,6 +265,42 @@ defmodule BDS.Desktop.ShellCommands do
end) end)
end end
defp dispatch("apply_site_validation", project, params) do
validation_report = normalize_apply_validation_report(BDS.MapUtils.attr(params, :report, %{}))
group_id = task_group_id("apply_site_validation")
attrs = %{group_id: group_id, group_name: "Apply Site Validation"}
{:ok, prepare_task} =
Tasks.submit_task(
"Prepare Validation Apply",
fn report ->
{:ok, preparation} =
Generation.prepare_validation_apply(project.id, validation_report,
on_progress: report
)
Map.put(preparation, :project_id, project.id)
end,
attrs
)
Task.start(fn ->
run_apply_validation_sequence(project, validation_report, group_id, attrs, prepare_task.id)
end)
{:ok,
%{
kind: "task_queued",
action: "apply_site_validation",
title: "Apply Site Validation",
message: "Apply Site Validation tasks queued",
project_id: project.id,
task_id: prepare_task.id,
task_group_id: group_id,
panel_tab: "tasks"
}}
end
defp dispatch("metadata_diff", project, _params) do defp dispatch("metadata_diff", project, _params) do
queue_task(project, "metadata_diff", "Metadata Diff", "Maintenance", fn report -> queue_task(project, "metadata_diff", "Metadata Diff", "Maintenance", fn report ->
{:ok, metadata_diff} = Maintenance.metadata_diff(project.id, on_progress: report) {:ok, metadata_diff} = Maintenance.metadata_diff(project.id, on_progress: report)
@@ -448,8 +478,209 @@ defmodule BDS.Desktop.ShellCommands do
end end
end end
defp queue_render_site(project, action, title, render_opts) do
group_id = task_group_id(action)
attrs = %{group_id: group_id, group_name: title}
{:ok, first_task} =
Tasks.submit_task(
section_task_name(:core),
fn report ->
{:ok, _result} =
Generation.render_site_section(
project.id,
:core,
[on_progress: report] ++ render_opts
)
report.(1.0, section_complete_message(:core))
:ok
end,
attrs
)
Task.start(fn -> run_render_site_sequence(project, group_id, attrs, render_opts) end)
{:ok,
%{
kind: "task_queued",
action: action,
title: title,
message: title <> " tasks queued",
project_id: project.id,
task_id: first_task.id,
task_group_id: group_id,
panel_tab: "tasks"
}}
end
defp run_render_site_sequence(project, group_id, attrs, render_opts) do
remaining_sections = [:single, :category, :tag, :date]
Enum.each(remaining_sections, fn section ->
{:ok, _task} =
Tasks.submit_task(
section_task_name(section),
fn report ->
{:ok, _result} =
Generation.render_site_section(
project.id,
section,
[on_progress: report] ++ render_opts
)
report.(1.0, section_complete_message(section))
:ok
end,
attrs
)
end)
section_names = Enum.map([:core | remaining_sections], &section_task_name/1)
with :ok <- wait_for_group_phase(group_id, section_names, @rebuild_phase_timeout) do
{:ok, _task} =
Tasks.submit_task(
"Build Search Index",
fn report ->
{:ok, _result} =
Generation.build_site_search_index(
project.id,
[on_progress: report] ++ render_opts
)
report.(1.0, "Build Search Index complete")
:ok
end,
attrs
)
wait_for_group_phase(group_id, ["Build Search Index"], @rebuild_phase_timeout)
end
:ok
end
defp run_apply_validation_sequence(project, validation_report, group_id, attrs, prepare_task_id) do
with :ok <-
wait_for_group_phase(group_id, ["Prepare Validation Apply"], @rebuild_phase_timeout),
{:ok, preparation} <- completed_task_result(prepare_task_id),
:ok <-
run_apply_validation_render_tasks(
project,
validation_report,
group_id,
attrs,
preparation
) do
if validation_apply_changed?(preparation) do
:ok = run_apply_validation_refresh_task(project, group_id, attrs, :calendar)
end
submit_apply_validation_check_task(project, attrs)
else
_other -> :ok
end
end
defp run_apply_validation_render_tasks(project, validation_report, group_id, attrs, preparation) do
sections = Map.get(preparation, :sections_to_render, [])
task_names = Enum.map(sections, &section_task_name/1)
Enum.each(sections, fn section ->
task_name = section_task_name(section)
{:ok, _task} =
Tasks.submit_task(
task_name,
fn report ->
{:ok, _result} =
Generation.apply_validation_section(project.id, validation_report, section,
on_progress: report
)
report.(1.0, section_complete_message(section))
:ok
end,
attrs
)
end)
case task_names do
[] -> :ok
names -> wait_for_group_phase(group_id, names, @rebuild_phase_timeout)
end
end
defp run_apply_validation_refresh_task(project, group_id, attrs, kind) do
task_name = apply_validation_refresh_task_name(kind)
{:ok, _task} =
Tasks.submit_task(
task_name,
fn report ->
{:ok, _result} =
Generation.refresh_validation_ancillary_outputs(project.id, kind, on_progress: report)
report.(1.0, apply_validation_refresh_complete_message(kind))
:ok
end,
attrs
)
wait_for_group_phase(group_id, [task_name], @rebuild_phase_timeout)
end
defp submit_apply_validation_check_task(project, attrs) do
{:ok, _task} =
Tasks.submit_task(
"Validate Site",
fn report ->
{:ok, validation} =
Generation.validate_site(project.id, @site_sections, on_progress: report)
site_validation_result(project.id, validation)
end,
attrs
)
:ok
end
defp completed_task_result(task_id) do
case Tasks.get_task(task_id) do
%{status: :completed, result: result} when is_map(result) -> {:ok, result}
%{status: :failed, error: error} -> {:error, error}
_other -> {:error, :task_result_unavailable}
end
end
defp validation_apply_changed?(preparation) do
Map.get(preparation, :sections_to_render, []) != [] or
Map.get(preparation, :deleted_url_count, 0) > 0
end
defp section_task_name(:core), do: "Render Site Core"
defp section_task_name(:single), do: "Render Single Posts"
defp section_task_name(:category), do: "Render Category Archives"
defp section_task_name(:tag), do: "Render Tag Archives"
defp section_task_name(:date), do: "Render Date Archives"
defp section_complete_message(:core), do: "Render Site Core complete"
defp section_complete_message(:single), do: "Render Single Posts complete"
defp section_complete_message(:category),
do: "Render Category Archives complete"
defp section_complete_message(:tag), do: "Render Tag Archives complete"
defp section_complete_message(:date), do: "Render Date Archives complete"
defp apply_validation_refresh_task_name(:calendar), do: "Regenerate Calendar"
defp apply_validation_refresh_complete_message(:calendar), do: "Regenerate Calendar complete"
defp translation_fill_enabled?(metadata) do 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 -> |> Enum.map(fn language ->
language language
|> to_string() |> to_string()
@@ -461,83 +692,6 @@ defmodule BDS.Desktop.ShellCommands do
|> length() > 1 |> length() > 1
end end
defp rebuild_database_steps(project) do
[
%{
name: "Rebuild Posts From Files",
work: fn report ->
{:ok, posts} =
Maintenance.rebuild_from_filesystem(project.id, "post",
on_progress: report,
rebuild_embeddings: false
)
report.(1.0, "Post rebuild complete")
%{project_id: project.id, counts: %{posts: length(posts)}}
end
},
%{
name: "Rebuild Media From Files",
work: fn report ->
{:ok, media} =
Maintenance.rebuild_from_filesystem(project.id, "media", on_progress: report)
report.(1.0, "Media rebuild complete")
%{project_id: project.id, counts: %{media: length(media)}}
end
},
%{
name: "Rebuild Scripts From Files",
work: fn report ->
{:ok, scripts} =
Maintenance.rebuild_from_filesystem(project.id, "script", on_progress: report)
report.(1.0, "Script rebuild complete")
%{project_id: project.id, counts: %{scripts: length(scripts)}}
end
},
%{
name: "Rebuild Templates From Files",
work: fn report ->
{:ok, templates} =
Maintenance.rebuild_from_filesystem(project.id, "template", on_progress: report)
report.(1.0, "Template rebuild complete")
%{project_id: project.id, counts: %{templates: length(templates)}}
end
},
%{
name: "Rebuild Post Links",
work: fn report ->
:ok = Posts.rebuild_post_links(project.id, on_progress: report)
report.(1.0, "Post links rebuilt")
%{project_id: project.id}
end
},
%{
name: "Regenerate Missing Thumbnails",
work: fn report ->
result = BDS.Media.regenerate_missing_thumbnails(project.id, on_progress: report)
report.(1.0, "Missing thumbnails regenerated")
Map.put(result, :project_id, project.id)
end
},
%{
name: "Rebuild Embedding Index",
work: 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
}
]
end
defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok defp run_rebuild_sequence(_group_id, _attrs, []), do: :ok
defp run_rebuild_sequence(group_id, attrs, [step | remaining_steps]) do defp run_rebuild_sequence(group_id, attrs, [step | remaining_steps]) do
@@ -549,27 +703,62 @@ defmodule BDS.Desktop.ShellCommands do
end end
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 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 = tasks =
BDS.Tasks.list_tasks() BDS.Tasks.list_tasks()
|> Enum.filter(&(&1.group_id == group_id and &1.name in names)) |> Enum.filter(&(&1.group_id == group_id and &1.name in names))
cond do cond do
length(tasks) < length(names) -> length(tasks) < length(names) -> :waiting
Process.sleep(50) Enum.any?(tasks, &(&1.status == :failed)) -> :failed
wait_for_group_phase(group_id, names, timeout - 50) Enum.all?(tasks, &(&1.status == :completed)) -> :ok
true -> :waiting
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)
end end
end end
@@ -595,6 +784,20 @@ defmodule BDS.Desktop.ShellCommands do
"http://#{server.host}:#{server.port}/" "http://#{server.host}:#{server.port}/"
end end
defp normalize_apply_validation_report(report) when is_map(report) do
%{
sitemap_path: BDS.MapUtils.attr(report, :sitemap_path),
sitemap_changed: BDS.MapUtils.attr(report, :sitemap_changed, false),
missing_url_paths: BDS.MapUtils.attr(report, :missing_url_paths, []),
extra_url_paths: BDS.MapUtils.attr(report, :extra_url_paths, []),
updated_post_url_paths: BDS.MapUtils.attr(report, :updated_post_url_paths, []),
expected_url_count: BDS.MapUtils.attr(report, :expected_url_count, 0),
existing_html_url_count: BDS.MapUtils.attr(report, :existing_html_url_count, 0)
}
end
defp normalize_apply_validation_report(_report), do: %{}
defp normalize_site_validation(report) do defp normalize_site_validation(report) do
%{ %{
sitemap_path: report.sitemap_path, sitemap_path: report.sitemap_path,

View File

@@ -6,6 +6,7 @@ defmodule BDS.Desktop.ShellData do
alias BDS.Git alias BDS.Git
alias BDS.I18n alias BDS.I18n
alias BDS.Projects alias BDS.Projects
alias BDS.Repo
alias BDS.UI.Dashboard alias BDS.UI.Dashboard
alias BDS.UI.Sidebar alias BDS.UI.Sidebar
alias BDS.UI.Workbench alias BDS.UI.Workbench
@@ -62,15 +63,11 @@ defmodule BDS.Desktop.ShellData do
end end
def project_snapshot do def project_snapshot do
Projects.shell_snapshot() if Repo.ready?() do
rescue {:ok, Projects.shell_snapshot()}
error in [Exqlite.Error, DBConnection.OwnershipError] -> else
if match?(%Exqlite.Error{}, error) and {:error, :not_ready}
not String.contains?(Exception.message(error), "no such table: projects") do end
reraise error, __STACKTRACE__
end
default_project_snapshot()
end end
def current_project(projects_snapshot) do def current_project(projects_snapshot) do
@@ -79,55 +76,31 @@ defmodule BDS.Desktop.ShellData do
end end
def dashboard(project_id) do def dashboard(project_id) do
Dashboard.snapshot(project_id) if Repo.ready?() do
rescue {:ok, Dashboard.snapshot(project_id)}
error in [Exqlite.Error, DBConnection.OwnershipError] -> else
if match?(%Exqlite.Error{}, error) and {:error, :not_ready}
not String.contains?(Exception.message(error), "no such table") do end
reraise error, __STACKTRACE__
end
Dashboard.empty_snapshot()
end end
def sidebar_view(project_id, view_id, params \\ %{}) do def sidebar_view(project_id, view_id, params \\ %{}) do
Sidebar.view(project_id, view_id, params) if Repo.ready?() do
rescue {:ok, Sidebar.view(project_id, view_id, params)}
error in [Exqlite.Error, DBConnection.OwnershipError] -> else
if match?(%Exqlite.Error{}, error) and {:error, :not_ready}
not String.contains?(Exception.message(error), "no such table") do end
reraise error, __STACKTRACE__
end
Sidebar.view(nil, view_id, params)
end end
def assistant_cards do # Accepts the caller's already-resolved locale so hot render paths never
[ # re-read the ui.language setting from the database.
%{ def editor_meta(task_status, ui_language \\ nil) do
label: dgettext("ui", "Offline Gate"),
text: dgettext("ui", "Automatic AI actions stay gated by airplane mode.")
},
%{
label: dgettext("ui", "Filesystem Sync"),
text:
dgettext("ui", "Metadata flush, diffing, and rebuild hooks still need editor wiring.")
},
%{
label: dgettext("ui", "Desktop Runtime"),
text: dgettext("ui", "The app window is now served from LiveView state.")
}
]
end
def editor_meta(task_status) do
[ [
%{ %{
label: dgettext("ui", "Status"), label: dgettext("ui", "Status"),
value: task_status.running_task_message || dgettext("ui", "Idle") value: task_status.running_task_message || dgettext("ui", "Idle")
}, },
%{label: dgettext("ui", "Mode"), value: dgettext("ui", "Offline")}, %{label: dgettext("ui", "Mode"), value: dgettext("ui", "Offline")},
%{label: dgettext("ui", "Main Language"), value: ui_language()} %{label: dgettext("ui", "Main Language"), value: ui_language || ui_language()}
] ]
end end
@@ -136,7 +109,7 @@ defmodule BDS.Desktop.ShellData do
post_count: dashboard.post_stats.total_posts, post_count: dashboard.post_stats.total_posts,
media_count: dashboard.media_stats.media_count, media_count: dashboard.media_stats.media_count,
theme_badge: "desktop-shell", theme_badge: "desktop-shell",
ui_language: Keyword.get(opts, :ui_language, ui_language()), ui_language: Keyword.get_lazy(opts, :ui_language, &ui_language/0),
offline_mode: Keyword.get(opts, :offline_mode, true), offline_mode: Keyword.get(opts, :offline_mode, true),
running_task_message: task_status.running_task_message, running_task_message: task_status.running_task_message,
running_task_overflow: task_status.running_task_overflow, running_task_overflow: task_status.running_task_overflow,
@@ -146,14 +119,16 @@ defmodule BDS.Desktop.ShellData do
def git_badge_count(project_id, opts \\ []) def git_badge_count(project_id, opts \\ [])
def git_badge_count(nil, _opts), do: 0 def git_badge_count(nil, _opts), do: {:ok, 0}
def git_badge_count("default", _opts), do: 0 def git_badge_count("default", _opts), do: {:ok, 0}
def git_badge_count(project_id, opts) when is_binary(project_id) do def git_badge_count(project_id, opts) when is_binary(project_id) do
provider = Keyword.get(opts, :provider, git_remote_state_provider()) if not Repo.ready?() do
custom_provider? = provider != (&BDS.Git.remote_state/2) {:error, :not_ready}
else
provider = Keyword.get(opts, :provider, git_remote_state_provider())
custom_provider? = provider != (&BDS.Git.remote_state/2)
try do
has_git = has_git =
custom_provider? || custom_provider? ||
case BDS.Projects.get_project(project_id) do case BDS.Projects.get_project(project_id) do
@@ -161,23 +136,18 @@ defmodule BDS.Desktop.ShellData do
project -> File.dir?(Path.join(BDS.Projects.project_data_dir(project), ".git")) project -> File.dir?(Path.join(BDS.Projects.project_data_dir(project), ".git"))
end end
if has_git do count =
case provider.(project_id, []) do if has_git do
{:ok, %{behind: behind}} when is_integer(behind) and behind > 0 -> behind case provider.(project_id, []) do
{:ok, %{behind: behind}} when is_binary(behind) -> parse_positive_count(behind) {:ok, %{behind: behind}} when is_integer(behind) and behind > 0 -> behind
_other -> 0 {:ok, %{behind: behind}} when is_binary(behind) -> parse_positive_count(behind)
end _other -> 0
else end
0 else
end 0
rescue
error in [DBConnection.OwnershipError, Exqlite.Error] ->
if match?(%Exqlite.Error{}, error) and
not String.contains?(Exception.message(error), "no such table") do
reraise error, __STACKTRACE__
end end
0 {:ok, count}
end end
end end
@@ -303,7 +273,7 @@ defmodule BDS.Desktop.ShellData do
defp maybe_add_panel_tab(tabs, _route, _tab), do: tabs defp maybe_add_panel_tab(tabs, _route, _tab), do: tabs
defp default_project_snapshot do def default_project_snapshot do
%{ %{
active_project_id: "default", active_project_id: "default",
projects: [ projects: [

File diff suppressed because it is too large Load Diff

View File

@@ -5,29 +5,128 @@ defmodule BDS.Desktop.ShellLive.Bridges do
import Phoenix.LiveView, only: [connected?: 1, send_update: 2] import Phoenix.LiveView, only: [connected?: 1, send_update: 2]
alias BDS.Desktop.ShellData alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{ChatEditor, PostEditor} alias BDS.Desktop.ShellLive.{ChatEditor, MediaEditor, PostEditor}
alias BDS.Desktop.ShellLive.{CliSync, SessionUtil} alias BDS.Desktop.ShellLive.{CliSync, SessionUtil}
alias BDS.UI.Workbench alias BDS.UI.Workbench
@refreshable_tab_meta_types [:import, :chat, :post, :media]
@spec handle_info(tuple() | atom(), Phoenix.LiveView.Socket.t(), map()) :: @spec handle_info(tuple() | atom(), Phoenix.LiveView.Socket.t(), map()) ::
{:noreply, Phoenix.LiveView.Socket.t()} {: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 end
def handle_info({:import_editor_tab_meta, definition_id, title, subtitle}, socket, callbacks) do def handle_info({:editor_tab_meta, type, id, updates}, socket, callbacks)
tab_meta = when is_atom(type) and is_map(updates) do
Map.put(socket.assigns.tab_meta, {:import, definition_id}, %{ key = {type, id}
title: title, current_meta = Map.get(socket.assigns.tab_meta, key, %{})
subtitle: subtitle || "" 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, {:noreply,
socket callbacks.refresh_layout.(socket, Workbench.close_tab(socket.assigns.workbench, type, id))}
|> assign(:tab_meta, tab_meta)
|> callbacks.reload.(socket.assigns.workbench)}
end 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 def handle_info({:chat_tool_call, conversation_id, tool_call}, socket, _callbacks) do
send_update(ChatEditor, send_update(ChatEditor,
id: "chat-editor-#{conversation_id}", id: "chat-editor-#{conversation_id}",
@@ -68,47 +167,89 @@ defmodule BDS.Desktop.ShellLive.Bridges do
{:noreply, assign(socket, :chat_editor_request_refs, refs)} {:noreply, assign(socket, :chat_editor_request_refs, refs)}
end end
def handle_info({:chat_editor_output, title, message, level}, socket, callbacks) do def handle_info({:persist_surface_state, conversation_id}, socket, _callbacks) do
{:noreply, callbacks.append_output.(socket, title, message, nil, level)} send_update(ChatEditor,
end id: "chat-editor-#{conversation_id}",
action: :persist_surface_state
)
def handle_info({:chat_editor_tab_meta, conversation_id, title, subtitle}, socket, callbacks) do {:noreply, socket}
tab_meta =
Map.put(socket.assigns.tab_meta, {:chat, conversation_id}, %{
title: title,
subtitle: subtitle || ""
})
{:noreply,
socket
|> assign(:tab_meta, tab_meta)
|> callbacks.reload.(socket.assigns.workbench)}
end
def handle_info({:open_sidebar_item, params, intent}, socket, callbacks) do
{:noreply, callbacks.open_sidebar.(socket, params, intent)}
end end
def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do
{:noreply, callbacks.reload.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))} {:noreply,
callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
end end
def handle_info({:chat_editor_toggle_panel}, socket, callbacks) do def handle_info({:chat_editor_toggle_panel}, socket, callbacks) do
{:noreply, callbacks.reload.(socket, Workbench.toggle_panel(socket.assigns.workbench))} {:noreply,
callbacks.refresh_layout.(socket, Workbench.toggle_panel(socket.assigns.workbench))}
end end
def handle_info({:chat_editor_toggle_assistant_sidebar}, socket, callbacks) do def handle_info({:chat_editor_toggle_assistant_sidebar}, socket, callbacks) do
{:noreply, {:noreply,
callbacks.reload.(socket, Workbench.toggle_assistant_sidebar(socket.assigns.workbench))} callbacks.refresh_layout.(
socket,
Workbench.toggle_assistant_sidebar(socket.assigns.workbench)
)}
end end
def handle_info({:chat_editor_switch_view, view}, socket, callbacks) do def handle_info({:chat_editor_switch_view, view}, socket, callbacks) do
{:noreply, {:noreply,
callbacks.reload.(socket, Workbench.click_activity(socket.assigns.workbench, view))} callbacks.refresh_sidebar.(socket, Workbench.click_activity(socket.assigns.workbench, view))}
end 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({:editor_translation_completed, :post, post_id, language}, socket, _callbacks) do
send_update(PostEditor,
id: "post-editor-#{post_id}",
action: :translation_completed,
language: language
)
{:noreply, socket}
end
def handle_info({:editor_translation_completed, :media, media_id, language}, socket, _callbacks) do
send_update(MediaEditor,
id: "media-editor-#{media_id}",
action: :translation_completed,
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 def handle_info({:entity_changed, payload}, socket, callbacks) when is_map(payload) do
{:noreply, CliSync.apply_entity_change(socket, payload, callbacks.reload)} {:noreply, CliSync.apply_entity_change(socket, payload, callbacks.refresh_content)}
end end
def handle_info(:refresh_task_status, socket, callbacks) do def handle_info(:refresh_task_status, socket, callbacks) do
@@ -125,7 +266,7 @@ defmodule BDS.Desktop.ShellLive.Bridges do
socket socket
|> assign(:task_status, task_status) |> assign(:task_status, task_status)
|> assign(:editor_meta, ShellData.editor_meta(task_status)) |> assign(:editor_meta, ShellData.editor_meta(task_status, socket.assigns.page_language))
|> assign( |> assign(
:status, :status,
ShellData.status_bar( ShellData.status_bar(
@@ -150,125 +291,5 @@ defmodule BDS.Desktop.ShellLive.Bridges do
{:noreply, socket} {:noreply, socket}
end 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.reload.(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.reload.(socket, Workbench.close_tab(socket.assigns.workbench, type, id))}
end
def handle_info(_message, socket, _callbacks), do: {:noreply, socket} def handle_info(_message, socket, _callbacks), do: {:noreply, socket}
end end

View File

@@ -1,12 +1,15 @@
defmodule BDS.Desktop.ShellLive.ChatEditor do defmodule BDS.Desktop.ShellLive.ChatEditor do
@moduledoc false @moduledoc false
require Logger
use Phoenix.LiveComponent use Phoenix.LiveComponent
import Phoenix.HTML, only: [raw: 1] import Phoenix.HTML, only: [raw: 1]
alias BDS.{AI, BoundedAtoms, MapUtils, Persistence} alias BDS.{AI, BoundedAtoms, MapUtils, Persistence}
alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking} alias BDS.Desktop.ShellLive.ChatEditor.{MessageBuild, ModelSelection, ToolTracking}
alias BDS.Desktop.ShellLive.Notify
alias BDS.Desktop.ShellLive.TabHelpers alias BDS.Desktop.ShellLive.TabHelpers
use Gettext, backend: BDS.Gettext use Gettext, backend: BDS.Gettext
@@ -36,6 +39,10 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
{:ok, do_note_streaming_content(socket, content)} {:ok, do_note_streaming_content(socket, content)}
end end
def update(%{action: :persist_surface_state}, socket) do
{:ok, persist_surface_state(socket)}
end
def update(assigns, socket) do def update(assigns, socket) do
socket = socket =
socket socket
@@ -77,7 +84,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
{:noreply, assign(socket, :model_selector_open?, false) |> build_data()} {:noreply, assign(socket, :model_selector_open?, false) |> build_data()}
{:error, reason} -> {: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()} {:noreply, assign(socket, :model_selector_open?, false) |> build_data()}
end end
end end
@@ -96,7 +103,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
socket socket
) do ) do
next_data = Map.put(socket.assigns.surface_data, surface_id, fields) 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 end
def handle_event( def handle_event(
@@ -110,6 +119,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
:surface_tabs, :surface_tabs,
Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index)) Map.put(socket.assigns.surface_tabs, surface_id, parse_integer(index))
) )
|> persist_surface_state()
|> build_data() |> build_data()
{:noreply, socket} {:noreply, socket}
@@ -119,6 +129,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
socket = socket =
socket socket
|> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id)) |> assign(:dismissed_surfaces, MapSet.put(socket.assigns.dismissed_surfaces, surface_id))
|> persist_surface_state()
|> build_data() |> build_data()
{:noreply, socket} {:noreply, socket}
@@ -129,14 +140,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end end
def handle_event("open_chat_settings", _params, socket) do def handle_event("open_chat_settings", _params, socket) do
notify_parent( Notify.open_sidebar_item(
{:open_sidebar_item, %{
%{ "route" => "settings",
"route" => "settings", "id" => "settings-ai",
"id" => "settings-ai", "title" => "Settings",
"title" => "Settings", "subtitle" => "AI"
"subtitle" => "AI" },
}, :pin} :pin
) )
{:noreply, socket} {:noreply, socket}
@@ -147,14 +158,29 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp ensure_state(socket) do defp ensure_state(socket) do
conversation_id = socket.assigns.current_tab.id 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 = %{ defaults = %{
conversation_id: conversation_id, conversation_id: conversation_id,
input: "", input: "",
model_selector_open?: false, model_selector_open?: false,
request: nil, request: nil,
surface_data: %{}, surface_data: surface_data,
surface_tabs: %{}, surface_tabs: surface_tabs,
dismissed_surfaces: MapSet.new(), dismissed_surfaces: dismissed_surfaces,
action_error: nil action_error: nil
} }
@@ -202,15 +228,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
not is_nil(socket.assigns.request) -> not is_nil(socket.assigns.request) ->
build_data(socket) build_data(socket)
socket.assigns.offline_mode -> socket.assigns.offline_mode and not AI.airplane_endpoint_configured?() ->
notify_parent( Notify.output(
{:chat_editor_output, dgettext("ui", "Chat"), dgettext("ui", "Chat"),
dgettext("ui", "Automatic AI actions stay gated by airplane mode."), "info"} dgettext("ui", "Automatic AI actions stay gated by airplane mode."),
"info"
) )
build_data(socket) build_data(socket)
ModelSelection.needs_api_key?(false) -> ModelSelection.needs_api_key?(socket.assigns.offline_mode) ->
build_data(socket) build_data(socket)
true -> true ->
@@ -227,7 +254,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
:ok = allow_repo_sandbox(task.pid) :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 socket
|> assign(:input, "") |> assign(:input, "")
@@ -254,7 +281,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
%{ref: ref} = _request -> %{ref: ref} = _request ->
:ok = AI.cancel_chat(conversation_id) :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 socket
|> assign(:request, nil) |> assign(:request, nil)
@@ -293,9 +320,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
assign(socket, :request, nil) |> build_data() assign(socket, :request, nil) |> build_data()
{:error, reason} -> {:error, reason} ->
notify_parent( Notify.output(dgettext("ui", "Chat"), format_error(reason), "error")
{:chat_editor_output, dgettext("ui", "Chat"), format_error(reason), "error"}
)
assign(socket, :request, nil) |> build_data() assign(socket, :request, nil) |> build_data()
end end
@@ -347,7 +372,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
|> MapUtils.attr(:title) |> MapUtils.attr(:title)
if is_binary(title) and String.trim(title) != "" do 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 end
socket socket
@@ -368,14 +393,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
:open_post -> :open_post ->
case Map.get(payload, "postId") || Map.get(payload, "post_id") do case Map.get(payload, "postId") || Map.get(payload, "post_id") do
post_id when is_binary(post_id) and post_id != "" -> post_id when is_binary(post_id) and post_id != "" ->
notify_parent( Notify.open_sidebar_item(
{:open_sidebar_item, %{
%{ "route" => "post",
"route" => "post", "id" => post_id,
"id" => post_id, "title" => TabHelpers.post_title(post_id),
"title" => TabHelpers.post_title(post_id), "subtitle" => TabHelpers.post_subtitle(post_id)
"subtitle" => TabHelpers.post_subtitle(post_id) },
}, :pin} :pin
) )
assign(socket, :action_error, nil) |> build_data() assign(socket, :action_error, nil) |> build_data()
@@ -387,14 +412,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
:open_media -> :open_media ->
case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do
media_id when is_binary(media_id) and media_id != "" -> media_id when is_binary(media_id) and media_id != "" ->
notify_parent( Notify.open_sidebar_item(
{:open_sidebar_item, %{
%{ "route" => "media",
"route" => "media", "id" => media_id,
"id" => media_id, "title" => TabHelpers.media_title(media_id),
"title" => TabHelpers.media_title(media_id), "subtitle" => TabHelpers.media_subtitle(media_id)
"subtitle" => TabHelpers.media_subtitle(media_id) },
}, :pin} :pin
) )
assign(socket, :action_error, nil) |> build_data() assign(socket, :action_error, nil) |> build_data()
@@ -404,14 +429,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
end end
:open_settings -> :open_settings ->
notify_parent( Notify.open_sidebar_item(
{:open_sidebar_item, %{
%{ "route" => "settings",
"route" => "settings", "id" => "settings-ai",
"id" => "settings-ai", "title" => "Settings",
"title" => "Settings", "subtitle" => "AI"
"subtitle" => "AI" },
}, :pin} :pin
) )
assign(socket, :action_error, nil) |> build_data() assign(socket, :action_error, nil) |> build_data()
@@ -421,14 +446,14 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") || Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") ||
socket.assigns.conversation_id socket.assigns.conversation_id
notify_parent( Notify.open_sidebar_item(
{:open_sidebar_item, %{
%{ "route" => "chat",
"route" => "chat", "id" => chat_id,
"id" => chat_id, "title" => Map.get(payload, "title", "Chat"),
"title" => Map.get(payload, "title", "Chat"), "subtitle" => Map.get(payload, "subtitle", "")
"subtitle" => Map.get(payload, "subtitle", "") },
}, :pin} :pin
) )
assign(socket, :action_error, nil) |> build_data() assign(socket, :action_error, nil) |> build_data()
@@ -439,20 +464,20 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
set_action_error(socket, "Invalid payload for switchView action") set_action_error(socket, "Invalid payload for switchView action")
view -> view ->
notify_parent({:chat_editor_switch_view, view}) Notify.parent({:chat_editor_switch_view, view})
assign(socket, :action_error, nil) |> build_data() assign(socket, :action_error, nil) |> build_data()
end end
:toggle_sidebar -> :toggle_sidebar ->
notify_parent({:chat_editor_toggle_sidebar}) Notify.parent({:chat_editor_toggle_sidebar})
assign(socket, :action_error, nil) |> build_data() assign(socket, :action_error, nil) |> build_data()
:toggle_panel -> :toggle_panel ->
notify_parent({:chat_editor_toggle_panel}) Notify.parent({:chat_editor_toggle_panel})
assign(socket, :action_error, nil) |> build_data() assign(socket, :action_error, nil) |> build_data()
:toggle_assistant_sidebar -> :toggle_assistant_sidebar ->
notify_parent({:chat_editor_toggle_assistant_sidebar}) Notify.parent({:chat_editor_toggle_assistant_sidebar})
assign(socket, :action_error, nil) |> build_data() assign(socket, :action_error, nil) |> build_data()
:unknown -> :unknown ->
@@ -518,10 +543,15 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
@spec markdown_html(binary()) :: Phoenix.HTML.Safe.t() @spec markdown_html(binary()) :: Phoenix.HTML.Safe.t()
def markdown_html(content) when is_binary(content) do def markdown_html(content) when is_binary(content) do
# Match Earmark's defaults (GFM tables, strikethrough, autolinks); escape
# raw HTML to text rather than rendering it, as the old escape: true did.
html = html =
case Earmark.as_html(content, escape: true) do case MDEx.to_html(content,
{:ok, rendered, _messages} -> rendered extension: [table: true, strikethrough: true, autolink: true],
{:error, rendered, _messages} -> rendered render: [escape: true]
) do
{:ok, rendered} -> rendered
{:error, _reason} -> ""
end end
|> rewrite_external_images() |> rewrite_external_images()
@@ -549,8 +579,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
def chart_width(_max_value, _value), do: 0 def chart_width(_max_value, _value), do: 0
@spec truthy?(term()) :: boolean() @spec truthy?(term()) :: boolean()
def truthy?(value) when value in [true, "true", 1, "1", "on"], do: true def truthy?(value), do: BDS.Values.truthy?(value)
def truthy?(_value), do: false
# ── HEEx components ─────────────────────────────────────────────────────── # ── HEEx components ───────────────────────────────────────────────────────
@@ -659,19 +688,116 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
<h3><%= @surface.title %></h3> <h3><%= @surface.title %></h3>
<% end %> <% end %>
<p class="chat-surface-chart-type"><%= @surface.chart_type %></p> <p class="chat-surface-chart-type"><%= @surface.chart_type %></p>
<div class="chat-surface-chart-list"> <%= case @surface.chart_type do %>
<%= for series <- @surface.series do %> <% chart_type when chart_type in ["pie", "donut"] -> %>
<div class="chat-surface-chart-row"> <% pie = BDS.Desktop.ShellLive.ChatEditor.ChartView.pie(@surface.series) %>
<div class="chat-surface-chart-meta"> <div class="chat-surface-chart-pie">
<span><%= series.label %></span> <svg class="chat-surface-chart-pie-svg" viewBox={"0 0 #{pie.size} #{pie.size}"} preserveAspectRatio="xMidYMid meet">
<span><%= series.value %></span> <%= for slice <- pie.slices do %>
</div> <%= if slice.full_circle do %>
<div class="chat-surface-chart-bar"> <circle class="chat-surface-chart-pie-slice" cx={pie.center} cy={pie.center} r={pie.radius} fill={slice.color}>
<span style={"width: #{chart_width(@surface.max_value, series.value)}%"}></span> <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>
</div> </div>
<% end %>
</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">
<div class="chat-surface-chart-meta">
<span><%= series.label %></span>
<span><%= series.value %></span>
</div>
<div class="chat-surface-chart-bar">
<span style={"width: #{chart_width(@surface.max_value, series.value)}%"}></span>
</div>
</div>
<% end %>
</div>
<% end %>
<% "metric" -> %> <% "metric" -> %>
<div class="chat-surface-metric"> <div class="chat-surface-metric">
@@ -822,8 +948,40 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
# ── Private helpers ─────────────────────────────────────────────────────── # ── Private helpers ───────────────────────────────────────────────────────
defp notify_parent(message) do @surface_state_debounce_ms 500
send(self(), message)
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 end
defp active_project_id(socket) do defp active_project_id(socket) do
@@ -835,7 +993,9 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
try do try do
Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid) Ecto.Adapters.SQL.Sandbox.allow(BDS.Repo, self(), pid)
rescue rescue
_error -> :ok error ->
Logger.debug("swallowed chat_editor allow_repo_sandbox error: #{inspect(error)}")
:ok
end end
else else
:ok :ok
@@ -871,9 +1031,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor do
defp present?(value) when is_binary(value), do: String.trim(value) != "" defp present?(value) when is_binary(value), do: String.trim(value) != ""
defp present?(value), do: not is_nil(value) defp present?(value), do: not is_nil(value)
defp format_error(%{kind: :endpoint_not_configured}), # :endpoint_not_configured is handled by its own case clause before this is
do: dgettext("ui", "Configure an API key in Settings to enable AI chat.") # reached; the chat surface already shows the configuration hint.
defp format_error(reason), do: inspect(reason) defp format_error(reason), do: inspect(reason)
defp parse_integer(value) when is_integer(value), do: value defp parse_integer(value) when is_integer(value), do: value

View 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

View File

@@ -189,6 +189,8 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|> mark_surfaces_expanded(assigns) |> mark_surfaces_expanded(assigns)
end 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) defp persisted_user_message_for_request?(messages, %{message: message} = request)
when is_binary(message) do when is_binary(message) do
messages messages
@@ -198,8 +200,6 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
end) end)
end end
defp persisted_user_message_for_request?(_messages, _request), do: false
defp persisted_assistant_content_for_request?(messages, request, content) defp persisted_assistant_content_for_request?(messages, request, content)
when is_binary(content) and content != "" do when is_binary(content) and content != "" do
messages messages

View File

@@ -87,7 +87,16 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
%{ %{
label: map_value(entry, "label", dgettext("ui", "Assistant")), label: map_value(entry, "label", dgettext("ui", "Assistant")),
value: numeric_value(map_value(entry, "value", 0)), 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) end)
@@ -95,7 +104,7 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
id: surface_id, id: surface_id,
type: "chart", type: "chart",
title: map_value(arguments, "title"), 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, series: series,
max_value: Enum.max([0 | Enum.map(series, & &1.value)]) max_value: Enum.max([0 | Enum.map(series, & &1.value)])
} }
@@ -287,13 +296,12 @@ defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
defp map_value(map, key, default \\ nil) defp map_value(map, key, default \\ nil)
defp map_value(map, key, default) when is_map(map) and is_binary(key) do defp map_value(map, key, default) when is_map(map) and is_binary(key) do
Map.get(map, key, Map.get(map, String.to_atom(key), default)) Map.get(map, key, Map.get(map, String.to_existing_atom(key), default))
rescue rescue
ArgumentError -> Map.get(map, key, default) ArgumentError -> Map.get(map, key, default)
end end
defp map_value(_map, _key, default), do: default defp map_value(_map, _key, default), do: default
defp truthy?(value) when value in [true, "true", 1, "1", "on"], do: true defp truthy?(value), do: BDS.Values.truthy?(value)
defp truthy?(_value), do: false
end end

View File

@@ -62,7 +62,7 @@
<h2><%= dgettext("ui", "API Key Required") %></h2> <h2><%= dgettext("ui", "API Key Required") %></h2>
<p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p> <p><%= dgettext("ui", "Configure an API key in Settings to enable AI chat.") %></p>
<div class="api-key-form"> <div class="api-key-form">
<button class="api-key-submit ui-button ui-button-primary" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= dgettext("ui", "Open Settings") %></button> <button class="api-key-submit primary ui-button ui-button-primary" type="button" phx-click="open_chat_settings" phx-target={@myself}><%= dgettext("ui", "Open Settings") %></button>
</div> </div>
</div> </div>
<% else %> <% else %>
@@ -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}> <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> <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 primary ui-button ui-button-primary" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself}>↑</button>
</form> </form>
<%= if @chat_editor.action_error do %> <%= if @chat_editor.action_error do %>

View File

@@ -0,0 +1,82 @@
defmodule BDS.Desktop.ShellLive.ContentState do
@moduledoc false
import Phoenix.Component, only: [assign: 3]
alias BDS.AI
alias BDS.Desktop.ShellData
alias BDS.Desktop.ShellLive.{SidebarState, SocketState, TabHelpers, TaskLocalization, TitlebarMenu}
def refresh_content(socket, workbench) do
projects =
case ShellData.project_snapshot() do
{:ok, data} -> data
{:error, :not_ready} -> ShellData.default_project_snapshot()
end
dashboard =
case ShellData.dashboard(projects.active_project_id) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Dashboard.empty_snapshot()
end
git_badge_count =
case ShellData.git_badge_count(projects.active_project_id) do
{:ok, count} -> count
{:error, :not_ready} -> 0
end
active_view_id = Atom.to_string(workbench.active_view)
sidebar_data =
case ShellData.sidebar_view(
projects.active_project_id,
active_view_id,
SidebarState.current_filters(socket, active_view_id)
) do
{:ok, data} -> data
{:error, :not_ready} -> BDS.UI.Sidebar.view(nil, active_view_id, %{})
end
sidebar_data = SidebarState.merge_ui_state(socket, active_view_id, sidebar_data)
socket
|> assign(:projects, projects)
|> assign(:current_project, ShellData.current_project(projects))
|> assign(:dashboard, dashboard)
|> assign(:dashboard_timeline_entries, Map.get(dashboard, :timeline_entries, []))
|> assign(:dashboard_category_counts, Map.get(dashboard, :category_counts, []))
|> assign(:dashboard_recent_posts, Map.get(dashboard, :recent_posts, []))
|> assign(
:dashboard_tag_cloud_items,
ShellData.dashboard_tag_cloud_items(Map.get(dashboard, :tag_cloud_items, []))
)
|> assign(:git_badge_count, git_badge_count)
|> assign(:sidebar_data, sidebar_data)
|> SocketState.refresh_layout(workbench)
end
def reload_shell(socket, workbench) do
tab_meta = TabHelpers.sync_tab_meta(workbench, socket.assigns[:tab_meta] || %{})
raw_task_status = BDS.Tasks.status_snapshot()
page_language = socket.assigns[:page_language] || ShellData.ui_language()
offline_mode =
if Phoenix.LiveView.connected?(socket) do
Map.get(socket.assigns, :offline_mode, AI.airplane_mode?(true))
else
Map.get(socket.assigns, :offline_mode, true)
end
task_status = TaskLocalization.localize_task_status(raw_task_status, page_language)
socket
|> assign(:tab_meta, tab_meta)
|> assign(:task_status, task_status)
|> assign(:offline_mode, offline_mode)
|> assign(:supported_ui_languages, ShellData.supported_ui_languages())
|> assign(:menu_groups, socket.assigns[:menu_groups] || TitlebarMenu.groups())
|> assign(:titlebar_menu_item_index, socket.assigns[:titlebar_menu_item_index])
|> refresh_content(workbench)
end
end

View 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): `![](bds-media://id)` 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
"![](bds-media://#{media.id})"
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

Some files were not shown because too many files have changed in this diff Show More