Compare commits
142 Commits
5a464920de
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7a4a9512c | |||
| 141c2bfc89 | |||
| a5ac74db91 | |||
| beca4d992f | |||
| 9e6d93a4b3 | |||
| e29dfb490a | |||
| f2b340ba86 | |||
| d18e0ef7f2 | |||
| 2d796cee83 | |||
| b052d59376 | |||
| 4a089b0856 | |||
| 2632649cdc | |||
| 782511d523 | |||
| 1cb59d7a78 | |||
| 9844f3555a | |||
| 99dc1c2216 | |||
| 71fb99af16 | |||
| 0808b27057 | |||
| ac4f5a3580 | |||
| 43a435f35d | |||
| 7b383d31ab | |||
| 09df925e9b | |||
| a4ecbabc21 | |||
| 2be43ca06d | |||
| d231f42363 | |||
| 7c00279b9d | |||
| b6f9cf58e1 | |||
| 3f77488e33 | |||
| 5c17751d55 | |||
| e4452ca504 | |||
| ce80f28e60 | |||
| f1de11a205 | |||
| ff219fd110 | |||
| de7ea12c9c | |||
| 1beffe6b07 | |||
| 999632dbe7 | |||
| 44b88056e3 | |||
| 35b3818d58 | |||
| 37db52c024 | |||
| f1445120fc | |||
| 14dfbd8829 | |||
| 24e9e9a022 | |||
| 88c689ee55 | |||
| e5429f7265 | |||
| 93a4159c31 | |||
| 06d80e2924 | |||
| 291dff697c | |||
| 9944b70ab1 | |||
| 723b8c6433 | |||
| 92334256cf | |||
| d3f45ba0dd | |||
| 3ce6010b87 | |||
| f704aba288 | |||
| 5282fcd241 | |||
| 7756d9f83c | |||
| 4ab0bc7b4e | |||
| eca89e51d2 | |||
| 8e715eec8b | |||
| 35017f9793 | |||
| b17e9cc3f8 | |||
| 6b6c985187 | |||
| cb46b45cda | |||
| 43a4610ce7 | |||
| 4de8492c4f | |||
| 4bee8cf1db | |||
| dbb93a66f6 | |||
| 483c13aaa3 | |||
| f3d8fbcbdc | |||
| c16afa4c00 | |||
| 0f193929da | |||
| fa76cdf11d | |||
| 98243cbd16 | |||
| 657ed58e80 | |||
| 556f33711f | |||
| 5bc2b4a338 | |||
| 9f17954ce3 | |||
| b9797809aa | |||
| 4fee1a6333 | |||
| 0075f25ef7 | |||
| 8d7e7419d4 | |||
| ce54e973ad | |||
| 6c7fde6b95 | |||
| eb8f5698e3 | |||
| 2be751400d | |||
| 73e066c330 | |||
| a4ea24faa2 | |||
| 45040f9f66 | |||
| 4cf0f5281b | |||
| 24f114c24e | |||
| 07fab7d1ab | |||
| c118412f56 | |||
| e0f13e325b | |||
| 631ceb0521 | |||
| 7db8f6d36b | |||
| c495a2ed0a | |||
| 64a5eb525d | |||
| fef722c4c9 | |||
| 11df11dbdb | |||
| a5193240ad | |||
| d3aa7f2438 | |||
| a17c549817 | |||
| 391a7f216f | |||
| c25720bf6e | |||
| e4db1d6d62 | |||
| f8b8ccabbd | |||
| b5ebea6ff2 | |||
| dd0c05b785 | |||
| 8a582ee6c7 | |||
| b2ced48cc5 | |||
| f39fe9c40d | |||
| be439f929f | |||
| 436485683e | |||
| e9ec301af9 | |||
| d92d05de92 | |||
| 6e6a751db0 | |||
| 661bc0037c | |||
| 5d70f1b55a | |||
| 59e5d71396 | |||
| 67b8c321c7 | |||
| 9f9dab85fc | |||
| 881056eb61 | |||
| abcae1dad7 | |||
| c12001307f | |||
| eb358bf512 | |||
| 07ce5f8b4d | |||
| 3505355980 | |||
| f6425de51d | |||
| 79ee67c2e0 | |||
| a95e9482a7 | |||
| 7f5077c6ad | |||
| 3133beffcb | |||
| 296a57814f | |||
| 62e44150b3 | |||
| fbc1cba52e | |||
| cf3c598911 | |||
| f76e48e409 | |||
| 7463875b81 | |||
| 52857f2959 | |||
| 3f985885a4 | |||
| 753f742b99 | |||
| 96402bb4f3 | |||
| 9691d931b3 |
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"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 *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -4,6 +4,7 @@
|
|||||||
"allium": true,
|
"allium": true,
|
||||||
"command": true,
|
"command": true,
|
||||||
"printf": true,
|
"printf": true,
|
||||||
"git ls-tree": true
|
"git ls-tree": true,
|
||||||
|
"/opt/homebrew/bin/allium": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
- you must use ecto to generate migrations and snapshots
|
- you must use ecto to generate migrations and snapshots
|
||||||
- on MacOS we use native menus and you have to hook them into the intercept for new menu items
|
- on MacOS we use native menus and you have to hook them into the intercept for new menu items
|
||||||
- there are two areas of localization, you sometimes need both (menus for example)
|
- there are two areas of localization, you sometimes need both (menus for example)
|
||||||
|
- localization is done with elixier gettext and you need mix gettext.extract to update translation files
|
||||||
- all automatic AI activities must be gated by airplane (offline) mode of the app and either use the local model or inform the user via toast
|
- all automatic AI activities must be gated by airplane (offline) mode of the app and either use the local model or inform the user via toast
|
||||||
- metadata needs to be flushed to the filesystem and needs to be included in metadata diff tool and in rebuild from filesystem. All three aspects have to be in sync with each other.
|
- metadata needs to be flushed to the filesystem and needs to be included in metadata diff tool and in rebuild from filesystem. All three aspects have to be in sync with each other.
|
||||||
- if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database
|
- if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database
|
||||||
|
|||||||
500
CODESMELL.md
500
CODESMELL.md
@@ -1,500 +0,0 @@
|
|||||||
# Elixir Code Smell Analysis — bDS2
|
|
||||||
|
|
||||||
> Generated by static analysis of the codebase. Do not delete; use as a reference for refactoring sprints.
|
|
||||||
>
|
|
||||||
> **Updated 2026-04-30:** Each claim re-verified against current code. See "Verification Summary" and "Priority Order" sections at the bottom.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
The project is architecturally sound at a high level: contexts are well-defined, Ecto is used properly for schema validation, `with` blocks handle errors cleanly, and the GenServer implementations are mostly correct. However, **the codebase suffers from severe module bloat, excessive duplication of helper functions, risky process-dictionary usage, and several side-effect-in-transaction anti-patterns** that become maintenance liabilities in a desktop application expected to run for long sessions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Critical Anti-Patterns
|
|
||||||
|
|
||||||
### 1. Massive Module Bloat (Most Serious)
|
|
||||||
|
|
||||||
Several modules are far too large and violate the single-responsibility principle.
|
|
||||||
|
|
||||||
| Module | Lines | Issue |
|
|
||||||
|--------|-------|-------|
|
|
||||||
| `BDS.Desktop.ShellLive` | 2,607 | Handles UI events, routing, overlays, menus, project switching, tab management, translations, task polling, and native menu integration |
|
|
||||||
| `BDS.Posts` | 1,709 | Handles CRUD, publishing, file I/O, translations, auto-translation scheduling, embeddings sync, search sync, link rebuilding, and validation |
|
|
||||||
| `BDS.Generation` | 2,624 | Site generation, validation, sitemap building, archive pagination, feed rendering, and file hashing (verified larger than originally documented) |
|
|
||||||
| `BDS.MCP` | 670 | MCP tools, resources, proposals, post detail serialization, search, and category counting |
|
|
||||||
| `BDS.Media` | 939 | Media CRUD, thumbnail generation, sidecar I/O, post-media join management |
|
|
||||||
|
|
||||||
**Why this is an anti-pattern:** Giant modules reduce compiler concurrency, make refactoring dangerous, obscure test coverage, and increase merge conflicts. Elixir/OTP best practice is to keep modules under ~400 lines and split by responsibility.
|
|
||||||
|
|
||||||
**Fix:** Extract sub-domains into dedicated modules (e.g., `BDS.Posts.Publisher`, `BDS.Posts.Translations`, `BDS.Generation.SitemapBuilder`, `BDS.Desktop.ShellLive.EventRouter`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. Process Dictionary for I18n State
|
|
||||||
(and 13 other call sites across all `*_editor.ex` modules — 15 total)
|
|
||||||
In `BDS.Desktop.ShellLive`:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
def render(assigns) do
|
|
||||||
Process.put(:bds_ui_locale, assigns.page_language)
|
|
||||||
index(assigns)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
And later:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
defp translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is an anti-pattern:** The process dictionary is implicit global state. It makes functions harder to test in isolation and can leak state between concurrent operations in the same process. Elixir strongly discourages `Process.put/get` for business logic.
|
|
||||||
|
|
||||||
**Fix:** Pass `locale` explicitly through `assigns` to all translation call sites, or use a dedicated context struct.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Side Effects Inside Database Transactions
|
|
||||||
(7 transactions in `lib/bds/media.ex` follow this pattern)
|
|
||||||
In `BDS.Media.import_media/1`:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
Repo.transaction(fn ->
|
|
||||||
media = %Media{} |> Media.changeset(...) |> Repo.insert!()
|
|
||||||
:ok = File.mkdir_p(Path.dirname(destination))
|
|
||||||
:ok = File.cp(source_path, destination)
|
|
||||||
:ok = write_sidecar(project, media)
|
|
||||||
:ok = ensure_thumbnails(project, media)
|
|
||||||
:ok = Search.sync_media(media)
|
|
||||||
media
|
|
||||||
end)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is an anti-pattern:** If the transaction retries (or the DB connection times out in SQLite), filesystem and thumbnail side effects are duplicated or orphaned. Ecto transactions should contain **only** database operations.
|
|
||||||
|
|
||||||
**Fix:** Perform the DB insert in the transaction, then do filesystem work after the transaction succeeds.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. `String.to_atom/1` on Untrusted Input
|
|
||||||
|
|
||||||
In `BDS.MCP`:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
defp atomize_keys(map) when is_map(map) do
|
|
||||||
Map.new(map, fn {key, value} -> {String.to_atom(key), value} end)
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is an anti-pattern:** Atoms are not garbage collected. If MCP receives large or arbitrary JSON keys, the VM atom table can fill up and crash the BEAM.
|
|
||||||
|
|
||||||
**Fix:** Use `String.to_existing_atom/1` (with a safe fallback) or keep keys as strings.
|
|
||||||
Note: 10 other `String.to_atom` call sites exist, but they operate on bounded enums (modality, platform, view id) and are lower risk.
|
|
||||||
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Bang Functions (`File.read!`) in Business Logic Without Rescue
|
|
||||||
|
|
||||||
Found in `BDS.Posts`, `BDS.Media`, `BDS.MCP`, `BDS.Generation`:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
defp parse_rebuild_file(project, path) do
|
|
||||||
contents = File.read!(path)
|
|
||||||
{:ok, %{fields: fields}} = Frontmatter.parse_document(contents)
|
|
||||||
...
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is an anti-pattern:** Missing files or permission errors raise unhandled exceptions that crash the caller Limit the change to files reached from long-running processes; mandatory configuration files can stay as `File.read!`.. In a GenServer context (e.g., background tasks), this brings down the worker.
|
|
||||||
|
|
||||||
**Fix:** Use `File.read/1` and handle `{:error, reason}` explicitly, returning `{:error, reason}` tuples to callers.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. Duplicate Helper Functions Across Contexts
|
|
||||||
|
|
||||||
The same private helpers are copy-pasted in `BDS.Posts`, `BDS.Media`, `BDS.Search`, `BDS.Generation`, `BDS.Publishing`, `BDS.MCP`:
|
|
||||||
|
|
||||||
- `attr/2` (atom/string key normalization)
|
|
||||||
- `maybe_put/3`
|
|
||||||
- `blank_to_nil/1`
|
|
||||||
- `progress_callback/1`
|
|
||||||
- `report_rebuild_started/3`, `report_rebuild_progress/4`
|
|
||||||
|
|
||||||
**Why this is an anti-pattern:** Violates DRY. When behavior needs to change (e.g., adding float progress support), every copy must be updated.
|
|
||||||
|
|
||||||
**Fix:** Extract to a shared utility module such as `BDS.MapUtils`, `BDS.AttrUtils`, or `BDS.ProgressReporter`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. Direct Repo Access in LiveView (10 sites verified)
|
|
||||||
|
|
||||||
`BDS.Desktop.ShellLive` directly queries the repo:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
case Repo.get(Post, post_id) do
|
|
||||||
%Post{} = post -> ...
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is an anti-pattern:** LiveViews are the web layer; they should call context functions (`BDS.Posts.get_post/1`). Direct Repo access leaks persistence details into the UI and makes testing harder.
|
|
||||||
|
|
||||||
**Fix:** Replace all `Repo.get` calls in `ShellLive` with context function calls.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. `String.to_existing_atom/1` on User Input Without Whitelist
|
|
||||||
|
|
||||||
In `ShellLive`:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
def handle_event("select_view", %{"view" => view_id}, socket) do
|
|
||||||
workbench = Workbench.click_activity(socket.assigns.workbench, String.to_existing_atom(view_id))
|
|
||||||
...
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is an anti-pattern:** If a client sends a non-existent atom string, it raises `ArgumentError`. While safer than `to_atom`, it is still a crash vector.
|
|
||||||
|
|
||||||
**Fix:** Validate against a whitelist or map of known string-to-atom conversions before conversion.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. Nested Exception Handling for Control Flow
|
|
||||||
|
|
||||||
In `ShellLive`:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
defp safe_existing_atom(action) when is_binary(action) do
|
|
||||||
String.to_existing_atom(action)
|
|
||||||
rescue
|
|
||||||
ArgumentError -> nil
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is an anti-pattern:** Using exceptions for expected control flow is slower and harder to follow than explicit validation.
|
|
||||||
|
|
||||||
**Fix:** Maintain an explicit mapping of allowed actions, or use a `case` with `:erlang.binary_to_existing_atom/2` and catch the error tuple.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 10. `Jason.decode!` Without Err and `BDS.AI` (HTTP-response decoding)or Handling
|
|
||||||
|
|
||||||
In `BDS.AI.OpenAICompatibleRuntime`:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
defp normalize_response(body) do
|
|
||||||
payload = Jason.decode!(body)
|
|
||||||
...
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
**Why this is an anti-pattern:** Malformed JSON from an externa Limit the change to HTTP-response decoding; decoding of our own on-disk files (`metadata.ex`, `embeddings/index.ex`, etc.) is acceptable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Moderate Concerns
|
|
||||||
|
|
||||||
### 11. Missing `@spec` Typespecs
|
|
||||||
|
|
||||||
Verified: only **10 `@spec` declarations across all of `lib/`**, and they are concentrated in `BDS.Scripting` / `BDS.Scripting.Lua`. Every other public context function relies on Dialyzer inference. For a project of this size, adding `@spec` to public APIs is the single highest-ROI type-safety improvement available — and pairs well with the existing `mix dialyzer --format short` check.
|
|
||||||
|
|
||||||
### 12. Tests Run Synchronously
|
|
||||||
|
|
||||||
Most tests use `use ExUnit.Case, async: false`. Many tests share the SQLite repo and named GenServers, so they cannot realistically be async. Limit conversion to pure-logic test files (parsers, formatters, etc.).
|
|
||||||
|
|
||||||
### 13. Raw SQL Overuse
|
|
||||||
|
|
||||||
19 `Repo.query` sites split into two groups:
|
|
||||||
|
|
||||||
1. **FTS5 virtual tables** in `BDS.Search` (`posts_fts`, `media_fts`, `MATCH`, `bm25`, `rank`). Ecto cannot express FTS5 queries cleanly. **Keep these raw.**
|
|
||||||
2. **`post_media` join table** queried by hand in `BDS.Posts`, `BDS.Media`, `BDS.Desktop.ShellLive.OverlayComponents`, `BDS.Desktop.ShellLive.PostEditor`. There is no `BDS.PostMedia` schema. This is the real type-safety hole; introducing the schema replaces 6–8 raw queries with Ecto
|
|
||||||
|
|
||||||
While SQLite FTS necessitates some raw SQL, many queries in `BDS.Search`, `BDS.Media`, and `BDS.Posts` use `Repo.query!/2` for standard operations that Ecto could express portably (e.g., `DELETE FROM ... WHERE ...`). This reduces type safety and database portability.
|
|
||||||
|
|
||||||
### 14. Atom/String Key Duality
|
|
||||||
|
|
||||||
Many functions normalize between atom and string keys repeatedly:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
Map.get(assigns, :language, Map.get(assigns, "language", ...))
|
|
||||||
```
|
|
||||||
|
|
||||||
This suggests data isn't normalized at boundaries. Prefer atoms for internal structs and strings only at external boundaries (JSON, HTTP params).
|
|
||||||
|
|
||||||
### 15. GenServer State Growth (Tasks)
|
|
||||||
|
|
||||||
`BDS.Tasks` stores all tasks forever in a single map. The `clear_completed`/`clear_finished` functions exist, but if the UI doesn't trigger them regularly in a long-running desktop app, memory grows unbounded. Consider TTL-based eviction.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What the Project Does Well
|
|
||||||
|
|
||||||
- **Contexts are clearly separated** (`Posts`, `Media`, `Projects`, `Search`, etc.).
|
|
||||||
- **Ecto changesets** are used consistently for validation.
|
|
||||||
- **`with` blocks** handle multi-step error flows cleanly.
|
|
||||||
- **Pattern matching** is idiomatic and pervasive.
|
|
||||||
- **Task.Supervisor** is used correctly for background work.
|
|
||||||
- *Verification Summary (2026-04-30)
|
|
||||||
|
|
||||||
| # | Claim | Verified | Notes |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 1 | Module bloat | ✅ | `Generation` is 2624 lines, worse than originally documented. |
|
|
||||||
| 2 | `Process.put(:bds_ui_locale)` | ✅ | 15 call sites across `shell_live.ex` and every `*_editor.ex`. |
|
|
||||||
| 3 | Side effects in transactions | ✅ | 7 transactions in `lib/bds/media.ex` wrap filesystem + Search side effects. |
|
|
||||||
| 4 | `String.to_atom` on untrusted input | ⚠️ Partly | Only `MCP.atomize_keys` is on arbitrary external JSON. Other 10 sites are bounded enums. |
|
|
||||||
| 5 | `File.read!` | ⚠️ Partly | Most sites read mandatory config; only loop/background paths are real risk. |
|
|
||||||
| 6 | Duplicated helpers | ✅ | Real DRY violation across contexts. |
|
|
||||||
| 7 | `Repo.get` in `ShellLive` | ✅ | 10 direct calls. |
|
|
||||||
| 8/9 | `to_existing_atom` + rescue | ⚠️ | The rescue effectively *is* the whitelist; cosmetic priority. |
|
|
||||||
| 10 | `Jason.decode!` on external responses | ⚠️ Partly | Only 2 of 14 sites decode HTTP responses; the rest decode our own files. |
|
|
||||||
| 11 | Missing `@spec` | ✅ | 10 specs in entire `lib/`. Highest type-safety ROI. |
|
|
||||||
| 12 | Tests synchronous | ✅ | Limited convertibility — most tests share repo/GenServers. |
|
|
||||||
| 13 | Raw SQL | ⚠️ Partly | FTS5 must stay raw; `post_media` table is the real fix target. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Priority Order
|
|
||||||
|
|
||||||
1. **Add `@spec` to public APIs of core contexts.** ✅ **DONE 2026-04-30.** See "Priority #1 Completion" section below.
|
|
||||||
2. **Extract filesystem / Search side effects out of `Repo.transaction` in `BDS.Media`.** ✅ **DONE 2026-04-30.** See "Priority #2 Completion" section below.
|
|
||||||
3. **Fix `MCP.atomize_keys`** to use `String.to_existing_atom/1` with a string-fallback. ✅ **DONE 2026-04-30.** See "Priority #3 Completion" section below.
|
|
||||||
4. **Introduce `BDS.PostMedia` Ecto schema** and migrate the 6–8 raw `post_media` queries. ✅ **DONE 2026-04-30.** See "Priority #4 Completion" section below.
|
|
||||||
5. **Module split.** `BDS.Generation` (2624) and `BDS.Desktop.ShellLive` (2607) first, then `BDS.AI` (1700+) and `BDS.Posts`. ✅ **PARTIAL 2026-04-30 / 2026-05-01.** `BDS.Generation` reduced 2651 → 647 (76%). See "Priority #5 Progress" section below.
|
|
||||||
6. **Replace `Repo.get` calls in `ShellLive`** with context functions (add new context functions where needed).
|
|
||||||
7. **Move locale from `Process.put` into assigns**, then ban `Process.put` via Credo.
|
|
||||||
8. **Extract shared helpers** (`attr/2`, `maybe_put/3`, `blank_to_nil/1`, `progress_callback/1`, rebuild progress reporters) into `BDS.MapUtils` / `BDS.ProgressReporter`.
|
|
||||||
9. **Wrap external `Jason.decode!` calls** in `BDS.AI.OpenAICompatibleRuntime` and `BDS.AI` with `Jason.decode/1` + `{:error, _}` propagation.
|
|
||||||
|
|
||||||
### Skipped / downgraded
|
|
||||||
|
|
||||||
- #5 in bulk — convert only paths reached from long-running processes.
|
|
||||||
- #8 / #9 — cosmetic; the rescue functions as a whitelist today.
|
|
||||||
- #12 — leave `async: false` where the repo or named GenServers are involved.
|
|
||||||
- #15 — bounded in practice; ensure UI triggers `clear_finished` periodically.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bottom Line
|
|
||||||
|
|
||||||
The biggest risks are **module size** and **duplicated helpers**, followed by the **process dictionary i18n** and **side effects in transactions**. The single highest-ROI improvement for type safety is **adding `@spec` to public context APIs** so Dialyzer can do its job — that is the starting point of the priority order above
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Priority #1 Completion (2026-04-30)
|
|
||||||
|
|
||||||
Added `@spec` and `@type` declarations to the public APIs of all core contexts and their schemas. Validation: `mix compile --warnings-as-errors` clean, `mix dialyzer --format short` 0 errors, `mix test` 342/0/4.
|
|
||||||
|
|
||||||
**Files modified (specs added):**
|
|
||||||
|
|
||||||
- `lib/bds/projects.ex`, `lib/bds/projects/project.ex`
|
|
||||||
- `lib/bds/posts.ex`, `lib/bds/posts/post.ex`, `lib/bds/posts/translation.ex`, `lib/bds/posts/link.ex`
|
|
||||||
- `lib/bds/media.ex`, `lib/bds/media/media.ex`, `lib/bds/media/translation.ex`
|
|
||||||
- `lib/bds/search.ex`
|
|
||||||
- `lib/bds/publishing.ex`, `lib/bds/publishing/publish_job.ex`
|
|
||||||
- `lib/bds/generation.ex`
|
|
||||||
- `lib/bds/metadata.ex`
|
|
||||||
- `lib/bds/mcp.ex`
|
|
||||||
- `lib/bds/ai.ex`
|
|
||||||
|
|
||||||
**Bugs surfaced and fixed by Dialyzer once specs were in place:**
|
|
||||||
|
|
||||||
- `BDS.Search.list_stemmer_languages/0` returns `[String.t()]`, not `[{String.t(), [String.t()]}]`.
|
|
||||||
- `BDS.Media.sync_media_sidecar/1` returns `:ok` (not `{:ok, t()}`); the `posts.ex` caller was already pattern-matching on `:ok`.
|
|
||||||
- `BDS.Media.replace_media_file/2` can return `{:ok, nil}` when the new file's checksum is unchanged.
|
|
||||||
- Removed unreachable `other -> {:error, other}` fall-through clauses in the auto-translate cascades in `BDS.Posts` (the preceding `{:error, reason}` pattern already covers the only remaining return shape).
|
|
||||||
- Removed unreachable `defp blank?(_value)` clause in `BDS.Desktop.ShellLive.ChatEditor` (prior clauses already covered `binary` and `nil`, no other types reach the function).
|
|
||||||
|
|
||||||
**Conventions established for future spec work:**
|
|
||||||
|
|
||||||
- Ecto schemas need explicit `@type t` — Ecto does not generate one.
|
|
||||||
- Use `term()` for `belongs_to` / `has_many` association fields to avoid circular type dependencies between sibling schemas.
|
|
||||||
- Use `@typedoc` + named types (e.g. `attrs`, `metadata_state`, `search_filters`, `reindex_opts`) to avoid repeating large map shapes across many specs in the same module.
|
|
||||||
- For `update_*` / attrs-style maps, the canonical type is `%{optional(atom()) => term(), optional(String.t()) => term()}` because both renderer-supplied string keys and Elixir atom keys flow into them.
|
|
||||||
|
|
||||||
**Not yet specced (intentional, out of scope of priority #1):**
|
|
||||||
|
|
||||||
- LiveView modules under `lib/bds/desktop/shell_live/`. These are Phoenix LiveView callbacks (`mount/3`, `handle_event/3`, `handle_info/2`) whose specs are inherited from the behaviour. Public helper functions in those modules can be specced as part of the eventual ShellLive module split (priority #9).
|
|
||||||
- Internal helpers in `BDS.MCP` and `BDS.AI` that are private (`defp`) — Dialyzer infers these.
|
|
||||||
- `BDS.Tags`, `BDS.Templates`, `BDS.Scripts`, `BDS.PostLinks` — smaller contexts; queue for a follow-up pass if Dialyzer surfaces issues.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Priority #2 Completion (2026-04-30)
|
|
||||||
|
|
||||||
Refactored every `Repo.transaction/1` block in [lib/bds/media.ex](lib/bds/media.ex) so that only DB writes run inside the transaction. Filesystem writes (`File.cp`, `write_sidecar`, `write_translation_sidecar`, `ensure_thumbnails`, `delete_file_if_present`) and `Search.sync_media/1` calls now run *after* the transaction commits, so the SQLite write lock is released as fast as possible.
|
|
||||||
|
|
||||||
**Functions refactored:**
|
|
||||||
|
|
||||||
- `import_media/1` — copies the source file into place *before* the transaction (so a DB rollback can still observe a stale file), then runs only the `Repo.insert!` inside the transaction, then writes sidecar / thumbnails / search index. On DB failure the copied file is removed.
|
|
||||||
- `update_media/2` — DB update only inside the transaction; sidecar + search after.
|
|
||||||
- `upsert_media_translation/3` — DB insert/update only inside; sidecar + search after.
|
|
||||||
- `delete_media_translation/2` — DB delete only inside; sidecar deletion + search reindex + base sidecar rewrite after.
|
|
||||||
- `replace_media_file/2` — moves the existing destination to a `.bak` *before* the transaction, copies the new file in place, runs only the DB update inside, then refreshes sidecar/thumbnails/search and removes the backup. On DB failure the original file is restored from the backup.
|
|
||||||
- `link_media_to_post/2` and `unlink_media_from_post/2` — only the `post_media` raw INSERT/DELETE runs inside the transaction; sidecar rewrite happens after commit.
|
|
||||||
|
|
||||||
**Why this matters:**
|
|
||||||
|
|
||||||
SQLite has a single global write lock. Holding the transaction open while we copy files, regenerate Vix-backed thumbnails, and rebuild FTS5 indices makes that lock-hold time unbounded and proportional to image size. Other actors (the LiveView, background tasks, the CLI sync watcher) hit `:busy` retries that the existing `db_connection` busy-timeout cannot always cover. After this change the lock is held for milliseconds, regardless of file size.
|
|
||||||
|
|
||||||
**Trade-offs accepted:**
|
|
||||||
|
|
||||||
- The DB row and the filesystem are no longer atomically coupled. If the BEAM crashes between `Repo.insert!` and `write_sidecar/2`, the row exists without a sidecar. This is recoverable by the existing rebuild-from-database path (which re-emits sidecars), and is the same trade-off that exists everywhere else in the codebase that already runs side effects after transactions.
|
|
||||||
- `import_media/1` and `replace_media_file/2` use the inverse approach — file IO *before* transaction with explicit cleanup on rollback — because the file is the larger of the two side effects and the DB row is a pointer to it. This keeps the destination consistent on rollback.
|
|
||||||
|
|
||||||
**Spec correction surfaced by Dialyzer:**
|
|
||||||
|
|
||||||
- `BDS.Media.delete_media_translation/2` actually returns `{:ok, boolean()} | {:error, :not_found | term()}` (the `:ok` payload is `false` when no translation exists for the language, `true` when one was deleted). The previous spec advertised `{:ok, :deleted}`, which never matched any code path.
|
|
||||||
|
|
||||||
**Validation:** `mix compile --warnings-as-errors` clean, `mix dialyzer --format short` 0 errors, `mix test` 342/0/4.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Priority #3 Completion (2026-04-30)
|
|
||||||
|
|
||||||
Removed `BDS.MCP.atomize_keys/1` entirely instead of just narrowing it to `String.to_existing_atom/1`. The function existed only to convert MCP-proposal JSON keys (attacker-controlled strings) into atoms before passing them to `Media.update_media/2` and `Posts.update_post/2`. Both contexts already accept string-keyed maps natively through their `attr/2` helper (`%{optional(atom()) => term(), optional(String.t()) => term()}`), so the conversion was both unnecessary and a textbook unbounded-atom-table DoS vector.
|
|
||||||
|
|
||||||
**Change:**
|
|
||||||
|
|
||||||
- Deleted `defp atomize_keys/1` from [lib/bds/mcp.ex](lib/bds/mcp.ex).
|
|
||||||
- The two `accept_proposal` call sites now pass `proposal.data["changes"] || %{}` straight through to `Media.update_media/2` and `Posts.update_post/2`.
|
|
||||||
|
|
||||||
**Why removal beats whitelisting:**
|
|
||||||
|
|
||||||
- `String.to_existing_atom/1` would still need a whitelist or a `try/rescue` wrapper, both of which add code without buying type safety — the downstream contexts already key on either form.
|
|
||||||
- Removing the function eliminates the only place in MCP that converts external JSON to atoms; the surface area for atom-table attacks via the MCP tool API is now zero.
|
|
||||||
- The `attr/2` helpers in `BDS.Posts` and `BDS.Media` are the *single* canonical place where attribute key normalization happens, which is exactly the invariant we want.
|
|
||||||
|
|
||||||
**Other `String.to_atom/1` call sites considered:**
|
|
||||||
|
|
||||||
The codebase has nine other `String.to_atom/1` call sites; all of them operate on bounded inputs (`Workbench` type names, release platforms, route IDs from the router config, `:supports_attachment` / `:supports_tool_calls` capability keys we ourselves wrote, `parse_modality` from a fixed AI catalog). None of them are reachable from attacker-controlled JSON the way `MCP.accept_proposal` was. They are safe to leave as a follow-up if a stricter sweep is wanted later.
|
|
||||||
|
|
||||||
**Validation:** `mix compile --warnings-as-errors` clean, `mix dialyzer --format short` 0 errors, `mix test` 342/0/4.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Priority #4 Completion (2026-04-30)
|
|
||||||
|
|
||||||
Introduced [lib/bds/posts/post_media.ex](lib/bds/posts/post_media.ex) — a proper `Ecto.Schema` for the `post_media` join table — and migrated every raw SQL / string-table query in the codebase to use it.
|
|
||||||
|
|
||||||
**New schema:** `BDS.Posts.PostMedia` with fields `id`, `project_id`, `post_id`, `media_id`, `sort_order`, `created_at`, `belongs_to :post` and `belongs_to :media` associations, full `@type t`, and a `changeset/2` enforcing `validate_required` + `foreign_key_constraint` + `unique_constraint([:post_id, :media_id])`.
|
|
||||||
|
|
||||||
**Call sites migrated:**
|
|
||||||
|
|
||||||
- [lib/bds/media.ex](lib/bds/media.ex)
|
|
||||||
- `list_linked_posts/1` — replaced `join: post_media in "post_media"` string-table join with `join: pm in PostMedia`.
|
|
||||||
- `link_media_to_post/2` — replaced `Repo.query("SELECT 1 FROM post_media …")` with `Repo.exists?(from pm in PostMedia, …)` and `Repo.query("INSERT INTO post_media …")` with `%PostMedia{} |> changeset() |> Repo.insert!()` (uniqueness now enforced through the schema constraint).
|
|
||||||
- `unlink_media_from_post/2` — replaced `Repo.query("DELETE FROM post_media …")` with `Repo.delete_all(from pm in PostMedia, …)`.
|
|
||||||
- `linked_post_ids/1` — replaced raw `SELECT post_id` with `Repo.all(from pm in PostMedia, select: pm.post_id)`.
|
|
||||||
- `next_sort_order/1` — replaced raw `SELECT COALESCE(MAX(sort_order), -1)` with `Repo.one(from pm in PostMedia, select: max(pm.sort_order))` and a `value when is_integer(value)` guard for the empty-table case.
|
|
||||||
- [lib/bds/posts.ex](lib/bds/posts.ex)
|
|
||||||
- `linked_media_ids/1` — replaced raw `SELECT media_id` with `Repo.all(from pm in PostMedia, select: pm.media_id)`.
|
|
||||||
- [lib/bds/desktop/shell_live/post_editor.ex](lib/bds/desktop/shell_live/post_editor.ex)
|
|
||||||
- `linked_media/1` — replaced raw `SELECT media_id, sort_order` with a `Repo.all(from pm in PostMedia, select: {pm.media_id, pm.sort_order})` query.
|
|
||||||
- [lib/bds/desktop/shell_live/overlay_components.ex](lib/bds/desktop/shell_live/overlay_components.ex)
|
|
||||||
- `post_media_ids/1` — replaced raw `SELECT media_id` with `Repo.all(from pm in PostMedia, select: pm.media_id)`.
|
|
||||||
- `delete_details/2` — replaced the raw `SELECT posts.title FROM posts JOIN post_media ON …` with a typed Ecto join query (`from post in Post, join: pm in PostMedia, on: pm.post_id == post.id, …`).
|
|
||||||
|
|
||||||
**Why this matters:**
|
|
||||||
|
|
||||||
- All `post_media` access now goes through a typed schema, so every column reference is checked at compile time by Ecto and any future migration that renames a column will produce a `Postgrex/Sqlite` error at compile or test time instead of silently breaking at runtime.
|
|
||||||
- The unique `(post_id, media_id)` constraint is now enforceable via `Ecto.Changeset.unique_constraint/2`, which would let the next refactor turn `link_media_to_post/2` into an idempotent upsert without losing the protection.
|
|
||||||
- `Repo.query/2` is reserved for the few remaining cases that genuinely need raw SQL (FTS5 virtual tables in `BDS.Search`, which are not Ecto-mappable).
|
|
||||||
|
|
||||||
**Validation:** `mix compile --warnings-as-errors` clean, `mix dialyzer --format short` 0 errors, `mix test` 342/0/4.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Priority #5 Progress (2026-04-30 / 2026-05-01)
|
|
||||||
|
|
||||||
**Goal:** Split god modules. Started with the worst offender, `BDS.Generation` (2651 lines).
|
|
||||||
|
|
||||||
**Result:** `lib/bds/generation.ex` reduced **2651 → 647 lines (76%)** by extracting nine cohesive submodules under `lib/bds/generation/`:
|
|
||||||
|
|
||||||
| Module | Lines | Responsibility |
|
|
||||||
|---|---|---|
|
|
||||||
| `BDS.Generation.Outputs` | 490 | All `build_*_outputs/*` builders + `*_route_paths` + `additional_languages`, `route_post_output_path`, `suppress_subtree_translation_variants` |
|
|
||||||
| `BDS.Generation.Validation` | 445 | `compare_sitemap_to_html`, `plan_validation_paths`, `build_targeted_validation_plan`, `targeted_output?`, `prune_empty_parent_dirs`, post/lang timestamp checks |
|
|
||||||
| `BDS.Generation.Data` | 352 | `generation_data/2`, snapshot loaders, post-index builders, translation-lookup helpers |
|
|
||||||
| `BDS.Generation.Sitemap` | 280 | sitemap.xml, RSS/Atom feeds, calendar feed, hreflang link assembly |
|
|
||||||
| `BDS.Generation.Paths` | 262 | URL/route/path helpers, language prefixing, pagination math, archive routing |
|
|
||||||
| `BDS.Generation.Renderers` | 227 | Liquid template rendering wrappers (home, post, archive, date, list, 404) |
|
|
||||||
| `BDS.Generation.Progress` | 96 | Generation/validation progress callback helpers |
|
|
||||||
| `BDS.Generation.Pagefind` | 70 | Pagefind search-index input file emission |
|
|
||||||
| `BDS.Generation.GeneratedFileHash` | 23 | (pre-existing) hash-tracking schema |
|
|
||||||
|
|
||||||
Total: 2245 lines now live in focused submodules; the remaining 647 in `BDS.Generation` is the orchestrating `plan_generation/2`, `apply_validation/2`, `validate_site/3`, `write_generated_file`, and `delete_extra_validation_paths` — small enough to manage as a single coordinator.
|
|
||||||
|
|
||||||
**Refactor pattern used:** `import BDS.Generation.X, only: [...]` (or `except: [...]`) at the head of `BDS.Generation` so the hundreds of internal call sites needed no changes; `defdelegate` for any function that had to remain reachable through the public `BDS.Generation` namespace (e.g. `post_output_path/1,2`).
|
|
||||||
|
|
||||||
**Validation after each extraction:** `mix compile --warnings-as-errors` clean, `mix dialyzer --format short` 0 errors, `mix test` 342/0/4.
|
|
||||||
|
|
||||||
**Date-bug side fix:** `test/bds/maintenance_test.exs` had hardcoded `posts/2026/04/...` paths that worked only when the published-post setup happened to create the same year/month directory. With today's date in May, the orphan writes failed; added explicit `File.mkdir_p!` calls for the hardcoded fixture paths.
|
|
||||||
|
|
||||||
**Remaining work in this priority:**
|
|
||||||
|
|
||||||
- ✅ `BDS.Generation` — done (76% reduction, 647 lines remaining is acceptable for a coordinator).
|
|
||||||
- 🔄 `BDS.Desktop.ShellLive` (2607 → 1545, 41% reduction). Submodules extracted under `lib/bds/desktop/shell_live/`:
|
|
||||||
|
|
||||||
| Module | Lines | Responsibility |
|
|
||||||
|---|---|---|
|
|
||||||
| `TitlebarMenu` | 181 | Menu group definition, dropdown items, open/close/hover/keydown |
|
|
||||||
| `CliSync` | 133 | CLI watcher entity-change application + tab refresh |
|
|
||||||
| `PanelRenderer` | 290 | Tasks/output/post-links/git-log panel rendering + editor toolbar |
|
|
||||||
| `TabHelpers` | 99 | Tab title/subtitle/icon, route atom mapping, post/media labels |
|
|
||||||
| `TaskLocalization` | 80 | Task status localization, editor-meta translation |
|
|
||||||
| `ChatSurface` | 233 | Chat-surface action dispatch, assistant message helpers |
|
|
||||||
| `SidebarCreate` | 131 | New post/media/script/template/import sidebar creation |
|
|
||||||
| `Layout` | 53 | Sync-layout, resize-panel, parse-width, ignore-shortcut |
|
|
||||||
| `ShellCommandRunner` | 95 | `apply_shell_command` + `apply_result` dispatch |
|
|
||||||
| `SessionUtil` | 49 | Workbench-session restore, project-name picker, task-result tracking |
|
|
||||||
|
|
||||||
Coordinator (`shell_live.ex`) now 1545 lines containing only `mount/3`, `render/1`, `handle_event/3`, `handle_info/2` clauses, plus thin dispatchers and small editor-assign helpers.
|
|
||||||
- ⏳ `BDS.Posts` (1781 → 569, 68% reduction). Submodules extracted under `lib/bds/posts/`:
|
|
||||||
|
|
||||||
| Module | Lines | Responsibility |
|
|
||||||
|---|---|---|
|
|
||||||
| `Slugs` | 86 | `slug_available/3`, `unique_slug_for_title/3`, `unique`, `unique_for_import`, `default_source` |
|
|
||||||
| `AutoTranslation` | 176 | `maybe_schedule/1`, missing-language detection, post + cascading media auto-translate task scheduling |
|
|
||||||
| `FileSync` | 146 | Post/translation relative-path computation, frontmatter serialization, body extraction, on-disk delete |
|
|
||||||
| `TranslationValidation` | 464 | `validate/2`, `fix_invalid/1`, invalid DB/FS issue classification, legacy report fields, canonical-language helpers, markdown-file recursion |
|
|
||||||
| `RebuildFromFiles` | 320 | `rebuild_posts_from_files/2`, `import_orphan_post_file/2`, `import_orphan_post_translation_file/2`, `parse_rebuild_file`, `upsert_post_from_file`, `upsert_post_from_rebuild_file`, `upsert_post_translation_from_rebuild_file`, `progress_callback/1`, `report_rebuild_started/3`, `report_rebuild_progress/4`, `parse_post_status`, `parse_translation_status` |
|
|
||||||
| `Translations` | 279 | `publish_post_translation/2`, `list_post_translations/1`, `upsert_post_translation/3`, `delete_post_translation/1`, `sync_post_translation_from_file/1`, `rewrite_published_post_translation/1`, `publish_translation/2`, `publish_post_translations/1`, `normalize_translation_updates`, `maybe_reopen_source_post_for_manual_translation` |
|
|
||||||
|
|
||||||
Public API on `BDS.Posts` preserved via `defdelegate` for: `slug_available/3`, `unique_slug_for_title/3`, `validate_translations/2`, `fix_invalid_translations/1`, `rebuild_posts_from_files/2`, `import_orphan_post_file/2`, `import_orphan_post_translation_file/2`, `publish_post_translation/2`, `list_post_translations/1`, `upsert_post_translation/3`, `delete_post_translation/1`, `sync_post_translation_from_file/1`, `rewrite_published_post_translation/1`. Remaining clusters in posts.ex are core CRUD (`create_post`, `update_post`, `publish_post`, `delete_post`, `archive_post`, `discard_post_changes`, `sync_post_from_file`, `rewrite_published_post`, `editor_body`), small stats (`dashboard_stats`, `post_counts_by_year_month`, ~40 lines extractable), and `rebuild_post_links` (~22 lines). Stats could be split next, but ~569 lines is a reasonable steady state.
|
|
||||||
- ⏳ `BDS.AI` (1711 → 168, **90% reduction**). Submodules extracted under `lib/bds/ai/`:
|
|
||||||
|
|
||||||
| Module | Lines | Responsibility |
|
|
||||||
|---|---|---|
|
|
||||||
| `Chat` | 597 | Public chat API (`start_chat`, `list_chat_conversations`, `available_chat_models`, `set_conversation_model`, `list_chat_messages`, `send_chat_message`, `cancel_chat`) + chat round/tool-call orchestration, request building, message truncation, system prompt + project stats summary, conversation/message persistence, `count_distinct_string_list/3`, `normalize_usage/1` |
|
|
||||||
| `OneShot` | 382 | One-shot operations (`detect_language`, `analyze_taxonomy`, `analyze_import_taxonomy`, `analyze_post`, `translate_post`, `analyze_image`, `translate_media`) + per-op system/user prompt builders, JSON response extraction, post/media input normalization, taxonomy mapping filtering |
|
|
||||||
| `Catalog` | 306 | Model catalog API (`list_endpoint_models`, `refresh_model_catalog`, `list_catalog_providers`, `get_catalog_model`, `catalog_meta`, `put_model_capabilities`, `format_model`, `model_capabilities`, `decode_nullable_json`) + models.dev fetch, persistence, modality parsing |
|
|
||||||
| `ChatTools` | 271 | Chat tool dispatch (`execute/3`) for blog_stats, list_posts, list_media, render_table/chart/form/card/metric/list/tabs/mindmap; tool spec generation (`available_specs/2`) |
|
|
||||||
| `Runtime` | 100 | `model_preference_keys/0`, `resolve_target/2`, `validate_target/3`, `endpoint_with_model/2`, per-operation airplane/online resolution |
|
|
||||||
| `SettingsStore` | 78 | Setting/secret/catalog-meta storage helpers (`get_setting`, `put_setting`, `delete_setting`, `put_secret`, `get_secret`, `encrypted_key`, `secret_backend`, catalog meta) |
|
|
||||||
|
|
||||||
Public `BDS.AI` API preserved via `defdelegate` for all extracted operations. Remaining 168 lines hold endpoint storage (`put_endpoint`, `get_endpoint`, `delete_endpoint`), airplane mode (`set_airplane_mode`, `airplane_mode?`), model preferences (`put_model_preference`, `get_model_preference`), and the defdelegate facade.
|
|
||||||
|
|
||||||
- ⏳ `BDS.Scripting.Capabilities` (1715 → 194, **89% reduction**). Submodules extracted under `lib/bds/scripting/capabilities/`:
|
|
||||||
|
|
||||||
| Module | Lines | Responsibility |
|
|
||||||
|---|---|---|
|
|
||||||
| `Util` | 301 | Sanitization, normalization, arity wrappers, optional-key map builders, datetime parsing, project-path lookup, shell-open helpers |
|
|
||||||
| `Posts` | 270 | All `posts.*` capabilities (CRUD, publishing, body/cover/excerpt, search, tags, categories, archive, restore, preview path, names-with-counts) |
|
|
||||||
| `Media` | 254 | All `media.*` capabilities (CRUD, upload, thumbnails, metadata, translations, search) |
|
|
||||||
| `Crud` | 284 | `scripts.*`, `templates.*`, `tags.*`, `tasks.*` CRUD/search/exec |
|
|
||||||
| `Projects` | 204 | Project CRUD, metadata read/write, sync-meta-on-startup, data paths, project-for-folder |
|
|
||||||
| `AppShell` | 134 | Clipboard, bookmarklet, title-bar metrics, renderer-ready, open/select folder, show-in-folder, trigger-menu-action, preview-target, test-mode/env detection |
|
|
||||||
| `Bridges` | 176 | Sync availability, repo state/status/history/fetch/pull/push/commit-all, upload-site, AI detect/analyze/translate (post + media), embeddings progress/find-similar/compute-similarities/suggest-tags/find-duplicates/dismiss-pair/index-unindexed |
|
|
||||||
|
|
||||||
Public `BDS.Scripting.Capabilities.for_project/2` contract preserved unchanged. Main file (194 lines) now holds only the capability-map assembly using `import` of all submodules.
|
|
||||||
|
|
||||||
- ⏳ `BDS.MCP` (677).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Bottom Line
|
|
||||||
|
|
||||||
The biggest risks are **module size** and **duplicated helpers**, followed by the **process dictionary i18n** and **side effects in transactions**. Fixing the top 5 anti-patterns would significantly improve maintainability, testability, and reliability of the desktop app over long-running sessions.
|
|
||||||
474
DOCUMENTATION.md
Normal file
474
DOCUMENTATION.md
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
# bDS2 User Guide
|
||||||
|
|
||||||
|
## In this article
|
||||||
|
|
||||||
|
- [Who this guide is for](#who-this-guide-is-for)
|
||||||
|
- [How bDS2 works](#how-bds2-works)
|
||||||
|
- [Getting started](#getting-started)
|
||||||
|
- [Understanding the interface](#understanding-the-interface)
|
||||||
|
- [Working with posts](#working-with-posts)
|
||||||
|
- [Working with pages](#working-with-pages)
|
||||||
|
- [Working with media](#working-with-media)
|
||||||
|
- [Working with translations](#working-with-translations)
|
||||||
|
- [Using macros](#using-macros)
|
||||||
|
- [Using scripting](#using-scripting)
|
||||||
|
- [Using the AI assistant](#using-the-ai-assistant)
|
||||||
|
- [Organizing with tags](#organizing-with-tags)
|
||||||
|
- [Using blogmarks](#using-blogmarks)
|
||||||
|
- [Importing from WordPress (WXR)](#importing-from-wordpress-wxr)
|
||||||
|
- [Using Git (Source Control)](#using-git-source-control)
|
||||||
|
- [Configuring settings](#configuring-settings)
|
||||||
|
- [Checking and repairing metadata](#checking-and-repairing-metadata)
|
||||||
|
- [Managing templates](#managing-templates)
|
||||||
|
- [Generating and publishing](#generating-and-publishing)
|
||||||
|
- [Typical editorial workflows](#typical-editorial-workflows)
|
||||||
|
- [Working fully offline](#working-fully-offline)
|
||||||
|
- [Troubleshooting and recovery](#troubleshooting-and-recovery)
|
||||||
|
- [Team conventions](#team-conventions)
|
||||||
|
|
||||||
|
## Who this guide is for
|
||||||
|
|
||||||
|
This guide is for people who use bDS2 day to day to create, edit, organize, translate, generate, and publish blog content. It is written for editors, content managers, and project owners who need reliable guidance on what each part of the application does and how to use it safely.
|
||||||
|
|
||||||
|
If you need implementation notes, project architecture, or development setup, use the repository README. This guide stays focused on end-user operation and editorial decisions.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- bDS2 documentation should help with real editorial work, not only isolated clicks.
|
||||||
|
- Each chapter explains purpose first, then usage.
|
||||||
|
- Safe content handling and recoverability matter throughout the application.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How bDS2 works
|
||||||
|
|
||||||
|
bDS2 is a local-first writing and publishing workspace. You can draft, revise, structure, preview, and publish content on your machine without depending on constant internet access. Optional remote Git synchronization and AI-assisted workflows extend that model, but they do not replace it.
|
||||||
|
|
||||||
|
Three states matter in day-to-day work. A draft is your in-progress state. Publishing marks a local content state as published inside your project. A Git commit creates a recoverable snapshot that can be reviewed, synchronized, and restored. These actions are related, but they are not the same operation.
|
||||||
|
|
||||||
|
The recommended sequence remains simple: edit in draft, publish when the content is ready, then commit immediately. That is the safest pattern for protecting work and keeping project history understandable.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- bDS2 is designed for local reliability first.
|
||||||
|
- Publish and commit are different actions and both matter.
|
||||||
|
- The safe default lifecycle is: Draft -> Publish -> Commit.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
Before you begin editorial work, confirm that the project context is correct. Open bDS2 and select the right project. If this is a new project, create it and define its identity early, including project name and description.
|
||||||
|
|
||||||
|
Next, open Settings and verify the project data path and Public Base URL. The data path should match your backup strategy. The Public Base URL should be set early because sitemap and feed generation depend on it.
|
||||||
|
|
||||||
|
Finally, define language and author defaults. These defaults reduce repetitive edits and keep output consistent when multiple contributors work in the same project.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Set project identity, data location, and Public Base URL at the beginning.
|
||||||
|
- Configure language and author defaults before regular editing starts.
|
||||||
|
- Early setup decisions reduce later cleanup.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Understanding the interface
|
||||||
|
|
||||||
|
The bDS2 interface is organized around workflows rather than isolated forms. The Activity Bar on the left moves between major areas such as Posts, Pages, Media, Tags, Import, Source Control, and Settings. The Sidebar changes with the active area and helps with filtering, selection, and navigation. The Editor area is where most work happens and supports tabbed editing for content, configuration, and analysis views.
|
||||||
|
|
||||||
|
The bottom panel and status area matter during longer operations such as imports, rebuild actions, metadata scans, and media work. Toasts provide quick feedback. The Output panel provides deeper detail when something needs attention.
|
||||||
|
|
||||||
|
Tab behavior is optimized for quick scanning and focused editing. Single click often opens a transient tab. Double click or explicit actions pin a tab for longer work.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Use the Activity Bar for section-level context switching.
|
||||||
|
- Use the Sidebar for finding and narrowing content.
|
||||||
|
- Pin tabs when you move from inspection to editing.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working with posts
|
||||||
|
|
||||||
|
The Posts section is for chronological content such as articles, notes, and recurring updates. In most editorial teams, Posts are the primary outward-facing stream.
|
||||||
|
|
||||||
|
A post combines title, body content, category, tags, excerpt, and status. Titles establish topic. Body content carries the narrative. Categories provide broad structure. Tags support finer discovery. Status should be used intentionally so collaborative workflows stay clear.
|
||||||
|
|
||||||
|
A reliable post workflow is: draft to completion, review structure and metadata, preview the result, publish when editorially ready, then commit immediately.
|
||||||
|
|
||||||
|
When you want help refining post metadata, use Quick Actions in the post editor and review AI suggestions for title, summary, and slug. Treat this as editorial assistance, not an automatic rewrite.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Use Posts for date-oriented and regularly updated content.
|
||||||
|
- Categories and tags serve different purposes: broad grouping versus precise discovery.
|
||||||
|
- Publish only when editorially ready, then commit right away.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working with pages
|
||||||
|
|
||||||
|
Pages are for durable, non-chronological content such as About, Contact, legal notices, and other structural information. Use Pages when content should stay stable in navigation and should not be interpreted as part of a time-based feed.
|
||||||
|
|
||||||
|
Because pages are revisited over longer periods, naming consistency matters. Keep titles and slugs predictable, avoid unnecessary structural churn, and follow your project navigation conventions.
|
||||||
|
|
||||||
|
The working pattern is similar to posts: draft, review, preview, publish, commit. The difference is editorial intent: pages prioritize clarity and long-term maintainability over release cadence.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Use Pages for stable structural content.
|
||||||
|
- Keep titles and slugs consistent for maintainability.
|
||||||
|
- Apply the same safe lifecycle: Draft -> Publish -> Commit.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working with media
|
||||||
|
|
||||||
|
The Media section is where you import, describe, and maintain assets used by posts and pages. It is not only a file list; it is also where accessibility and descriptive quality are enforced through metadata.
|
||||||
|
|
||||||
|
When importing media, add metadata while context is still fresh. Alt text should describe meaning for accessibility. Captions should support reader understanding. Media tags should help later retrieval and reuse.
|
||||||
|
|
||||||
|
You can also drag image files into the post editor or paste screenshots from the clipboard. bDS2 imports the image into the media library, links it to the current post, and inserts the Markdown image at the cursor position.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Media management includes metadata quality, not only file import.
|
||||||
|
- Add alt text and captions during import, not as a postponed task.
|
||||||
|
- Commit content and related media in the same change when possible.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working with translations
|
||||||
|
|
||||||
|
bDS2 supports translating both posts and media metadata into multiple languages. Translations are stored separately from canonical content so localized variants do not drift into unrelated records.
|
||||||
|
|
||||||
|
### Post translations
|
||||||
|
|
||||||
|
Each post has a canonical language and can have translations for additional languages. Translations keep their own title, excerpt, and content, while canonical metadata such as category, tags, slug, and publish state stays centralized.
|
||||||
|
|
||||||
|
The post editor shows the current language, existing translations, and missing languages. Posts marked Do Not Translate are excluded from automatic translation and from alternate language trees during site generation.
|
||||||
|
|
||||||
|
Published translation body content follows the same filesystem rule as published posts: the body lives in the file, not in the database.
|
||||||
|
|
||||||
|
### Media translations
|
||||||
|
|
||||||
|
Media items can have translated title, alt text, and caption values per language. The binary asset stays shared; only descriptive text varies by language.
|
||||||
|
|
||||||
|
### Automatic translation cascade
|
||||||
|
|
||||||
|
When blog languages are configured, bDS2 can fill missing translations for posts and linked media. Automatic translation respects airplane mode and the configured AI runtime. If an automatic action cannot run in the current AI mode, bDS2 reports that through the UI instead of silently inventing a result.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Post translations store title, excerpt, and content separately from the canonical post.
|
||||||
|
- Media translations store translated descriptive text while the asset stays shared.
|
||||||
|
- Automatic translation keeps posts and linked media aligned across configured languages.
|
||||||
|
- Do Not Translate excludes content from multi-language workflows.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using macros
|
||||||
|
|
||||||
|
Macros let you insert dynamic content blocks directly inside Markdown by using `[[macro_name ...]]` syntax. bDS2 expands these macros during preview and generated output using local assets only.
|
||||||
|
|
||||||
|
Built-in macros include YouTube, Vimeo, gallery, photo archive, and tag cloud helpers. Use them when you want reusable rich blocks without dropping into raw HTML.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Macros are inserted directly in Markdown and expanded during preview and publishing.
|
||||||
|
- Use macro parameters to control behavior without leaving the editor.
|
||||||
|
- Built-in macros remain the first choice for common embedded content blocks.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using scripting
|
||||||
|
|
||||||
|
Scripts in bDS2 are Lua files stored in your project's `scripts/` directory. Published scripts are written as `.lua` files with frontmatter metadata, so they stay portable and Git-reviewable.
|
||||||
|
|
||||||
|
Each script has a Kind (`macro`, `transform`, or `utility`) and an Entrypoint. Utility and transform scripts typically default to `main`. Macro scripts default to `render`.
|
||||||
|
|
||||||
|
### Transform scripts
|
||||||
|
|
||||||
|
Transform scripts run during blogmark import to normalize or enrich incoming post data before the post is created. The entrypoint receives a post table and can optionally receive a context table.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
function main(post, context)
|
||||||
|
local title = (post.title or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
||||||
|
|
||||||
|
if title ~= "" and not title:match("^%[Clipped%]") then
|
||||||
|
post.title = "[Clipped] " .. title
|
||||||
|
end
|
||||||
|
|
||||||
|
post.categories = { "Inbox", "Research" }
|
||||||
|
return post
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
`context.source` identifies the import source. `context.url` contains the original bookmarked URL when that information exists.
|
||||||
|
|
||||||
|
### Macro scripts
|
||||||
|
|
||||||
|
Macro scripts let you create custom `[[macro_name ...]]` blocks that expand during preview and generation. The entrypoint receives a context table and the current post table.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
function render(context, post)
|
||||||
|
local params = context.params or {}
|
||||||
|
local title = (post and post.title) or "Unknown"
|
||||||
|
local label = params.label or ""
|
||||||
|
|
||||||
|
return {
|
||||||
|
html = "<p>" .. title .. ": " .. label .. "</p>"
|
||||||
|
}
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
Built-in macros take priority over custom Lua macros that reuse the same slug.
|
||||||
|
|
||||||
|
### API access
|
||||||
|
|
||||||
|
Lua scripts can call the application API through `bds`. The in-app API tab is rendered from the live Lua capability map, and [API.md](API.md) is generated from the same source.
|
||||||
|
|
||||||
|
```lua
|
||||||
|
local result = bds.posts.get("post-id")
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Scripts in bDS2 are Lua files, not Python files.
|
||||||
|
- Published scripts are stored as `.lua` files with frontmatter metadata.
|
||||||
|
- `main` is the usual entrypoint for utility and transform scripts; `render` is the usual entrypoint for macros.
|
||||||
|
- The scripting API is documented with Lua examples and kept in sync with the live runtime.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using the AI assistant
|
||||||
|
|
||||||
|
The AI assistant is integrated into bDS2 to help with editorial tasks such as search, analysis, metadata suggestions, translation, and structured content inspection.
|
||||||
|
|
||||||
|
The assistant works on your project data. Depending on configuration, requests can run against the configured online endpoint or the airplane-mode endpoint. Automatic AI actions remain gated by airplane mode rules in the app, and bDS2 surfaces status through toasts and the Output area instead of silently bypassing that policy.
|
||||||
|
|
||||||
|
The assistant can present results as text, tables, cards, charts, metrics, lists, forms, and tabbed views. Ask plainly for the result you need, or request a specific presentation when that helps your workflow.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- The assistant works with your project content and metadata.
|
||||||
|
- AI configuration can be online or airplane-mode based, depending on your setup.
|
||||||
|
- Automatic AI actions respect airplane mode and report availability through the UI.
|
||||||
|
- Ask for a table, chart, list, or form when a specific shape is useful.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Organizing with tags
|
||||||
|
|
||||||
|
Tags are your precision taxonomy tool. Over time, even well-managed projects accumulate near-duplicate tags, naming inconsistencies, and labels that no longer help readers or editors. Use the Tags area to keep taxonomy useful.
|
||||||
|
|
||||||
|
After significant taxonomy cleanup, create a focused commit that captures the change clearly.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Tags improve discovery only if naming stays consistent.
|
||||||
|
- Merge and rename operations should be deliberate and reviewed.
|
||||||
|
- Commit taxonomy changes in focused snapshots.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using blogmarks
|
||||||
|
|
||||||
|
Blogmarks provide a quick way to save links from the browser directly into bDS2 as new posts. Generate the bookmarklet from Settings, add it to your browser bar, and click it when you want to capture a page into the current project.
|
||||||
|
|
||||||
|
Transform scripts can normalize incoming blogmark posts before creation. Use them for title cleanup, default tags, or source-specific formatting.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Blogmarks turn the browser into a one-click content capture tool.
|
||||||
|
- Generate the bookmarklet from Settings and add it to your browser bar.
|
||||||
|
- Use transform scripts to enrich incoming posts automatically.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Importing from WordPress (WXR)
|
||||||
|
|
||||||
|
The Import section supports structured migration from WordPress exports. Treat import as a staged workflow: analyze first, adjust mappings, then execute. For larger sites, iterative passes are usually safer than a single rigid import.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Treat WXR import as analyze, adjust, execute.
|
||||||
|
- Iterative passes are safer than one large import.
|
||||||
|
- Validate representative output before committing migrated content.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using Git (Source Control)
|
||||||
|
|
||||||
|
Source Control in bDS2 is the foundation for reliable recovery and collaboration. Publishing marks local editorial state, but Git commits provide durable history.
|
||||||
|
|
||||||
|
In a normal cycle, synchronize first, complete editorial changes, publish when ready, commit with a specific message, then push when you want to share the result.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Git provides recoverable history; publishing alone does not.
|
||||||
|
- A stable rhythm is: sync, edit, publish, commit, push.
|
||||||
|
- Specific commit messages improve teamwork and recovery.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuring settings
|
||||||
|
|
||||||
|
Settings define how the project behaves. Project settings control identity, paths, public URL context, and render languages. Editor settings shape day-to-day working defaults. AI settings are optional and should enhance, not define, your editorial workflow.
|
||||||
|
|
||||||
|
Maintenance actions such as rebuilds and diff scans are repair tools for specific situations, not part of routine editing.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Settings affect long-term consistency across the project.
|
||||||
|
- Optional integrations should not replace the core workflow.
|
||||||
|
- Rebuild actions are corrective tools, not daily habits.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checking and repairing metadata
|
||||||
|
|
||||||
|
Over time, metadata stored in the database and metadata stored on disk can drift apart, especially after external edits, merges, or file operations. The Metadata Diff tool detects these inconsistencies and lets you repair them without rebuilding everything.
|
||||||
|
|
||||||
|
The scan covers posts, media, scripts, and templates. Results are grouped by entity type, and field pills let you focus on one kind of difference at a time.
|
||||||
|
|
||||||
|
Use DB to File when the database is correct. Use File to DB when the filesystem is correct.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Metadata Diff compares database records against files on disk.
|
||||||
|
- Field pills help you bulk-repair one difference type at a time.
|
||||||
|
- Use it after external changes, not as part of routine editing.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Managing templates
|
||||||
|
|
||||||
|
Templates control the Liquid layout used when bDS2 generates HTML pages. Template kinds determine where they are used: `post`, `list`, `not-found`, and `partial`.
|
||||||
|
|
||||||
|
Templates are stored as files with frontmatter metadata in the project data directory, so they are portable and Git-reviewable.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Templates define the generated HTML layout.
|
||||||
|
- Four template kinds cover page, list, not-found, and reusable partial rendering.
|
||||||
|
- Templates are filesystem-backed and Git-friendly.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generating and publishing
|
||||||
|
|
||||||
|
Publishing in bDS2 is a staged process: publish content locally, generate or validate-and-apply site changes, commit the result, then deploy when ready.
|
||||||
|
|
||||||
|
Full generation builds the entire static site. Site validation detects missing, extra, and updated routes so bDS2 can re-render only what changed. This is the practical incremental workflow for most daily editorial changes.
|
||||||
|
|
||||||
|
When blog languages are configured, generation produces language-aware route trees, per-language feeds, and alternate language metadata.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Full generation produces the complete site.
|
||||||
|
- Validate and Apply is the efficient daily workflow for incremental publishing.
|
||||||
|
- Public Base URL must be set before generation.
|
||||||
|
- Commit generated output before deploying for recoverability.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typical editorial workflows
|
||||||
|
|
||||||
|
Short link posts benefit from a lightweight workflow: create, add concise context, classify, preview once, publish, commit. Long-form articles benefit from a fuller cycle: draft thoroughly, add media, review metadata, preview carefully, publish, commit content and media together.
|
||||||
|
|
||||||
|
Across both patterns, the safety baseline stays the same: Draft -> Publish -> Commit.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Use a lightweight workflow for short notes and links.
|
||||||
|
- Use a fuller workflow for long-form content with media.
|
||||||
|
- Keep the same safety baseline in both cases.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Working fully offline
|
||||||
|
|
||||||
|
bDS2 is designed so core editorial work can continue without network access. You can create and revise content, manage metadata, preview locally, and publish within local project state while offline.
|
||||||
|
|
||||||
|
When AI is involved, airplane mode determines which automatic actions are allowed and which endpoint class is used. Keep local commits frequent even when you are not pushing to a remote.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Core editing and publishing workflows work offline.
|
||||||
|
- Local commits still matter when no remote is available.
|
||||||
|
- Reconnect and synchronize in a controlled order.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 lists or references become inconsistent after manual file changes, start with Metadata Diff. If broader inconsistency remains, use rebuild tools to realign database and filesystem state.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Most missing remote content issues are commit or push gaps.
|
||||||
|
- Metadata Diff is the first repair tool after external file changes.
|
||||||
|
- Frequent meaningful commits are the strongest safety net.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Team conventions
|
||||||
|
|
||||||
|
Shared conventions reduce ambiguity and merge friction. Teams should agree on category definitions, tag naming rules, publish-readiness criteria, and commit message patterns.
|
||||||
|
|
||||||
|
A practical minimum rule is simple: any content considered published should be committed promptly.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- Explicit conventions improve speed and reduce avoidable conflict.
|
||||||
|
- Start with a small rule set and enforce it consistently.
|
||||||
|
- Minimum standard: published content should be committed promptly.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
186
PLAN.md
186
PLAN.md
@@ -1,186 +0,0 @@
|
|||||||
# bDS2 Plan
|
|
||||||
|
|
||||||
This document tracks the current implementation state of bDS2 against the Allium specs and the old bDS application.
|
|
||||||
|
|
||||||
## Open Work Summary
|
|
||||||
|
|
||||||
- Completed plan steps: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12.
|
|
||||||
- Open plan steps: none.
|
|
||||||
- Next actionable step: rerun parity audits when scope expands; current implemented surfaces are at parity.
|
|
||||||
- Scheduled after the current parity pass: none.
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
|
|
||||||
The rewrite already implements most of the backend and compatibility-critical surface described by the specs.
|
|
||||||
|
|
||||||
### Implemented Now
|
|
||||||
|
|
||||||
- Foundation and persistence: OTP app startup, Ecto repo, migrations, and persisted tables for projects, posts, translations, media, tags, templates, scripts, settings, chat, AI catalog data, embeddings, imports, publishing jobs, MCP proposals, and CLI notifications.
|
|
||||||
- Compatibility-critical file contracts: post frontmatter, media sidecars, thumbnail layout, template and script frontmatter, menu OPML, metadata diff, and rebuild-from-filesystem flows.
|
|
||||||
- Compatibility parity locks: executable parity coverage now pins the old-bDS behavior for serializer/file contracts, metadata-diff ordering, canonical generation routes, feed output, localized archive rendering, preview draft language selection, taxonomy archive links, and media URL rewriting.
|
|
||||||
- Core editorial domains: projects, posts, post translations, media, media translations, tags, templates, scripts, menu data, and project metadata.
|
|
||||||
- Output and infrastructure: search, Liquid rendering, site generation, preview server, publishing, background tasks, i18n, git support, MCP server, AI operations, embeddings, and CLI sync.
|
|
||||||
- Desktop shell foundation: native menu definitions, LiveView shell endpoint, activity bar, sidebar views, tab/workbench state, task panel, assistant sidebar, status bar, project switcher, shell command routing, template-backed shell rendering, sidebar search/filter/load-more controls, dashboard recent-post opening, UI language switching, project dropdown actions, output/post-link/git lower-panel content, browser-native menu bridging, desktop-side CLI mutation watching/broadcasting, and the shared modal/overlay layer for AI suggestions, picker flows, confirmations, and gallery/lightbox interactions.
|
|
||||||
- Dedicated editor surfaces now exist for posts, media, settings, style, tags, scripts, templates, chat, and the misc maintenance views, with focused shell-live tests covering real rendering and core save/preview/publish flows.
|
|
||||||
|
|
||||||
### Implemented But Not Yet At Parity
|
|
||||||
|
|
||||||
- The currently implemented batch-3 and batch-4 surfaces now score green in this plan snapshot; follow-on work should come only from later missing-feature tracks or newly proven drift.
|
|
||||||
- Shared workbench/session state exists, but any future UI-data-flow work should now be treated as a concrete parity gap only when it can be tied back to a proven old-app behavior.
|
|
||||||
|
|
||||||
### Missing Or Materially Incomplete
|
|
||||||
|
|
||||||
- Import remains definition-only: stored import definitions exist, but the old WXR analysis/execution pipeline and its dedicated editor surface are not present.
|
|
||||||
- The remaining parity write-up gap is now keeping the chat row and the final minimal backlog in sync with the current implementation.
|
|
||||||
|
|
||||||
## Spec Coverage Snapshot
|
|
||||||
|
|
||||||
Ordered from base contracts upward:
|
|
||||||
|
|
||||||
| Area | Specs | Status | Notes |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Persistence and file contracts | `schema`, `frontmatter`, `project`, `post`, `translation`, `media`, `tag`, `template`, `script`, `menu`, `metadata` | Implemented | Core schemas, file formats, publish flows, sidecars, rebuild, and metadata diff are present and tested. |
|
|
||||||
| Rendering and output pipelines | `template_context`, `search`, `generation`, `preview`, `publishing`, `task`, `i18n` | Implemented | Rendering, generation, preview, publishing, task tracking, and localization are in place. |
|
|
||||||
| Integrations | `git`, `mcp`, `ai`, `embedding`, `cli_sync`, `metadata_diff` | Implemented | Service layer and test coverage exist for these domains. |
|
|
||||||
| Desktop shell primitives | `layout`, `tabs`, `sidebar_views` | Implemented | Route state, registry-backed sidebar/editor coverage, panel fallback rules, menu/native-command wiring, and a LiveView-owned shell frame are in place. |
|
|
||||||
| Cross-cutting flows | `ui_data_flow`, `engine_side_effects`, `action_patterns`, `media_processing` | Partial | Most backend behavior exists, but UI coordination, explicit engine event modeling, and some parity details are still incomplete. |
|
|
||||||
| Editor UX layer | `editor_post`, `editor_media`, `editor_settings`, `editor_tags`, `editor_chat`, `editor_script`, `editor_template`, `editor_misc`, `modals`, `menu` | Partial | The existing editor routes now render dedicated surfaces and have focused tests; import parity and any later not-yet-implemented old-app behavior remain outstanding. |
|
|
||||||
|
|
||||||
## Batch 3 And 4 Focus
|
|
||||||
|
|
||||||
Only these two audit tracks matter for the current pass. The follow-on missing-feature tracks now sit explicitly after this pass as steps 11 and 12.
|
|
||||||
|
|
||||||
1. Lock compatibility contracts. Completed 2026-04-25.
|
|
||||||
Schema, frontmatter, sidecars, template context, generation output, preview behavior, metadata diff, and rebuild behavior are now pinned against the Allium specs and the old bDS application with executable parity tests.
|
|
||||||
|
|
||||||
2. Close engine-level behavior gaps. Completed 2026-04-25.
|
|
||||||
Save/publish/delete side-effects, published-post `templateSlug` frontmatter rewrites, manual-translation source-post reopening, post-to-media sidecar cleanup, auto-translation task cascades, linked-media translation cascades, link graph maintenance, thumbnail regeneration rules, and rebuild notifications are now implemented and covered at the backend layer independent of UI.
|
|
||||||
|
|
||||||
3. Finish the desktop shell primitives. Completed 2026-04-26.
|
|
||||||
Route state, registry-backed shell command coverage, panel fallback integration, menu/native-command wiring, template-backed LiveView rendering, sidebar search/filter/load-more controls, dashboard recent-post opening, project dropdown actions, UI language switching, real output/post-link/git lower-panel content, and native-menu event bridging now cover the old shell frame behavior while preserving the legacy layout and styling.
|
|
||||||
|
|
||||||
4. Implement the shared modal and confirmation layer. Completed 2026-04-26.
|
|
||||||
The LiveView shell now owns the shared modal/overlay system required by the specs: AI suggestions, delete confirmations, merge confirmation, picker overlays, and gallery/lightbox flows, with overlay state isolated in a pure module and covered by focused tests.
|
|
||||||
|
|
||||||
5. Audit batch 3: shell UI plus all dedicated editors already present. Completed 2026-04-28.
|
|
||||||
Compared the old Electron/React shell and each dedicated editor route against the current LiveView shell, scored parity green/yellow/red, and reduced the proven batch-3 drift list to the remaining chat-surface gaps only.
|
|
||||||
|
|
||||||
6. Audit batch 4: lower-risk backend and integration services behind those surfaces. Completed 2026-04-29.
|
|
||||||
Tied each old engine contract to its bDS2 replacement, verified the executable proof set, probed the relevant owning UI/editor entry points, and recorded the batch-4 parity scores in the snapshot below.
|
|
||||||
|
|
||||||
7. Restore remaining chat editor parity on the implemented route. Completed 2026-04-29.
|
|
||||||
Re-ran the focused chat parity pass against the old `ChatPanel`/`ChatTranscript` surface, closed the remaining in-flight input-state drift on the implemented route, and refreshed the parity row to green without widening into backend streaming work.
|
|
||||||
|
|
||||||
8. Restore misc maintenance editor parity on the implemented routes. Completed 2026-04-28.
|
|
||||||
Translation validation now uses the old dedicated validate-and-fix card surface again, and git diff now renders through the structured Monaco diff viewer while keeping the current metadata-diff, site-validation, and duplicate flows intact.
|
|
||||||
|
|
||||||
9. Fix any remaining batch 3 and batch 4 parity gaps that the audit proves. Completed 2026-04-29.
|
|
||||||
No further proven batch-3 or batch-4 gaps remained after the focused chat parity pass, so the minimal backlog now begins with the later menu, CLI-mutation-watching, and import tracks.
|
|
||||||
|
|
||||||
10. Restore menu editor parity on the implemented data model. Completed 2026-04-29.
|
|
||||||
The shell now renders a dedicated menu editor with old-app-inspired structure, toolbar flow, inline page/category insertion, drag/drop plus move/indent controls, localized copy, and focused shell-live coverage on the existing menu persistence model.
|
|
||||||
|
|
||||||
11. Restore desktop-side CLI mutation watching parity. Completed 2026-04-29.
|
|
||||||
A supervised CLI-sync watcher now polls the persisted notification store on the old-app timing budget, broadcasts `entity:changed` events through PubSub, and the LiveView shell refreshes sidebar/editor state while closing stale post/media tabs on external deletes.
|
|
||||||
|
|
||||||
12. Restore import execution and editor parity. Completed 2026-04-30.
|
|
||||||
The stored import-definition flow now runs through the old analysis/execution pipeline again with progress callbacks, dedicated import-editor detail sections, inline taxonomy mapping pills plus AI-backed mapping, and focused import proof plus clean compile, dialyzer, and full-suite validation.
|
|
||||||
|
|
||||||
## Batch 3 Audit Matrix
|
|
||||||
|
|
||||||
| Slice | Old bDS anchor | bDS2 anchor | Audit questions |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Shell chrome | `src/renderer/components/ActivityBar`, `Sidebar`, `TabBar`, `Panel`, `WindowTitleBar`, `AssistantSidebar`, `App.tsx`, `App.css` | `lib/bds/desktop/shell_live.ex`, `lib/bds/desktop/shell_live/index.html.heex`, `lib/bds/ui/workbench.ex`, `lib/bds/desktop/shell_live/sidebar_components.ex`, `priv/ui/app.css` | Same activity ordering, same toggle semantics, same transient/pinned tab behavior, same panel fallback rules, same project switcher behavior, same language switching, same titlebar/menu behavior, same shell sizing defaults. |
|
|
||||||
| Post editor | `src/renderer/components/Editor/PostEditor.tsx` | `lib/bds/desktop/shell_live/post_editor.ex`, `lib/bds/desktop/shell_live/post_editor_html` | Same draft/published body loading, save/publish/discard/delete rules, preview switching, quick actions, translation flags, media panel, dirty-state behavior, localized labels. |
|
|
||||||
| Media editor | `src/renderer/components/Editor/MediaEditor.tsx` | `lib/bds/desktop/shell_live/media_editor.ex`, `lib/bds/desktop/shell_live/media_editor_html` | Same explicit-save flow, same linked-post rendering, same translation modal/edit path, same quick actions, same metadata fields and localization. |
|
|
||||||
| Settings editor | `src/renderer/components/SettingsView/SettingsView.tsx` | `lib/bds/desktop/shell_live/settings_editor.ex`, `lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex` | Same section model, same search/filter behavior, same section targeting, same metadata fields, same AI/settings grouping, same publishing/data/MCP sections. |
|
|
||||||
| Style editor | `src/renderer/components/StyleView/StyleView.tsx` | `lib/bds/desktop/shell_live/settings_editor.ex`, `lib/bds/desktop/shell_live/settings_editor_html/style_editor.html.heex` | Same theme list, same preview URL behavior, same apply flow, same selected/applied theme semantics. |
|
|
||||||
| Tags editor | `src/renderer/components/TagsView/TagsView.tsx` | `lib/bds/desktop/shell_live/tags_editor.ex`, `lib/bds/desktop/shell_live/tags_editor_html` | Same tag CRUD, same color handling, same merge/delete flows, same post-template selection behavior. |
|
|
||||||
| Script editor | `src/renderer/components/ScriptsView/ScriptsView.tsx` | `lib/bds/desktop/shell_live/code_entity_editor.ex`, `lib/bds/desktop/shell_live/code_entity_editor_html/script_editor.html.heex` | Same script metadata fields, same Monaco setup, same check/run/save/delete flow, same entrypoint discovery behavior. |
|
|
||||||
| Template editor | `src/renderer/components/TemplatesView/TemplatesView.tsx` | `lib/bds/desktop/shell_live/code_entity_editor.ex`, `lib/bds/desktop/shell_live/code_entity_editor_html/template_editor.html.heex` | Same template metadata fields, same Monaco setup, same validate/save/delete flow, same template kind handling. |
|
|
||||||
| Chat editor | `src/renderer/components/ChatPanel/ChatPanel.tsx` | `lib/bds/desktop/shell_live/chat_editor.ex`, `lib/bds/desktop/shell_live/chat_editor_html` | Same conversation loading, same input/send flow, same transcript rendering, same task/result presentation, same airplane-mode gating. |
|
|
||||||
| Misc maintenance editors | `src/renderer/components/SiteValidationView`, `TranslationValidationView`, `MetadataDiffPanel`, `DuplicatesView`, `GitDiffView` | `lib/bds/desktop/shell_live/misc_editor.ex`, `lib/bds/desktop/shell_live/misc_editor_html` | Same result summaries, same action buttons, same diff/repair/apply flows, same tab/filter behavior, same empty/error states. |
|
|
||||||
|
|
||||||
## Batch 3 Procedure
|
|
||||||
|
|
||||||
1. Freeze a parity row per slice: old file, new file, proof tests, manual scenario, current score, gap notes.
|
|
||||||
2. Run shell-level proof first: `mix test test/bds/desktop/shell_live_test.exs test/bds/ui/sidebar_test.exs`.
|
|
||||||
3. Audit shell chrome before individual editors, so layout/tabs/sidebar drift does not get misattributed to an editor.
|
|
||||||
4. Audit editors in this order: post, media, settings, style, tags, scripts, templates, chat, misc.
|
|
||||||
5. For each editor, capture only behavior deltas: missing flow, different state transition, styling drift, localization drift, or command wiring drift.
|
|
||||||
6. Mark each slice `green`, `yellow`, or `red`; do not down-score a row for a separate feature that is not implemented yet.
|
|
||||||
|
|
||||||
## Batch 3 Audit Snapshot
|
|
||||||
|
|
||||||
Current batch 3 parity scores for existing implemented UI/editor code only. Missing or not-yet-implemented surfaces are not counted as parity failures in this table.
|
|
||||||
|
|
||||||
| Slice | Score | Old bDS anchor | bDS2 anchor | Proof used | Existing-code parity result |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| Shell chrome | green | `src/renderer/components/ActivityBar`, `Sidebar`, `TabBar`, `Panel`, `WindowTitleBar`, `AssistantSidebar`, `App.tsx`, `App.css` | `lib/bds/desktop/shell_live.ex`, `lib/bds/desktop/shell_live/index.html.heex`, `lib/bds/ui/workbench.ex`, `lib/bds/desktop/shell_live/sidebar_components.ex`, `priv/ui/app.css` | `mix test test/bds/desktop/shell_live_test.exs test/bds/ui/sidebar_test.exs`; owner files and shell CSS compared | No material implemented-code parity drift found in the shell frame, activity/sidebar/tab/panel behavior, or current chrome styling. |
|
|
||||||
| Post editor | green | `src/renderer/components/Editor/PostEditor.tsx` | `lib/bds/desktop/shell_live/post_editor.ex`, `lib/bds/desktop/shell_live/post_editor_html/*` | `mix test test/bds/desktop/shell_live_test.exs`; post editor implementation compared | No material implemented-code parity drift found in the current save, publish, discard, preview, or published-body-loading flows. |
|
|
||||||
| Media editor | green | `src/renderer/components/Editor/MediaEditor.tsx` | `lib/bds/desktop/shell_live/media_editor.ex`, `lib/bds/desktop/shell_live/media_editor_html/*` | `mix test test/bds/desktop/shell_live_test.exs`; media editor implementation compared | No material implemented-code parity drift found in the current explicit-save, translation edit, linked-post, and metadata-edit flows. |
|
|
||||||
| Settings editor | green | `src/renderer/components/SettingsView/SettingsView.tsx` | `lib/bds/desktop/shell_live/settings_editor.ex`, `lib/bds/desktop/shell_live/settings_editor_html/settings_editor.html.heex` | `mix test test/bds/desktop/shell_live_test.exs`; settings editor implementation compared | No material implemented-code parity drift found in the current section model, AI settings groups, project/editor/content/publishing/data/MCP sections, or implemented search/filter behavior. |
|
|
||||||
| Style editor | green | `src/renderer/components/StyleView/StyleView.tsx` | `lib/bds/desktop/shell_live/settings_editor.ex`, `lib/bds/desktop/shell_live/settings_editor_html/style_editor.html.heex` | `mix test test/bds/desktop/shell_live_test.exs`; style editor implementation compared | No material implemented-code parity drift found in the current theme picker, preview mode, preview URL, and apply flow. |
|
|
||||||
| Tags editor | green | `src/renderer/components/TagsView/TagsView.tsx` | `lib/bds/desktop/shell_live/tags_editor.ex`, `lib/bds/desktop/shell_live/tags_editor_html/*` | `mix test test/bds/desktop/shell_live_test.exs`; tags editor implementation compared | No material implemented-code parity drift found in the current create/edit/delete/merge flows, color handling, or post-template selection behavior. |
|
|
||||||
| Script editor | green | `src/renderer/components/ScriptsView/ScriptsView.tsx` | `lib/bds/desktop/shell_live/code_entity_editor.ex`, `lib/bds/desktop/shell_live/code_entity_editor_html/script_editor.html.heex` | `mix test test/bds/desktop/shell_live_test.exs`; script editor implementation compared | No material implemented-code parity drift found in the current Monaco editor, metadata form, syntax check, run, save, delete, and entrypoint selection flow. |
|
|
||||||
| Template editor | green | `src/renderer/components/TemplatesView/TemplatesView.tsx` | `lib/bds/desktop/shell_live/code_entity_editor.ex`, `lib/bds/desktop/shell_live/code_entity_editor_html/template_editor.html.heex` | `mix test test/bds/desktop/shell_live_test.exs`; template editor implementation compared | No material implemented-code parity drift found in the current Monaco editor, metadata form, validate, save, and delete flow. |
|
|
||||||
| Chat editor | green | `src/renderer/components/ChatPanel/ChatPanel.tsx`, `src/renderer/components/ChatSurface/ChatTranscript.tsx` | `lib/bds/desktop/shell_live/chat_editor.ex`, `lib/bds/desktop/shell_live/chat_editor_html/chat_editor.html.heex`, `priv/ui/live.js` | `mix test test/bds/desktop/shell_live_test.exs`; chat editor implementation and hook flow compared | The current bDS2 chat route now matches the implemented old-panel surface for provider-grouped model selection, welcome state, persisted transcript, airplane-mode gating, API-key-required state, assistant markdown rendering, tool markers, structured tool surfaces, assistant navigation actions, Enter/Shift+Enter handling, auto-grow/auto-scroll, and in-flight stop/input-lock behavior. No further proven implemented-route drift remains in batch 3. |
|
|
||||||
| Misc maintenance editors | green | `src/renderer/components/SiteValidationView`, `TranslationValidationView`, `MetadataDiffPanel`, `DuplicatesView`, `GitDiffView` | `lib/bds/desktop/shell_live/misc_editor.ex`, `lib/bds/desktop/shell_live/misc_editor_html/misc_editor.html.heex` | `mix test test/bds/desktop/shell_live_test.exs`; misc editor implementation compared | No material implemented-code parity drift found in the current metadata diff, site validation, duplicate dismissal, translation validation, or working-tree git diff flows. Translation validation again uses dedicated issue cards with revalidate/fix actions, and git diff now uses the structured Monaco diff surface. |
|
|
||||||
|
|
||||||
## Batch 4 Audit Matrix
|
|
||||||
|
|
||||||
| Service slice | Old bDS anchor | bDS2 anchor | Audit questions |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Projects and metadata | `src/main/engine/ProjectEngine.ts`, `MetaEngine.ts` | `lib/bds/projects.ex`, `lib/bds/metadata.ex` | Same project activation/switching semantics, same filesystem metadata sync, same category/theme/settings persistence, same defaults. |
|
|
||||||
| Rendering and preview | `src/main/engine/PageRenderer.ts`, `PreviewServer.ts`, `TemplateEngine.ts` | `lib/bds/rendering.ex`, `lib/bds/preview.ex`, `lib/bds/templates.ex` | Same template selection, same preview URL/content rules, same theme override behavior, same render-language behavior. |
|
|
||||||
| Generation and validation entry points | `src/main/engine/BlogGenerationEngine.ts`, `SiteValidationDiffService.ts` | `lib/bds/generation.ex`, `lib/bds/maintenance.ex` | Same command-surface semantics for generate/validate/apply where batch 3 editors trigger them, same result payload shapes, same update/apply behavior. |
|
|
||||||
| Publishing | `src/main/engine/PublishEngine.ts`, `PublishApiAdapter.ts` | `lib/bds/publishing.ex`, `lib/bds/desktop/shell_commands.ex` | Same queued-job behavior, same credential handling, same progress/result semantics, same UI entry points. |
|
|
||||||
| Tasks | `src/main/engine/TaskManager.ts` | `lib/bds/tasks.ex` | Same group/task lifecycle, same progress semantics, same status-bar/panel integration expectations. |
|
|
||||||
| Git | `src/main/engine/GitEngine.ts`, `GitApiAdapter.ts` | `lib/bds/git.ex`, `lib/bds/desktop/shell_live/misc_editor.ex` | Same repo state, same diff output expectations, same remote-state badge behavior, same editor integration. |
|
|
||||||
| Search | `src/main/engine/SearchIndexEngine.ts` | `lib/bds/search.ex` | Same reindex semantics, same content scope, same task/progress behavior, same downstream editor/search expectations. |
|
|
||||||
| Embeddings | `src/main/engine/EmbeddingEngine.ts` | `lib/bds/embeddings.ex` | Same rebuild/find-dismiss semantics, same duplicate pairing rules, same metadata-diff integration. |
|
|
||||||
| AI and model catalog | `src/main/engine/ChatEngine.ts`, `ModelCatalogEngine.ts`, `ai/*` | `lib/bds/ai.ex`, `lib/bds/ai/*` | Same endpoint/model resolution, same conversation/message behavior, same airplane-mode gating, same tool-call/UI expectations. |
|
|
||||||
| MCP | `src/main/engine/MCPServer.ts`, `mcp-view-builder.ts`, `mcp-views.ts` | `lib/bds/mcp.ex`, `lib/bds/mcp/*` | Same tool/resource coverage, same proposal/store behavior, same script/template bridge expectations. |
|
|
||||||
| CLI sync notification persistence | `src/main/engine/NotificationWatcher.ts` | `lib/bds/cli_sync.ex` | For the implemented notification store/drain/prune logic, do the `db_notifications` semantics match the old app. |
|
|
||||||
|
|
||||||
## Batch 4 Procedure
|
|
||||||
|
|
||||||
1. Freeze a parity row per service slice: old engine, bDS2 module, proof tests, owning editor/shell entry point, current score, gap notes.
|
|
||||||
2. Run the current proof set in four groups.
|
|
||||||
3. Group A: `mix test test/bds/generation_test.exs test/bds/maintenance_test.exs test/bds/rendering_test.exs test/bds/preview_test.exs`.
|
|
||||||
4. Group B: `mix test test/bds/posts_test.exs test/bds/media_test.exs test/bds/tags_test.exs test/bds/templates_test.exs test/bds/scripts_test.exs test/bds/projects_test.exs`.
|
|
||||||
5. Group C: `mix test test/bds/publishing_test.exs test/bds/git_test.exs test/bds/search_test.exs test/bds/embeddings_test.exs test/bds/desktop/shell_commands_test.exs`.
|
|
||||||
6. Group D: `mix test test/bds/ai_test.exs test/bds/mcp_server_test.exs test/bds/mcp_test.exs`.
|
|
||||||
7. Supplemental direct proofs for slices not fully covered by the grouped runs: `mix test test/bds/metadata_test.exs`, `mix test test/bds/tasks_test.exs`, and `mix test test/bds/cli_sync_test.exs`.
|
|
||||||
8. After the proof set, probe each service through the matching shell/editor action when that entry point already exists and is part of the implemented surface being scored.
|
|
||||||
9. Mark each slice `green`, `yellow`, or `red`; do not down-score a row for a separate feature that is not implemented yet.
|
|
||||||
|
|
||||||
## Batch 4 Audit Snapshot
|
|
||||||
|
|
||||||
Current batch 4 parity scores for implemented backend code only. This snapshot is the output of completed step 6. Missing or not-yet-implemented surfaces are not counted as parity failures in this table.
|
|
||||||
|
|
||||||
| Service slice | Score | Old bDS anchor | bDS2 anchor | Owning shell/editor entry | Proof used | Existing-code parity result |
|
|
||||||
| --- | --- | --- | --- | --- | --- | --- |
|
|
||||||
| Projects and metadata | green | `src/main/engine/ProjectEngine.ts`, `src/main/engine/MetaEngine.ts` | `lib/bds/projects.ex`, `lib/bds/metadata.ex` | `lib/bds/desktop/shell_live.ex`, `lib/bds/desktop/shell_live/settings_editor.ex` | `mix test test/bds/projects_test.exs`, `mix test test/bds/metadata_test.exs`; existing shell-live settings/project-switcher coverage reviewed | No material backend parity drift found in the current pass. |
|
|
||||||
| Rendering and preview | green | `src/main/engine/PageRenderer.ts`, `src/main/engine/PreviewServer.ts`, `src/main/engine/TemplateEngine.ts` | `lib/bds/rendering.ex`, `lib/bds/preview.ex`, `lib/bds/templates.ex` | `lib/bds/desktop/shell_live/post_editor.ex`, `lib/bds/desktop/shell_live/settings_editor.ex` | `mix test test/bds/rendering_test.exs test/bds/preview_test.exs`; existing shell-live preview/style coverage reviewed | No material backend parity drift found in the current pass. |
|
|
||||||
| Generation and validation entry points | green | `src/main/engine/BlogGenerationEngine.ts`, `src/main/engine/SiteValidationDiffService.ts` | `lib/bds/generation.ex`, `lib/bds/maintenance.ex` | `lib/bds/desktop/shell_commands.ex`, `lib/bds/desktop/shell_live/misc_editor.ex` | `mix test test/bds/generation_test.exs test/bds/maintenance_test.exs`, `mix test test/bds/desktop/shell_commands_test.exs` | No material backend parity drift found in the current pass. |
|
|
||||||
| Publishing | green | `src/main/engine/PublishEngine.ts`, `src/main/engine/PublishApiAdapter.ts` | `lib/bds/publishing.ex`, `lib/bds/desktop/shell_commands.ex` | Publishing section under `lib/bds/desktop/shell_live/settings_editor.ex` | `mix test test/bds/publishing_test.exs`; shell ownership reviewed | The implemented queued-job, credential, scp/rsync, sidecar-filter, and persistence behavior matches the old engine surface closely enough for green. |
|
|
||||||
| Tasks | green | `src/main/engine/TaskManager.ts` | `lib/bds/tasks.ex` | `lib/bds/desktop/shell_live.ex`, `lib/bds/ui/workbench.ex` | `mix test test/bds/tasks_test.exs`, `mix test test/bds/desktop/shell_commands_test.exs`; existing shell task-panel/status coverage reviewed | No material backend parity drift found in the current pass. |
|
|
||||||
| Git | green | `src/main/engine/GitEngine.ts`, `src/main/engine/GitApiAdapter.ts` | `lib/bds/git.ex`, `lib/bds/desktop/shell_live/misc_editor.ex` | Legacy git activity badge plus misc git-diff surface in `lib/bds/desktop/shell_live.ex` | `mix test test/bds/git_test.exs`; existing shell-live remote-badge coverage reviewed | The implemented repository, status, diff, history, remote-state, and fetch/pull/push semantics are close enough to old bDS for green on existing backend code. |
|
|
||||||
| Search | green | `src/main/engine/SearchIndexEngine.ts` | `lib/bds/search.ex` | `lib/bds/desktop/shell_commands.ex` via `reindex_text` | `mix test test/bds/search_test.exs`, `mix test test/bds/desktop/shell_commands_test.exs` | No material backend parity drift found in the current pass. |
|
|
||||||
| Embeddings | green | `src/main/engine/EmbeddingEngine.ts` | `lib/bds/embeddings.ex` | `lib/bds/desktop/shell_commands.ex`, `lib/bds/desktop/shell_live/misc_editor.ex` | `mix test test/bds/embeddings_test.exs`, `mix test test/bds/desktop/shell_commands_test.exs`; existing metadata-diff/duplicates shell coverage reviewed | No material backend parity drift found in the current pass. |
|
|
||||||
| AI and model catalog | green | `src/main/engine/ChatEngine.ts`, `src/main/engine/ModelCatalogEngine.ts`, `src/main/engine/ai/*` | `lib/bds/ai.ex`, `lib/bds/ai/*` | `lib/bds/desktop/shell_live/chat_editor.ex`, `lib/bds/desktop/shell_live/settings_editor.ex` | `mix test test/bds/ai_test.exs`; existing shell-live AI-settings/chat coverage reviewed | No material backend parity drift found for the implemented AI and catalog flows. |
|
|
||||||
| MCP | green | `src/main/engine/MCPServer.ts`, `src/main/engine/mcp-view-builder.ts`, `src/main/engine/mcp-views.ts` | `lib/bds/mcp.ex`, `lib/bds/mcp/server.ex`, `lib/bds/mcp/*` | MCP section under `lib/bds/desktop/shell_live/settings_editor.ex` and external MCP transport | `mix test test/bds/mcp_server_test.exs test/bds/mcp_test.exs` | No material backend parity drift found for the implemented tool, resource, proposal, and server surface. |
|
|
||||||
| CLI sync notification persistence | green | `src/main/engine/NotificationWatcher.ts` | `lib/bds/cli_sync.ex` | Notification rows consumed outside the shell; no desktop watcher is scored here | `mix test test/bds/cli_sync_test.exs` | The implemented `db_notifications` insert, drain, seen-marking, and prune semantics match the old row-handling behavior. Desktop watch/invalidation is outside this existing-code score because it is not implemented in bDS2. |
|
|
||||||
|
|
||||||
## Audit Outputs Required
|
|
||||||
|
|
||||||
1. A single parity matrix covering every batch 3 and batch 4 slice.
|
|
||||||
2. A green/yellow/red score per slice, never just narrative confidence.
|
|
||||||
3. The exact old file and exact bDS2 file that own each behavior.
|
|
||||||
4. The proof command or manual scenario used for the score.
|
|
||||||
5. A minimal change backlog containing only proven drifts in batch 3 and batch 4.
|
|
||||||
|
|
||||||
## Unresolved Questions
|
|
||||||
|
|
||||||
None.
|
|
||||||
166
README.md
166
README.md
@@ -1,52 +1,122 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
- 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 remains a vendor drop under [priv/ui/monaco/](/Users/gb/Projects/bDS2/priv/ui/monaco).
|
||||||
|
|
||||||
- [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 +126,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 +137,28 @@ 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
|
Notes for developers:
|
||||||
|
|
||||||
- Use `mix test` for validation during development.
|
- Specs in [specs/](/Users/gb/Projects/bDS2/specs) define the intended product behavior.
|
||||||
- The application behavior is defined by the Allium specs in [specs/](/Users/gb/Projects/bDS2/specs).
|
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md) is for end users, not implementation details.
|
||||||
- Use [PLAN.md](/Users/gb/Projects/bDS2/PLAN.md) for implementation status and the parity roadmap.
|
- [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.
|
||||||
|
|||||||
191
SPECAUDIT.md
Normal file
191
SPECAUDIT.md
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
# Spec Audit Process
|
||||||
|
|
||||||
|
This document describes the repeatable process for auditing the Allium specifications against the bDS2 codebase and test suite. Run it whenever specs or code change materially.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The audit produces three categories of findings:
|
||||||
|
|
||||||
|
1. **Spec-claims-not-in-code** — spec describes behavior the code does not implement
|
||||||
|
2. **Code-not-in-spec** — code implements behavior the spec does not describe
|
||||||
|
3. **Spec-claims-not-in-tests** — spec invariants/rules/behaviors lack test coverage
|
||||||
|
|
||||||
|
## Step 1: Map the Territory
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all spec files
|
||||||
|
ls specs/*.allium
|
||||||
|
|
||||||
|
# List all source modules
|
||||||
|
ls lib/bds/ lib/bds/**/
|
||||||
|
|
||||||
|
# List all test files
|
||||||
|
ls test/bds/ test/bds/**/
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the mapping between specs and code/test files. Use `specs/bds.allium` as the index — it lists every `use` directive with its domain label.
|
||||||
|
|
||||||
|
## Step 2: Extract Spec Claims
|
||||||
|
|
||||||
|
For each `.allium` file, extract:
|
||||||
|
|
||||||
|
| Claim Type | Pattern | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| **invariant** | `invariant Name:` or lines describing always-true properties | `UniqueSlugPerProject: slugs unique within project` |
|
||||||
|
| **rule** | `rule Name { requires: ... ensures: ... }` | `CreatePost: creates with slug, status=draft` |
|
||||||
|
| **guarantee** | `guarantee Name:` | `SandboxedExecution: no filesystem/process loading` |
|
||||||
|
| **config** | `config { key = value }` | `macro_timeout = 10.seconds` |
|
||||||
|
| **behavior** | Explicit claims in comments or entity descriptions | `"HomeAlwaysPresent: menu always has Home entry"` |
|
||||||
|
|
||||||
|
Record the spec file name, claim name, claim type, and line number for each.
|
||||||
|
|
||||||
|
## Step 3: Compare Spec Claims Against Code
|
||||||
|
|
||||||
|
For each claim, find the corresponding code and verify:
|
||||||
|
|
||||||
|
### 3a. Entity/field existence
|
||||||
|
- Does the Ecto schema have the fields the spec declares?
|
||||||
|
- Are relationships (has_many, belongs_to) present?
|
||||||
|
- Are enum/status values complete?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check schema fields
|
||||||
|
grep -n "field :" lib/bds/posts/post.ex
|
||||||
|
grep -n "has_many\|belongs_to" lib/bds/posts/post.ex
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3b. Rule implementation
|
||||||
|
- Does the code enforce the `requires` preconditions?
|
||||||
|
- Does the code produce the `ensures` postconditions?
|
||||||
|
- Are side-effects (FTS, embeddings, file writes) triggered?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check function implementation
|
||||||
|
grep -n "def create_post" lib/bds/posts.ex
|
||||||
|
grep -n "def publish_post" lib/bds/posts.ex
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3c. Invariant enforcement
|
||||||
|
- Are constraints enforced at the schema level (unique_index, check_constraint)?
|
||||||
|
- Are constraints enforced in changeset validations?
|
||||||
|
- Are constraints enforced in business logic?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check database constraints
|
||||||
|
grep -n "unique_index\|check_constraint" priv/repo/migrations/*.ex
|
||||||
|
grep -n "unique_constraint\|validate_" lib/bds/posts/post.ex
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3d. File format compliance
|
||||||
|
- Does the serialization format match the spec's frontmatter values?
|
||||||
|
- Are conditional fields omitted when falsy?
|
||||||
|
- Are required fields always present?
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check serialization
|
||||||
|
grep -n "serialize\|write_file\|Frontmatter" lib/bds/frontmatter.ex lib/bds/posts/file_sync.ex
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Compare Code Against Spec Claims
|
||||||
|
|
||||||
|
Search for code that implements behavior NOT described in any spec:
|
||||||
|
|
||||||
|
### 4a. Public API functions not in any spec rule
|
||||||
|
```bash
|
||||||
|
# List public functions in a module
|
||||||
|
grep -n "def " lib/bds/posts.ex | grep -v "defp"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4b. Schema fields not in any spec entity
|
||||||
|
```bash
|
||||||
|
# List all fields
|
||||||
|
grep -n "field :" lib/bds/posts/post.ex
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4c. Side effects not in engine_side_effects.allium
|
||||||
|
```bash
|
||||||
|
# Check what happens after CRUD operations
|
||||||
|
grep -n "sync_post\|sync_media\|Search\.\|Embeddings\.\|AutoTranslation" lib/bds/posts.ex lib/bds/media.ex
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4d. UI features not in any editor spec
|
||||||
|
```bash
|
||||||
|
# Check HEEx templates for UI elements
|
||||||
|
grep -n "phx-click\|data-phx-" lib/bds/desktop/post_editor_html/post_editor.html.heex
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Compare Spec Claims Against Tests
|
||||||
|
|
||||||
|
For each invariant, rule, and guarantee, search for a test that verifies it:
|
||||||
|
|
||||||
|
### 5a. Direct test search
|
||||||
|
```bash
|
||||||
|
# Search test names and bodies
|
||||||
|
grep -rn "test \"" test/bds/posts_test.exs | head -30
|
||||||
|
grep -rn "test \"" test/bds/media_test.exs | head -30
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5b. Invariant coverage check
|
||||||
|
For each invariant, determine:
|
||||||
|
- **YES**: Test explicitly verifies the invariant (creates violation, expects rejection)
|
||||||
|
- **PARTIAL**: Test verifies the happy path but not violation scenarios
|
||||||
|
- **NO**: No test exists
|
||||||
|
|
||||||
|
### 5c. Rule coverage check
|
||||||
|
For each rule, determine:
|
||||||
|
- **YES**: Test exercises `requires` precondition and `ensures` postcondition
|
||||||
|
- **PARTIAL**: Test exercises the happy path but not preconditions or all postconditions
|
||||||
|
- **NO**: No test exists
|
||||||
|
|
||||||
|
### 5d. Side-effect chain coverage
|
||||||
|
For each side-effect rule in `engine_side_effects.allium`, check whether a test verifies ALL `ensures` clauses fire together (not just individually).
|
||||||
|
|
||||||
|
## Step 6: Classify Findings
|
||||||
|
|
||||||
|
Each gap falls into one of these categories with a recommended action:
|
||||||
|
|
||||||
|
| Category | Direction | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| **Spec correct, code wrong** | Spec → Code | Fix the code |
|
||||||
|
| **Code correct, spec drifted** | Code → Spec | Update the spec |
|
||||||
|
| **Code behavior, no spec** | Code → Spec | Distill into spec |
|
||||||
|
| **Spec claim, no test** | Spec → Test | Write test |
|
||||||
|
| **Internal spec inconsistency** | Spec → Spec | Align specs |
|
||||||
|
| **Decision needed** | Both | Resolve with stakeholder |
|
||||||
|
|
||||||
|
## Step 7: Produce SPECGAPS.md
|
||||||
|
|
||||||
|
Consolidate all findings into `SPECGAPS.md` with:
|
||||||
|
- Gap ID for tracking
|
||||||
|
- Clear description of the gap
|
||||||
|
- Which spec file and line
|
||||||
|
- Which code file and line
|
||||||
|
- Recommended path (fix code / update spec / write test / decide)
|
||||||
|
- Priority (HIGH/MEDIUM/LOW)
|
||||||
|
|
||||||
|
## Step 8: Validate
|
||||||
|
|
||||||
|
After making changes:
|
||||||
|
```bash
|
||||||
|
# Run full test suite
|
||||||
|
mix test
|
||||||
|
|
||||||
|
# Run dialyzer
|
||||||
|
mix dialyzer
|
||||||
|
|
||||||
|
# Validate allium specs (if tool available)
|
||||||
|
# Use the allium CLI to validate spec files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Re-running the Audit
|
||||||
|
|
||||||
|
1. Start from Step 2 — re-extract claims from updated specs
|
||||||
|
2. Run Steps 3-5 against current code and tests
|
||||||
|
3. Compare against previous SPECGAPS.md to identify resolved and new gaps
|
||||||
|
4. Update SPECGAPS.md
|
||||||
|
|
||||||
|
The audit should be re-run after:
|
||||||
|
- Adding new spec files or significant spec changes
|
||||||
|
- Adding new features or refactoring code
|
||||||
|
- Adding new test files
|
||||||
|
- Before any release milestone
|
||||||
192
SPECGAPS.md
Normal file
192
SPECGAPS.md
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
# Spec Gaps — Allium Specs vs Code vs Tests
|
||||||
|
|
||||||
|
Gap categories: **SC** = spec correct, fix code | **CS** = code correct, update spec | **ST** = write test | **SD** = decide | **SI** = fix internal spec inconsistency
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Spec Claims Not Fulfilled by Code
|
||||||
|
|
||||||
|
### A1. Code Must Change (spec is normative)
|
||||||
|
|
||||||
|
| ID | Gap | Spec | Code | Path |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| A1-1 | No `archived→draft` or `archived→published` transition | post.allium:121-122 | No code path to unarchive | Fix code: implement unarchive transitions |
|
||||||
|
| A1-2 | `DeletePost` must delete translations + translation files | post.allium:209-212 | `delete_post/1` skips translation cleanup | Fix code: delete PostTranslation rows + files |
|
||||||
|
| A1-3 | Publish must delete old file when path changes | engine_side_effects.allium:73-74 | `publish_post` does not delete old file | Fix code: add old file deletion on path change |
|
||||||
|
| A1-4 | `doNotTranslate: false` written to frontmatter despite "only when true" | frontmatter.allium:398 | `lib/bds/frontmatter.ex:38-39` writes false | Fix code: omit `doNotTranslate` when false |
|
||||||
|
| A1-5 | Auto-save after 3000ms idle | editor_post.allium:183-188 | No auto-save timer | Fix code: implement auto-save on idle + unmount + tab switch |
|
||||||
|
| A1-6 | On-demand rendering in preview server | preview.allium:53-93 | Server serves static pre-generated files | Fix code: implement on-demand template rendering for post/archive/language routes |
|
||||||
|
| A1-7 | Template lookup must use all 4 levels (post→tag→category→default) | template_context.allium:267-277 | Only levels 1 and 4 implemented; tag/category fallback unused | Fix code: implement levels 2-3 in template_selection.ex |
|
||||||
|
| A1-8 | `ValidateLiquid`/`ValidateScript` before publish | template.allium:110, script.allium:165 | No validation gate before publish | Fix code: add validation step before publish |
|
||||||
|
| A1-9 | 17 preset colors + custom hex in tag picker | editor_tags.allium | Native `<input type="color">`, no preset palette | Fix code: implement preset color palette popover |
|
||||||
|
| A1-10 | Template file written on create | engine_side_effects.allium:151-153 | Draft templates have `file_path=""` | Fix code: write template file on create |
|
||||||
|
| A1-11 | Graceful shutdown with inflight request tracking | preview.allium:47-48 | Kills acceptor process, no inflight tracking | Fix code: track inflight requests, drain before shutdown |
|
||||||
|
| A1-12 | Real Pagefind integration for search | generation.allium:208 | Stub only: `pagefind-ui.js` is one-liner, `PagefindUI` never defined, search-runtime.js silently bails, client-side search non-functional | Fix code: bundle real Pagefind, build proper fragment index, wire PagefindUI |
|
||||||
|
|
||||||
|
### A2. Spec Should Update (code is normative)
|
||||||
|
|
||||||
|
| ID | Gap | Spec | Code | Path |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| A2-1 | WYSIWYG/visual editor mode (3 modes) | editor_post.allium:159-164 | Only markdown+preview; visual normalizes to markdown | Drop from spec or mark future |
|
||||||
|
| A2-2 | Template/Script are global entities | template.allium, script.allium | Both have `project_id`, per-project uniqueness | Update spec to per-project scoping |
|
||||||
|
| A2-3 | TagsFile uses `{tags: [...]}` wrapper | frontmatter.allium:255-273 | Code writes bare array `[...]` | Update spec |
|
||||||
|
| A2-4 | Sidecar is "YAML-like, not gray-matter" | frontmatter.allium:174 | Code wraps with `---` delimiters | Update spec to gray-matter style |
|
||||||
|
| A2-5 | Translation frontmatter omits status/timestamps | frontmatter.allium:107-117 | Code writes status, createdAt, updatedAt, publishedAt | Update spec to match written fields |
|
||||||
|
| A2-6 | Search index has single `stemmed_content` | search.allium:40-54 | FTS5 per-field stemmed columns | Update spec to per-field model |
|
||||||
|
| A2-7 | Tag archives are single-page | generation.allium:142-147 | Code paginates | Update spec |
|
||||||
|
| A2-8 | Date archives year+month only | generation.allium:151-159 | Code also generates day-level | Update spec |
|
||||||
|
| A2-9 | Menu is DB entity | menu.allium:20-26 | Purely file-based OPML, no DB table | Update spec to file-only model |
|
||||||
|
| A2-10 | Panel tabs: problems, terminal | layout.allium:235-240 | `[:tasks, :output, :post_links, :git_log]` | Update spec |
|
||||||
|
| A2-11 | Git sidebar: commit input, history, push/pull | sidebar_views.allium | Only "Working tree" item | Mark as partial/TODO in spec |
|
||||||
|
| A2-12 | Slug timestamp fallback after 999 | post.allium:21 | Unbounded numeric suffix | Update spec or fix code |
|
||||||
|
| A2-13 | Thumbnail generation is async | engine_side_effects.allium:117 | Synchronous | Update spec or fix code |
|
||||||
|
| A2-14 | AiModelModality: :video vs :file/:tool | schema.allium:291 | Code has `:file`, `:tool` instead of `:video` | Update spec to :file/:tool |
|
||||||
|
| A2-15 | JSON key convention: snake_case vs camelCase | frontmatter.allium values | Code uses camelCase for all metadata JSON | Update spec to camelCase |
|
||||||
|
| A2-16 | Snowball stemmer language list | search.allium:26-31 | Library determines which have algorithms vs passthrough | Update spec: don't enumerate; just say "Snowball stemmers via library" |
|
||||||
|
| A2-17 | `provider_package_ref` on AiModel | schema.allium:282 | Not in code; legacy field not needed | Drop from spec |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Code Behavior Not in Spec
|
||||||
|
|
||||||
|
### B1. Must Add to Spec (domain-level, affects behavior)
|
||||||
|
|
||||||
|
| ID | Behavior | Code Location | Path |
|
||||||
|
|---|---|---|---|
|
||||||
|
| B1-1 | Chat inline surfaces (9 types: card, chart, form, list, metric, mindmap, table, tabs, text/json) | `lib/bds/ui/chat/tool_surfaces.ex:6-15` | Distill into spec |
|
||||||
|
| B1-2 | Auto-translation system (AutoTranslation.maybe_schedule, media cascade, batch fill) | `lib/bds/posts/auto_translation.ex` | Distill into spec |
|
||||||
|
| B1-3 | 3 extra settings sections (Technology, MCP, Data Maintenance) | `lib/bds/ui/settings_editor/` | Distill into spec |
|
||||||
|
| B1-4 | Style/Theme as separate tab (`:style`), not settings section | `lib/bds/ui/style_editor.ex` | Distill into spec |
|
||||||
|
| B1-5 | `published_*` snapshot fields on Post for diffing | `lib/bds/posts/post.ex:61-65` | Add to post.allium entity |
|
||||||
|
| B1-6 | Full rendering subsystem (Liquex, Filters, Labels, LinksAndLanguages, PostRendering) | `lib/bds/rendering/` | Distill into spec |
|
||||||
|
| B1-7 | 404.html generation | `lib/bds/generation/outputs.ex:344-345` | Add to generation.allium |
|
||||||
|
| B1-8 | `linkedPostIds` in media sidecar | `lib/bds/media/sidecars.ex:42` | Add to frontmatter.allium MediaSidecar |
|
||||||
|
| B1-9 | `projectId` in template/script frontmatter | `templates.ex:337`, `scripts.ex:268` | Add to frontmatter.allium |
|
||||||
|
| B1-10 | Media translation editing modal | `media_editor.html.heex:275-303` | Add to editor_media.allium |
|
||||||
|
| B1-11 | Menu editor drag-drop, indent/unindent/move | `lib/bds/desktop/menu_editor/tree_ops.ex` | Add to editor_misc.allium |
|
||||||
|
| B1-12 | `:language_picker` overlay with flag emojis | `shell_overlay.html.heex:116-139` | Add to modals.allium |
|
||||||
|
| B1-13 | `:confirm_dialog` generic confirmation | `shell_overlay.html.heex:171-187` | Add to modals.allium |
|
||||||
|
| B1-14 | Publish actions for scripts and templates | `script_editor.html.heex:10-12`, `template_editor.html.heex:10-12` | Add to editor_script.allium, editor_template.allium |
|
||||||
|
| B1-15 | `:import` as full editor tab | `lib/bds/ui/import_editor.ex` | Add to tabs.allium |
|
||||||
|
| B1-16 | `:documentation`/`:api_documentation` tab types | `lib/bds/desktop/misc_editor/` | Add to tabs.allium |
|
||||||
|
| B1-17 | Metadata diff covers embedding, media_translation, post_translation as entity types | `lib/bds/maintenance/repair.ex` | Add to metadata_diff.allium |
|
||||||
|
| B1-18 | Finished task TTL eviction (1h, keep last 10) | `lib/bds/tasks.ex:365-386` | Add to task.allium |
|
||||||
|
| B1-19 | `discard_post_changes/1` | `lib/bds/posts.ex:201-227` | Add to post.allium |
|
||||||
|
| B1-20 | `replace_media_file/2` with checksum/backup | `lib/bds/media.ex:288-337` | Add to media.allium |
|
||||||
|
|
||||||
|
### B2. Lower Priority (implementation detail or minor)
|
||||||
|
|
||||||
|
| ID | Behavior | Code Location |
|
||||||
|
|---|---|---|
|
||||||
|
| B2-1 | `editor_body/1` content resolver | `lib/bds/posts.ex:229-252` |
|
||||||
|
| B2-2 | `sync_post_from_file/1` single-post reimport | `lib/bds/posts.ex:254-279` |
|
||||||
|
| B2-3 | `import_orphan_post_file/1` | `lib/bds/posts.ex:289-291` |
|
||||||
|
| B2-4 | `dashboard_stats/1`, `post_counts_by_year_month/1` | `lib/bds/posts.ex:378-413` |
|
||||||
|
| B2-5 | `regenerate_missing_thumbnails/2` | `lib/bds/media.ex:47-48` |
|
||||||
|
| B2-6 | Cache dir computation | `lib/bds/projects.ex:101-106` |
|
||||||
|
| B2-7 | `remove_stale_published_templates` | `lib/bds/templates.ex:524-552` |
|
||||||
|
| B2-8 | Rendering Labels module (30+ i18n strings) | `lib/bds/rendering/labels.ex` |
|
||||||
|
| B2-9 | Progress reporting during reindex | `lib/bds/generation/progress.ex` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Internal Spec Inconsistencies
|
||||||
|
|
||||||
|
All reconciled to follow code. Specs must be self-consistent and match code.
|
||||||
|
|
||||||
|
| ID | Conflict | Resolution | Path |
|
||||||
|
|---|---|---|---|
|
||||||
|
| C-1 | schema.allium ChatMessage has no cache tokens; ai.allium ChatMessage has `cache_read_tokens`/`cache_write_tokens` | Code has cache tokens → align schema.allium with ai.allium | Update schema.allium |
|
||||||
|
| C-2 | media.allium SidecarFile mentions `linkedPostIds`; frontmatter.allium MediaSidecar does NOT list it | Code writes `linkedPostIds` → add to frontmatter.allium | Update frontmatter.allium |
|
||||||
|
| C-3 | translation.allium says status/timestamps omitted; frontmatter.allium TranslationFrontmatter defines only 5 fields; code writes 8+ fields | Code writes status/timestamps → update both specs to match code | Update translation.allium + frontmatter.allium |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Spec Claims Not Covered by Tests
|
||||||
|
|
||||||
|
### D1. No Test Coverage (HIGH priority — invariants/guarantees)
|
||||||
|
|
||||||
|
| ID | Claim | Spec | Path |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D1-1 | UniqueMediaTranslation invariant | media.allium:108 | Write test: create duplicate media translation, expect rejection |
|
||||||
|
| D1-2 | UniqueTranslationPerLanguage invariant | translation.allium:94 | Write test: create duplicate post translation, expect rejection |
|
||||||
|
| D1-3 | BundledDefaultTemplatesExistOutsideProjectData | template.allium:65 | Write test: render with no Template rows, bundled template found |
|
||||||
|
| D1-4 | UserTemplateDirectoryOverridesBundledDefaults | template.allium:75 | Write test: project template overrides bundled same-slug |
|
||||||
|
| D1-5 | LiquidTagSubset (5 tags only) | template.allium:179 | Write test: unsupported tag raises error |
|
||||||
|
| D1-6 | LiquidFilterSubset (4 standard + 2 custom) | template.allium:191 | Write test: unsupported filter raises error |
|
||||||
|
| D1-7 | LiquidOperatorSubset | template.allium:210 | Write test: unsupported operator raises error |
|
||||||
|
| D1-8 | MacroTimeout guarantee | script.allium:94-95 | Write test: macro times out within budget |
|
||||||
|
| D1-9 | ExecuteTransform rule (pipeline, ordering, toast budget) | script.allium:229-263 | Write test: transform pipeline executes in order, toast budget enforced |
|
||||||
|
| D1-10 | TransformPipelineContinuation | script.allium:247-249 | Write test: error in transform doesn't halt pipeline |
|
||||||
|
| D1-11 | ChatContextTruncation invariant | ai.allium:375-379 | Write test: long chat history trimmed to context window |
|
||||||
|
| D1-12 | BoundedToolLoop enforcement | ai.allium:381-385 | Write test: tool rounds bounded by chat_max_tool_rounds |
|
||||||
|
| D1-13 | DiscardPostChangesSideEffects | engine_side_effects.allium:99-104 | Write test: FTS updated after discard |
|
||||||
|
| D1-14 | ReplaceMediaFileSideEffects | engine_side_effects.allium:128-134 | Write test: file replaced, thumbnails regenerated |
|
||||||
|
| D1-15 | Drag-and-drop image chain | action_patterns.allium:84-103 | Write integration test |
|
||||||
|
| D1-16 | DebouncedPersistence (5s) | embedding.allium:204-208 | Write test: index persistence debounced |
|
||||||
|
| D1-17 | Protected categories cannot be deleted | editor_settings.allium:81-84 | Write test: article/aside/page/picture deletion rejected |
|
||||||
|
| D1-18 | HomeItemProtection (menu) | editor_misc.allium:206-209 | Write test: cannot move/reorder/delete Home |
|
||||||
|
|
||||||
|
### D2. No Test Coverage (MEDIUM priority — rules/behaviors)
|
||||||
|
|
||||||
|
| ID | Claim | Spec | Path |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D2-1 | RemoveCategory rule | metadata.allium:100 | Write test: remove category, verify list+settings+JSON updated |
|
||||||
|
| D2-2 | CreateAndPublishTemplate rule | template.allium:105 | Write test: create+publish in one step |
|
||||||
|
| D2-3 | CreateAndPublishScript rule | script.allium:160 | Write test: create+publish in one step |
|
||||||
|
| D2-4 | UniqueScriptSlug dedup | script.allium:115 | Write test: two scripts same title → dedup slug |
|
||||||
|
| D2-5 | FrontmatterRoundtrip invariant | post.allium:223 | Write test: write file, read back, assert all DB fields match |
|
||||||
|
| D2-6 | SidecarRoundtrip invariant | media.allium:198 | Write test: write sidecar, read back, assert all fields match |
|
||||||
|
| D2-7 | ConditionalPostFields: nil fields absent from frontmatter | frontmatter.allium:398 | Write test: post with nil excerpt/author/language → fields not in file |
|
||||||
|
| D2-8 | ConditionalMediaFields: nil fields absent from sidecar | frontmatter.allium:417 | Write test: media with nil title/alt → fields not in sidecar |
|
||||||
|
| D2-9 | max_posts_per_page 1..500 constraint | metadata.allium:75-77 | Write test: values outside range rejected |
|
||||||
|
| D2-10 | SandboxedExecution: restricted capabilities blocked | script.allium:84-88 | Write test: filesystem/process/package loading blocked |
|
||||||
|
| D2-11 | TransformToastBudget enforcement | script.allium:251-258 | Write test: per-script and total toast limits enforced |
|
||||||
|
| D2-12 | ProgressThrottled: 250ms throttle | task.allium:110-113 | Write test: rapid progress reports throttled |
|
||||||
|
| D2-13 | archived→draft transition | post.allium:121 | Write test: unarchive post → draft |
|
||||||
|
| D2-14 | archived→published transition | post.allium:122 | Write test: unarchive post → published |
|
||||||
|
| D2-15 | AppNoopNotifier: app writes don't produce notification rows | cli_sync.allium:64-68 | Write test: app mutation produces no notification row |
|
||||||
|
| D2-16 | ValidateMedia rule | media_processing.allium:318-343 | Write test: missing/corrupted/orphan media detected |
|
||||||
|
| D2-17 | ContentHashSkipsUnchanged during reindex | embedding.allium:199-202 | Write test: unchanged content_hash skips re-embedding |
|
||||||
|
|
||||||
|
### D3. Partial Test Coverage (needs expansion)
|
||||||
|
|
||||||
|
| ID | Claim | Spec | Gap | Path |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| D3-1 | PublishPost: content=null after publish | post.allium:186 | Not explicitly tested | Add assertion |
|
||||||
|
| D3-2 | PublishPost: old file deleted on path change | engine_side_effects.allium:73-74 | Not tested | Add test |
|
||||||
|
| D3-3 | UpsertPostTranslation: do_not_translate guard | translation.allium:113 | Indirectly covered only | Add direct test |
|
||||||
|
| D3-4 | PublishTemplate: Liquid validation prerequisite | template.allium:139 | Not tested as publish gate | Add test |
|
||||||
|
| D3-5 | PublishScript: validation prerequisite | script.allium:181 | Not tested as publish gate | Add test |
|
||||||
|
| D3-6 | ExecuteMacro failure degrades to empty | script.allium:199 | Returns error tuple, not empty | Fix code or update spec |
|
||||||
|
| D3-7 | TemplateFrontmatter roundtrip | template.allium:53 | Slug verified, no full parse-back | Add roundtrip test |
|
||||||
|
| D3-8 | DefaultCategories for fresh project | metadata.allium:60 | Defaults present after add, not verified fresh | Add fresh-project test |
|
||||||
|
| D3-9 | FtsIncludesTranslations | translation.allium:178 | Tested for one language; expand | Test all stemmer languages |
|
||||||
|
| D3-10 | PostCanonicalUrl format | post.allium:33-40 | Constructed in links test, not asserted as invariant | Add format assertion |
|
||||||
|
| D3-11 | Slug generation: German transliteration | post.allium:14-22 | "Föö Bär" → "foo-bar-blog" tested; expand ä/ö/ü/ß/ÄÖÜ | Expand test |
|
||||||
|
|
||||||
|
### D4. UI Test Coverage Gaps (whole-editor specs)
|
||||||
|
|
||||||
|
| ID | Spec | Covered | Not Covered |
|
||||||
|
|---|---|---|---|
|
||||||
|
| D4-1 | editor_media.allium | AI analysis, delete | Translate, replace file, link-to-post, translation CRUD, detect language |
|
||||||
|
| D4-2 | editor_settings.allium | AI endpoints, airplane toggle, rebuild | Protected categories, MCP agents, style/theme, search filter, categories CRUD |
|
||||||
|
| D4-3 | editor_chat.allium | Chat creation, pinned tab | API key screen, message rendering, input area, model selector, inline surfaces |
|
||||||
|
| D4-4 | editor_script.allium | Editor layout, create defaults | Save, syntax check, run, delete |
|
||||||
|
| D4-5 | editor_template.allium | Editor layout, create defaults | Save with validation, validate, delete with references |
|
||||||
|
| D4-6 | editor_tags.allium | Sync/discover, merge | Cloud sizing, color picker, delete confirmation, create form |
|
||||||
|
| D4-7 | editor_misc.allium | Menu add/save, metadata diff, validation | Menu protection, import analysis, translation fix, duplicate dismiss, git diff |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Priority Order for Resolution
|
||||||
|
|
||||||
|
1. **A1-1 through A1-12** — code must follow spec (includes auto-save, on-demand preview, template lookup, validation gates, real Pagefind, graceful shutdown)
|
||||||
|
2. **D1-1 through D1-18** — untested invariants/guarantees
|
||||||
|
3. **C-1 through C-3** — internal spec inconsistencies (reconcile to code)
|
||||||
|
4. **B1-1 through B1-6** — major code behaviors missing from spec
|
||||||
|
5. **A2-1 through A2-17** — spec drift (code is normative, update spec)
|
||||||
|
6. **D2-1 through D2-17** — untested rules
|
||||||
|
7. **D3-1 through D3-11** — partial test coverage
|
||||||
|
8. **B1-7 through B1-20** — minor code behaviors missing from spec
|
||||||
|
9. **D4-1 through D4-7** — UI test coverage
|
||||||
87
TESTAUDIT.md
Normal file
87
TESTAUDIT.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Test Audit Procedure
|
||||||
|
|
||||||
|
Periodic review of the unit test suite to ensure every test exercises production
|
||||||
|
code against real assumptions and behavior.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
All `*_test.exs` files under `test/`.
|
||||||
|
|
||||||
|
## What counts as a valid unit test
|
||||||
|
|
||||||
|
A valid unit test **calls at least one production function** from `lib/bds/` and
|
||||||
|
**asserts on its return value, side effects, or observable behavior**.
|
||||||
|
|
||||||
|
Acceptable patterns:
|
||||||
|
|
||||||
|
- Calling a production function and asserting its return value.
|
||||||
|
- Calling a production function with injected test doubles (fake HTTP clients,
|
||||||
|
fake runtimes) and asserting the production code's orchestration logic.
|
||||||
|
- Mounting a LiveView or rendering a LiveComponent and asserting HTML output
|
||||||
|
or database state after interactions.
|
||||||
|
- Sending events to a GenServer and asserting state transitions.
|
||||||
|
|
||||||
|
### Source-property tests (acceptable, not flagged)
|
||||||
|
|
||||||
|
Tests that verify structural properties of source code are acceptable and should
|
||||||
|
not be flagged during this audit. Examples:
|
||||||
|
|
||||||
|
- Checking that all public functions have `@spec` annotations (AST parsing).
|
||||||
|
- Asserting absence of `String.to_atom` or `cond do` in specific files.
|
||||||
|
- Verifying CSS/JS/template assets contain expected class names or imports.
|
||||||
|
- Checking that `API.md` matches the output of a documentation generator.
|
||||||
|
- Verifying database indexes exist via `EXPLAIN QUERY PLAN`.
|
||||||
|
- Asserting `.allium` spec files have consistent parameter signatures.
|
||||||
|
- Checking config files for expected values.
|
||||||
|
- Verifying function decomposition patterns in source.
|
||||||
|
|
||||||
|
These are linting/contract/consistency checks. They serve a purpose but are
|
||||||
|
distinct from behavioral tests.
|
||||||
|
|
||||||
|
## What gets flagged
|
||||||
|
|
||||||
|
1. **Export-existence-only tests** — tests that call `function_exported?/3` or
|
||||||
|
`Code.ensure_loaded?/1` without ever invoking the function. These verify
|
||||||
|
compilation, not behavior. They are redundant when the same module is already
|
||||||
|
tested via rendering or direct calls in another test file.
|
||||||
|
|
||||||
|
2. **Mock-only tests** — tests that define a fake/stub module and only assert
|
||||||
|
on that fake's behavior without routing through any production code path.
|
||||||
|
|
||||||
|
3. **Trivially-passing tests** — tests whose assertions succeed regardless of
|
||||||
|
whether the production code is correct (e.g., asserting on a hardcoded value
|
||||||
|
that never touches production logic).
|
||||||
|
|
||||||
|
## How to run the audit
|
||||||
|
|
||||||
|
Ask Claude Code to:
|
||||||
|
|
||||||
|
> Analyse the unit tests of the project and check if all of them actually call
|
||||||
|
> proper production code or if there are tests that essentially only test
|
||||||
|
> scaffolds, mocks and helper functions. Every unit test must test proper
|
||||||
|
> production code against assumptions and behaviour. Source-property tests
|
||||||
|
> (structure, @spec, asset presence, schema verification, doc staleness) are
|
||||||
|
> acceptable and should not be flagged.
|
||||||
|
|
||||||
|
The audit should:
|
||||||
|
|
||||||
|
1. Read every `*_test.exs` file under `test/` in full.
|
||||||
|
2. For each test block, identify which production function (if any) is called.
|
||||||
|
3. Flag any test that falls into the categories above.
|
||||||
|
4. Report flagged tests with file path, line number, and explanation.
|
||||||
|
|
||||||
|
## Audit log
|
||||||
|
|
||||||
|
### 2026-05-11
|
||||||
|
|
||||||
|
Reviewed all 71 test files (69 after cleanup). Found 2 redundant files:
|
||||||
|
|
||||||
|
- `test/bds/desktop/shell_live/chat_editor_test.exs` — single test only called
|
||||||
|
`function_exported?` for `ChatEditor`. The component was already fully tested
|
||||||
|
via `render_component` in `shell_live_test.exs`. **Deleted.**
|
||||||
|
|
||||||
|
- `test/bds/desktop/shell_live/import_editor_test.exs` — single test only called
|
||||||
|
`Code.ensure_loaded?` + `function_exported?` for `ImportEditor`. The component
|
||||||
|
was already exercised in `import_shell_live_test.exs`. **Deleted.**
|
||||||
|
|
||||||
|
Result after cleanup: 646 tests, 0 failures, 4 skipped.
|
||||||
19
assets/css/app.css
Normal file
19
assets/css/app.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@import "tailwindcss" source(none);
|
||||||
|
|
||||||
|
@source "../css";
|
||||||
|
@source "../js";
|
||||||
|
@source "../../lib/bds/desktop";
|
||||||
|
|
||||||
|
@import "./tokens.css";
|
||||||
|
@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 "./import_editor.css";
|
||||||
|
@import "./utilities.css";
|
||||||
223
assets/css/assistant.css
Normal file
223
assets/css/assistant.css
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
.settings-view-shell,
|
||||||
|
.style-view,
|
||||||
|
.tags-view-shell,
|
||||||
|
.scripts-view-shell,
|
||||||
|
.templates-view-shell,
|
||||||
|
.chat-panel {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel {
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-header {
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
background: var(--vscode-sideBar-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 10px;
|
||||||
|
overflow: visible;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-title-main {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-model-selector-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-model-selector-button,
|
||||||
|
.chat-model-selector-option {
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-model-selector-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
right: auto;
|
||||||
|
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
|
||||||
|
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
|
||||||
|
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-model-selector-button.chat-model-selector-inline {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-model-selector-caret {
|
||||||
|
position: static;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages,
|
||||||
|
.chat-surface-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message {
|
||||||
|
display: flex;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.user {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-content {
|
||||||
|
max-width: min(760px, 100%);
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: var(--vscode-sideBar-background);
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-message.user .chat-message-content {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-list-activeSelectionForeground);
|
||||||
|
border: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-surface-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-surface-table th,
|
||||||
|
.chat-tool-surface-table td {
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
padding: 6px 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-tool-surface-json {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-textCodeBlock-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input-container {
|
||||||
|
--chat-input-line-height: 20px;
|
||||||
|
--chat-input-min-height: 20px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--vscode-sideBar-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input-wrapper {
|
||||||
|
min-height: 30px;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input-wrapper:focus-within {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input {
|
||||||
|
flex: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: var(--chat-input-min-height);
|
||||||
|
min-height: var(--chat-input-min-height);
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
line-height: var(--chat-input-line-height);
|
||||||
|
max-height: 160px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input::placeholder {
|
||||||
|
color: var(--vscode-input-placeholderForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-send-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
max-width: 22px;
|
||||||
|
max-height: 22px;
|
||||||
|
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 {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.chat-panel-header {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel-title {
|
||||||
|
width: 100%;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-model-selector-wrap {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-model-selector-button.chat-model-selector-inline {
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-messages {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message-content {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input-container {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
956
assets/css/editor.css
Normal file
956
assets/css/editor.css
Normal file
@@ -0,0 +1,956 @@
|
|||||||
|
.editor-shell {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-frame {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 240px;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-main,
|
||||||
|
.editor-meta,
|
||||||
|
.panel-shell,
|
||||||
|
.assistant-card {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-kicker {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title {
|
||||||
|
margin: 10px 0 6px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-subtitle {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar-button {
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar-button:hover,
|
||||||
|
.panel-tab:hover {
|
||||||
|
background: var(--vscode-toolbar-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-section {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-section h2 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-list.compact li {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-meta {
|
||||||
|
border-left: 1px solid var(--vscode-panel-border);
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-meta-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 10px 0;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-markdown-surface,
|
||||||
|
.scripts-monaco.monaco-editor-shell,
|
||||||
|
.templates-monaco.monaco-editor-shell {
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
border-color: var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .monaco-editor-instance,
|
||||||
|
.scripts-monaco .monaco-editor-instance,
|
||||||
|
.templates-monaco .monaco-editor-instance {
|
||||||
|
min-height: 0;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monaco-editor-shell .monaco-editor,
|
||||||
|
.monaco-editor-shell .monaco-editor .margin,
|
||||||
|
.monaco-editor-shell .monaco-editor-background,
|
||||||
|
.monaco-editor-shell .monaco-editor .inputarea.ime-input {
|
||||||
|
background-color: var(--vscode-editor-background) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monaco-editor-shell .monaco-editor,
|
||||||
|
.monaco-editor-shell .monaco-editor .view-line {
|
||||||
|
color: var(--vscode-editor-foreground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monaco-editor-shell .monaco-editor .line-numbers {
|
||||||
|
color: var(--vscode-editorLineNumber-foreground, #858585) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monaco-editor-shell .monaco-editor .current-line,
|
||||||
|
.monaco-editor-shell .monaco-editor .view-overlays .current-line {
|
||||||
|
border-color: var(--vscode-editor-lineHighlightBorder, transparent) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-doc-view {
|
||||||
|
--doc-bg: var(--panel-1, #1e1e1e);
|
||||||
|
--doc-surface: var(--panel-2, #252526);
|
||||||
|
--doc-border: var(--line, #3c3c3c);
|
||||||
|
--doc-text: var(--vscode-editor-foreground, #d4d4d4);
|
||||||
|
--doc-muted: var(--vscode-descriptionForeground, #9da3ad);
|
||||||
|
--doc-link: var(--vscode-textLink-foreground, #9cdcfe);
|
||||||
|
--doc-code-bg: var(--vscode-textCodeBlock-background, rgba(0, 0, 0, 0.2));
|
||||||
|
--doc-hover: var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.help-doc-view .misc-editor-content {
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-view,
|
||||||
|
.documentation-scroll {
|
||||||
|
background: var(--doc-bg, var(--vscode-editor-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-scroll {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 28px 24px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content {
|
||||||
|
max-width: 920px;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: var(--doc-text, var(--vscode-editor-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-article,
|
||||||
|
.help-doc-markdown {
|
||||||
|
background: var(--doc-surface);
|
||||||
|
padding: 18px 20px 24px;
|
||||||
|
border: 1px solid var(--doc-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body > .documentation-article > :first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body > .documentation-article > :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body h1,
|
||||||
|
.documentation-content.markdown-body h2,
|
||||||
|
.documentation-content.markdown-body h3 {
|
||||||
|
color: var(--doc-text);
|
||||||
|
border-bottom: 1px solid var(--doc-border);
|
||||||
|
padding-bottom: 6px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body h1 {
|
||||||
|
font-size: 1.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body h2 {
|
||||||
|
margin-top: 2rem;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body h3 {
|
||||||
|
margin-top: 1.6rem;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body p,
|
||||||
|
.documentation-content.markdown-body li,
|
||||||
|
.documentation-content.markdown-body td,
|
||||||
|
.documentation-content.markdown-body th {
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body a {
|
||||||
|
color: var(--doc-link);
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body a:hover {
|
||||||
|
color: var(--doc-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--doc-border);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body code {
|
||||||
|
background: var(--doc-code-bg);
|
||||||
|
padding: 0.12em 0.4em;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: 0.92em/1.45 "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body pre {
|
||||||
|
margin: 0.9rem 0 1.2rem;
|
||||||
|
background: var(--doc-code-bg);
|
||||||
|
border: 1px solid var(--doc-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body pre code {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body blockquote {
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0 0 0 12px;
|
||||||
|
border-left: 3px solid var(--doc-border);
|
||||||
|
color: var(--doc-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body table {
|
||||||
|
width: 100%;
|
||||||
|
margin: 1rem 0 1.4rem;
|
||||||
|
border-collapse: collapse;
|
||||||
|
display: table;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body th,
|
||||||
|
.documentation-content.markdown-body td {
|
||||||
|
border: 1px solid var(--doc-border);
|
||||||
|
padding: 8px 10px;
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body th {
|
||||||
|
background: var(--doc-hover);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body ul,
|
||||||
|
.documentation-content.markdown-body ol {
|
||||||
|
margin: 0.85rem 0 1rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body ul {
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body ol {
|
||||||
|
list-style: decimal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body li {
|
||||||
|
margin: 0.3rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body li > ul,
|
||||||
|
.documentation-content.markdown-body li > ol {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body strong {
|
||||||
|
color: var(--doc-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documentation-content.markdown-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor,
|
||||||
|
.scripts-view-shell,
|
||||||
|
.templates-view-shell {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--vscode-editor-background);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-tab-dirty {
|
||||||
|
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-tab-meta {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .quick-actions-wrapper {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .quick-actions-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .quick-actions-btn-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .quick-actions-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--vscode-dropdown-border, #454545);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .quick-action-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .quick-action-text strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .quick-action-text small {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .status-badge,
|
||||||
|
.scripts-view-shell .status-badge,
|
||||||
|
.templates-view-shell .status-badge {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .status-badge.status-draft,
|
||||||
|
.scripts-view-shell .status-badge.status-draft,
|
||||||
|
.templates-view-shell .status-badge.status-draft {
|
||||||
|
background-color: rgba(204, 167, 0, 0.2);
|
||||||
|
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .status-badge.status-published,
|
||||||
|
.scripts-view-shell .status-badge.status-published,
|
||||||
|
.templates-view-shell .status-badge.status-published {
|
||||||
|
background-color: rgba(115, 201, 145, 0.2);
|
||||||
|
color: var(--vscode-testing-iconPassed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .status-badge.status-archived,
|
||||||
|
.scripts-view-shell .status-badge.status-archived,
|
||||||
|
.templates-view-shell .status-badge.status-archived {
|
||||||
|
background-color: rgba(133, 133, 133, 0.2);
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .auto-save-indicator {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .metadata-toggle-header {
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .metadata-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 4px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .metadata-toggle:hover {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .metadata-toggle-chevron {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-header-row.is-collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-media-panel {
|
||||||
|
width: 200px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-field label,
|
||||||
|
.post-editor .editor-body label,
|
||||||
|
.post-editor .post-editor-links-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-checkbox-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-transform: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-input.is-readonly {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-excerpt {
|
||||||
|
min-height: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-input-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-input-container.is-disabled {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-input-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
min-height: 38px;
|
||||||
|
border: 1px solid var(--vscode-input-border, #3c3c3c);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background, #3c3c3c);
|
||||||
|
cursor: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-input-wrapper:focus-within {
|
||||||
|
border-color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: var(--vscode-badge-background, #4d4d4d);
|
||||||
|
border: 1px solid var(--vscode-widget-border, #454545);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--vscode-badge-foreground, #ffffff);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-chip.has-color {
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-chip-remove {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
margin-left: 2px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.6;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: opacity 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-chip-remove:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-chip.has-color .tag-chip-remove:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-input-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-input-foreground, #cccccc);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-input-field::placeholder {
|
||||||
|
color: var(--vscode-input-placeholderForeground, #a6a6a6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-input-field:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-suggestions {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--vscode-dropdown-background, #3c3c3c);
|
||||||
|
border: 1px solid var(--vscode-widget-border, #454545);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-suggestion {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-dropdown-foreground, #f0f0f0);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-suggestion:hover,
|
||||||
|
.post-editor .tag-suggestion.selected {
|
||||||
|
background: var(--vscode-list-hoverBackground, #2a2d2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-suggestion-color {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-suggestion-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-suggestion.create-new {
|
||||||
|
border-top: 1px solid var(--vscode-widget-border, #454545);
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
padding-top: 12px;
|
||||||
|
color: var(--vscode-notificationsInfoIcon-foreground, #75beff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-suggestion.create-new:first-child {
|
||||||
|
border-top: none;
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-suggestion-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 1px dashed currentColor;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-language-row select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-translation-flag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-translation-flag.status-draft {
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-translation-flag.status-archived {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-translation-flag.active {
|
||||||
|
border-color: var(--vscode-testing-iconQueued, #cca700);
|
||||||
|
background: color-mix(in srgb, var(--vscode-testing-iconQueued, #cca700) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-translation-flag:hover {
|
||||||
|
background: color-mix(in srgb, var(--vscode-list-hoverBackground) 75%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-links-panel,
|
||||||
|
.post-editor .post-editor-side-panel {
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 82%, white 3%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-side-panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-links-columns {
|
||||||
|
display: flex;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-links-columns > div {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-empty,
|
||||||
|
.post-editor .post-editor-media-meta {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-media-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-media-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-toolbar-center {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-mode-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-mode-toggle button,
|
||||||
|
.post-editor .editor-toolbar-button {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-mode-toggle button {
|
||||||
|
background-color: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08));
|
||||||
|
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-mode-toggle button:hover,
|
||||||
|
.post-editor .editor-toolbar-button:hover {
|
||||||
|
background-color: var(--vscode-button-secondaryHoverBackground, var(--vscode-toolbar-hoverBackground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-mode-toggle button.active {
|
||||||
|
background-color: var(--vscode-button-background, var(--accent-color));
|
||||||
|
color: var(--vscode-button-foreground, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-toolbar-button {
|
||||||
|
background: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08));
|
||||||
|
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-excerpt-panel.is-collapsed {
|
||||||
|
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 {
|
||||||
|
flex: 1;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
min-height: 240px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-preview {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 240px;
|
||||||
|
padding: 14px;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: auto;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-preview-frame {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 520px;
|
||||||
|
border: none;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .post-editor-markdown-surface {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 380px;
|
||||||
|
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .monaco-editor-shell,
|
||||||
|
.scripts-monaco.monaco-editor-shell,
|
||||||
|
.templates-monaco.monaco-editor-shell {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monaco-editor-instance {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .monaco-editor-instance {
|
||||||
|
min-height: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scripts-monaco .monaco-editor-instance,
|
||||||
|
.templates-monaco .monaco-editor-instance {
|
||||||
|
min-height: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monaco-editor-input {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: pre;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
background-color: var(--vscode-sideBar-background);
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.post-editor .editor-header,
|
||||||
|
.scripts-view-shell .ui-editor-header,
|
||||||
|
.templates-view-shell .ui-editor-header,
|
||||||
|
.post-editor .metadata-toggle-header,
|
||||||
|
.post-editor .editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-header-row,
|
||||||
|
.post-editor .editor-field-row,
|
||||||
|
.post-editor .post-editor-links-columns {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-media-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-toolbar-right,
|
||||||
|
.post-editor .ui-editor-actions,
|
||||||
|
.scripts-view-shell .ui-editor-actions,
|
||||||
|
.templates-view-shell .ui-editor-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
141
assets/css/forms.css
Normal file
141
assets/css/forms.css
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
.settings-view,
|
||||||
|
.style-view {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-header,
|
||||||
|
.style-view-header {
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-bottom: 1px solid var(--line, #3c3c3c);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-search input {
|
||||||
|
width: min(320px, 40vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-content {
|
||||||
|
padding: 20px;
|
||||||
|
overflow: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-section {
|
||||||
|
border: 1px solid var(--line, #3c3c3c);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--panel-2, #252526);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-section-header {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid var(--line, #3c3c3c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-section-content {
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(180px, 240px) minmax(0, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-control,
|
||||||
|
.setting-input-group {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-actions {
|
||||||
|
padding: 0 16px 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-theme-picker {
|
||||||
|
padding: 20px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-theme-option {
|
||||||
|
border: 1px solid var(--line, #3c3c3c);
|
||||||
|
background: var(--panel-2, #252526);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-theme-option.selected {
|
||||||
|
border-color: var(--accent-color);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-theme-swatch {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-theme-tones {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-theme-tone {
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-apply-row {
|
||||||
|
padding: 0 20px 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-preview-container {
|
||||||
|
padding: 0 20px 20px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-preview-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 420px;
|
||||||
|
border: 1px solid var(--line, #3c3c3c);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.setting-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
689
assets/css/import_editor.css
Normal file
689
assets/css/import_editor.css
Normal file
@@ -0,0 +1,689 @@
|
|||||||
|
.import-analysis {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 18px 20px 26px;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-analysis-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-analysis-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-definition-name {
|
||||||
|
width: min(480px, 100%);
|
||||||
|
border: 1px solid var(--vscode-input-border, transparent);
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground, var(--vscode-foreground));
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-selectors {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px minmax(0, 1fr) auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 76%, var(--vscode-input-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-row label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-path {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-path.placeholder {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-analysis button,
|
||||||
|
.import-analysis select {
|
||||||
|
border: 1px solid var(--vscode-button-border, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
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 {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid var(--vscode-descriptionForeground);
|
||||||
|
border-top-color: var(--vscode-button-background);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: import-spinner-rotate 0.8s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-step {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-detail {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes import-spinner-rotate {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-site-info {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-site-info-item,
|
||||||
|
.import-stat-card,
|
||||||
|
.import-date-distribution,
|
||||||
|
.import-detail-section,
|
||||||
|
.import-execute-section {
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-site-info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-stat-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-stat-card {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-stat-card h3,
|
||||||
|
.import-date-distribution h3,
|
||||||
|
.import-detail-section h3,
|
||||||
|
.taxonomy-group h4 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-stat-number {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-stat-breakdown,
|
||||||
|
.import-execute-summary,
|
||||||
|
.import-taxonomy-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-stat-breakdown {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-stat-tag,
|
||||||
|
.import-count-tag,
|
||||||
|
.import-taxonomy-pill,
|
||||||
|
.macro-status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-new,
|
||||||
|
.import-taxonomy-pill.new-tax {
|
||||||
|
background: rgba(117, 190, 255, 0.16);
|
||||||
|
color: #75beff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-update,
|
||||||
|
.stat-mapped,
|
||||||
|
.import-taxonomy-pill.exists,
|
||||||
|
.import-taxonomy-pill.mapped,
|
||||||
|
.macro-status-badge.mapped,
|
||||||
|
.import-execution-complete {
|
||||||
|
background: rgba(115, 201, 145, 0.16);
|
||||||
|
color: #73c991;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-conflict {
|
||||||
|
background: rgba(255, 166, 87, 0.16);
|
||||||
|
color: #ffb169;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-duplicate,
|
||||||
|
.stat-missing,
|
||||||
|
.macro-status-badge.unmapped,
|
||||||
|
.import-execution-error {
|
||||||
|
background: rgba(204, 167, 0, 0.16);
|
||||||
|
color: #cca700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-date-distribution,
|
||||||
|
.import-detail-section,
|
||||||
|
.import-execute-section {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-section-toggle {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0;
|
||||||
|
border: none !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: inherit !important;
|
||||||
|
font-size: 16px !important;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-section-toggle:hover {
|
||||||
|
background: transparent !important;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-bars {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 56px minmax(0, 1fr) 72px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-year,
|
||||||
|
.distribution-count,
|
||||||
|
.slug-cell {
|
||||||
|
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-bar-container {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-bar {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 8px;
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.distribution-bar-posts {
|
||||||
|
background: linear-gradient(90deg, rgba(117, 190, 255, 0.8), rgba(117, 190, 255, 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-execute-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-execute-summary {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-execution-complete,
|
||||||
|
.import-execution-error {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-execution-progress {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-execution-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-execution-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-bar {
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, rgba(117, 190, 255, 0.85), rgba(117, 190, 255, 0.45));
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-progress-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-phase {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-detail,
|
||||||
|
.import-counter {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-detail-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-detail-table th,
|
||||||
|
.import-detail-table td {
|
||||||
|
padding: 10px 8px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
vertical-align: middle;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-detail-table th {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-detail-table .status-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-detail-table .status-badge.new {
|
||||||
|
background: rgba(117, 190, 255, 0.16);
|
||||||
|
color: #75beff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-detail-table .status-badge.update {
|
||||||
|
background: rgba(115, 201, 145, 0.16);
|
||||||
|
color: #73c991;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-detail-table .status-badge.conflict {
|
||||||
|
background: rgba(255, 166, 87, 0.16);
|
||||||
|
color: #ffb169;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-detail-table .status-badge.duplicate,
|
||||||
|
.import-detail-table .status-badge.missing {
|
||||||
|
background: rgba(204, 167, 0, 0.16);
|
||||||
|
color: #cca700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.categories-cell,
|
||||||
|
.existing-match,
|
||||||
|
.mime-type-cell,
|
||||||
|
.post-type-cell {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mime-type-cell,
|
||||||
|
.post-type-cell,
|
||||||
|
.existing-match,
|
||||||
|
.slug-cell {
|
||||||
|
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.resolution-select,
|
||||||
|
.taxonomy-mapping-input {
|
||||||
|
min-width: 150px;
|
||||||
|
background: var(--vscode-dropdown-background, var(--vscode-input-background));
|
||||||
|
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
|
||||||
|
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
|
||||||
|
padding: 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-analyze-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 0 12px;
|
||||||
|
margin-top: 12px;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-analyze-dropdown {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-analyze-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-model-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
left: 0;
|
||||||
|
min-width: 220px;
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
|
||||||
|
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-model-option {
|
||||||
|
width: 100%;
|
||||||
|
display: block;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
background: transparent !important;
|
||||||
|
color: var(--vscode-foreground) !important;
|
||||||
|
text-align: left;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-model-option:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-analyze-hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-taxonomy-groups {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-taxonomy-entry,
|
||||||
|
.import-taxonomy-edit-form {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-taxonomy-entry,
|
||||||
|
.import-taxonomy-edit-form {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-taxonomy-pill {
|
||||||
|
border: none;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.import-taxonomy-pill {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapped-target {
|
||||||
|
background: rgba(115, 201, 145, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-mapping-arrow {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-mapping-input {
|
||||||
|
min-width: 170px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-edit-btn,
|
||||||
|
.taxonomy-clear-btn {
|
||||||
|
min-width: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 8px !important;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-edit-btn.ghost,
|
||||||
|
.taxonomy-clear-btn {
|
||||||
|
background: transparent !important;
|
||||||
|
border: 1px solid var(--vscode-panel-border) !important;
|
||||||
|
color: var(--vscode-descriptionForeground) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macros-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-item {
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-item.unmapped {
|
||||||
|
border-left: 3px solid #cca700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-name,
|
||||||
|
.import-taxonomy-pill {
|
||||||
|
font-family: var(--vscode-editor-font-family, ui-monospace, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.macro-count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 56px 20px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
border: 1px dashed var(--vscode-panel-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-empty-state p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.import-site-info,
|
||||||
|
.import-stat-cards,
|
||||||
|
.import-taxonomy-groups {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 780px) {
|
||||||
|
.import-analysis {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-row,
|
||||||
|
.distribution-row,
|
||||||
|
.import-execute-section,
|
||||||
|
.import-site-info,
|
||||||
|
.import-stat-cards,
|
||||||
|
.import-taxonomy-groups {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-execute-section {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-file-row {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-analysis button,
|
||||||
|
.resolution-select,
|
||||||
|
.taxonomy-mapping-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taxonomy-analyze-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-taxonomy-entry,
|
||||||
|
.import-taxonomy-edit-form {
|
||||||
|
width: 100%;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
325
assets/css/media_editor.css
Normal file
325
assets/css/media_editor.css
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
[data-testid="media-editor"] .editor-tab-dirty {
|
||||||
|
color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-editorWarning-foreground));
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .ui-editor-actions button {
|
||||||
|
padding: 4px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .ui-editor-actions button.danger:hover {
|
||||||
|
background-color: var(--vscode-notificationsErrorIcon-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .auto-save-indicator {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .quick-actions-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .quick-actions-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .quick-actions-btn-icon {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .quick-actions-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--vscode-dropdown-border, #454545);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .quick-action-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .quick-action-text strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .quick-action-text small {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] > .editor-content.media-editor {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .editor-field label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .post-editor-input.disabled,
|
||||||
|
[data-testid="media-editor"] .post-editor-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .media-preview {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
border-radius: 8px;
|
||||||
|
min-height: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .media-preview-placeholder {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .media-preview-image {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
align-self: stretch;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .media-preview-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .media-details {
|
||||||
|
width: 320px;
|
||||||
|
gap: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .media-details textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .linked-posts-section label {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
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 {
|
||||||
|
background: var(--vscode-dropdown-background);
|
||||||
|
border: 1px solid var(--vscode-dropdown-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .post-picker-search {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid var(--vscode-dropdown-border);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--vscode-dropdown-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .post-picker-search input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .post-picker-search input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .post-picker-list {
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .post-picker-item {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .post-picker-item:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .post-picker-more {
|
||||||
|
padding: 6px 8px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .no-posts,
|
||||||
|
[data-testid="media-editor"] .no-linked-posts {
|
||||||
|
padding: 12px 8px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .linked-posts-list {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .linked-post-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--vscode-sideBar-background);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .linked-post-title,
|
||||||
|
[data-testid="media-editor"] .linked-post-link {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .linked-post-title:hover,
|
||||||
|
[data-testid="media-editor"] .linked-post-link:hover {
|
||||||
|
color: var(--vscode-textLink-foreground);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .linked-post-item .unlink-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .linked-post-item:hover .unlink-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-testid="media-editor"] .linked-post-item .unlink-btn:hover {
|
||||||
|
color: var(--vscode-errorForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.68);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 10001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-modal {
|
||||||
|
width: min(640px, calc(100vw - 32px));
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-modal-header,
|
||||||
|
.translation-modal-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-modal-header {
|
||||||
|
border-bottom: 1px solid #3c3c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-modal-footer {
|
||||||
|
border-top: 1px solid #3c3c3c;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-modal-close {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #c5c5c5;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
259
assets/css/menu_editor.css
Normal file
259
assets/css/menu_editor.css
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
.menu-editor-header {
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-header p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tree-wrap {
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-toolbar {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tool {
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tool:hover:not(:disabled) {
|
||||||
|
background: var(--vscode-toolbar-hoverBackground);
|
||||||
|
border-color: var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tool:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tree-shell {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tree-level {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tree-item {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row {
|
||||||
|
--menu-editor-indent: calc(var(--menu-editor-depth) * 1rem);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.3rem 0.45rem 0.3rem calc(0.4rem + var(--menu-editor-indent));
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row.is-selected {
|
||||||
|
background: var(--vscode-list-activeSelectionBackground);
|
||||||
|
color: var(--vscode-list-activeSelectionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row.is-dragging {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row.is-drop-before::before,
|
||||||
|
.menu-editor-row.is-drop-after::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: calc(0.4rem + var(--menu-editor-indent));
|
||||||
|
right: 0.45rem;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row.is-drop-before::before {
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row.is-drop-after::after {
|
||||||
|
bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row.is-drop-inside {
|
||||||
|
box-shadow: inset 0 0 0 1px var(--vscode-focusBorder);
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row-handle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1rem;
|
||||||
|
min-width: 1rem;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row-handle:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row-kind {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1rem;
|
||||||
|
min-width: 1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row-title.is-editing {
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
text-overflow: clip;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-entry-form {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--vscode-focusBorder);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
padding: 0.25rem 0.45rem;
|
||||||
|
min-height: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-search {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
max-height: 18rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-search-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-search-head strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-search-head span {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-action {
|
||||||
|
border: 1px solid var(--vscode-button-border, transparent);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-action:hover {
|
||||||
|
background: var(--vscode-button-secondaryHoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
max-height: 16rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-item:hover {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-item small,
|
||||||
|
.menu-editor-picker-state {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-empty {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
padding: 0.5rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.menu-editor-inline-search-head {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-inline-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
336
assets/css/overlays.css
Normal file
336
assets/css/overlays.css
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
.overlay-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-root:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-shared-actions {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestions-modal-backdrop,
|
||||||
|
.insert-modal-backdrop,
|
||||||
|
.language-picker-modal-backdrop,
|
||||||
|
.confirm-delete-modal-backdrop,
|
||||||
|
.confirm-dialog-overlay,
|
||||||
|
.gallery-overlay,
|
||||||
|
.lightbox-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.68);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestions-modal,
|
||||||
|
.insert-modal,
|
||||||
|
.language-picker-modal,
|
||||||
|
.confirm-delete-modal,
|
||||||
|
.confirm-dialog,
|
||||||
|
.gallery-overlay-content {
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestions-modal,
|
||||||
|
.language-picker-modal,
|
||||||
|
.confirm-delete-modal,
|
||||||
|
.confirm-dialog {
|
||||||
|
width: min(680px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal {
|
||||||
|
width: min(680px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-overlay-content {
|
||||||
|
width: min(980px, calc(100vw - 48px));
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestions-modal-header,
|
||||||
|
.language-picker-modal-header,
|
||||||
|
.confirm-delete-modal-header,
|
||||||
|
.insert-modal-header,
|
||||||
|
.gallery-overlay-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-bottom: 1px solid #3c3c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-header.media-header-only {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestions-modal-header h2,
|
||||||
|
.language-picker-modal-header h2,
|
||||||
|
.confirm-delete-modal-header h2,
|
||||||
|
.gallery-overlay-header h2,
|
||||||
|
.insert-modal-title,
|
||||||
|
.confirm-dialog h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestions-modal-close,
|
||||||
|
.confirm-delete-modal-close,
|
||||||
|
.gallery-overlay-close,
|
||||||
|
.shared-popover-close,
|
||||||
|
.lightbox-close {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #c5c5c5;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestions-modal-body,
|
||||||
|
.language-picker-modal-body,
|
||||||
|
.confirm-delete-modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestions-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-checkbox {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-checkbox input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkmark {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 2px solid #555555;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-checkbox input:checked + .checkmark,
|
||||||
|
.ai-suggestion-checkbox input:checked ~ .checkmark {
|
||||||
|
background: #0078d4;
|
||||||
|
border-color: #0078d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-checkbox input:checked + .checkmark::after,
|
||||||
|
.ai-suggestion-checkbox input:checked ~ .checkmark::after {
|
||||||
|
content: "✓";
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-has-value,
|
||||||
|
.language-picker-badge,
|
||||||
|
.insert-modal-similarity-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: #c5c5c5;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-comparison {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-column.muted {
|
||||||
|
color: #9d9d9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-column.highlighted {
|
||||||
|
border: 1px solid rgba(0, 122, 204, 0.4);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-column-label {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-arrow {
|
||||||
|
color: #9d9d9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-value {
|
||||||
|
min-height: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestion-value.loading {
|
||||||
|
color: var(--accent-color);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestions-error {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(220, 50, 50, 0.12);
|
||||||
|
border: 1px solid rgba(220, 50, 50, 0.35);
|
||||||
|
color: #ff6b6b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestions-modal-footer,
|
||||||
|
.confirm-delete-modal-footer,
|
||||||
|
.confirm-dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid #3c3c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-cancel,
|
||||||
|
.button-delete,
|
||||||
|
.button-apply,
|
||||||
|
.confirm-dialog-actions button,
|
||||||
|
.insert-modal-submit,
|
||||||
|
.language-picker-row,
|
||||||
|
.shared-popover-entry,
|
||||||
|
.colour-swatch {
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
margin: 0 -20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-tab {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: #9d9d9d;
|
||||||
|
padding: 10px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-tab.active {
|
||||||
|
color: #ffffff;
|
||||||
|
border-bottom-color: #0e639c;
|
||||||
|
background: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-search {
|
||||||
|
border-bottom: 1px solid #3c3c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-input,
|
||||||
|
.shared-popover-input {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: #f0f0f0;
|
||||||
|
padding: 14px 20px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
541
assets/css/panel.css
Normal file
541
assets/css/panel.css
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
.panel-shell {
|
||||||
|
min-height: 160px;
|
||||||
|
max-height: 160px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-shell.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tab {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-tab-inactiveForeground);
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tab:hover {
|
||||||
|
color: var(--vscode-tab-activeForeground);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-tab.active {
|
||||||
|
color: var(--vscode-tab-activeForeground);
|
||||||
|
border-bottom-color: var(--vscode-focusBorder);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-heading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-description,
|
||||||
|
.assistant-sidebar-context-text,
|
||||||
|
.assistant-sidebar-message-content {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-status {
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-status.is-offline {
|
||||||
|
background: rgba(255, 196, 0, 0.18);
|
||||||
|
border-color: rgba(255, 196, 0, 0.35);
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-context {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-context-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-context-label,
|
||||||
|
.assistant-sidebar-message-role {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-context-value {
|
||||||
|
text-align: right;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-context-text,
|
||||||
|
.assistant-sidebar-message-content {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-prompt-form,
|
||||||
|
.assistant-sidebar-welcome,
|
||||||
|
.assistant-sidebar-transcript {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-prompt {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
resize: vertical;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
padding: 10px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-prompt:focus {
|
||||||
|
outline: 1px solid var(--vscode-focusBorder);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-start-button {
|
||||||
|
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 {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-card,
|
||||||
|
.assistant-sidebar-message {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-message.user {
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-message.assistant {
|
||||||
|
background: var(--vscode-editorWidget-background, rgba(0, 0, 0, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
height: 22px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background-color: var(--vscode-statusBar-background);
|
||||||
|
color: var(--vscode-statusBar-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0 8px;
|
||||||
|
user-select: none;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-left,
|
||||||
|
.status-bar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 100%;
|
||||||
|
max-width: none;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-item .task-message-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-spinner {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-list,
|
||||||
|
.git-log-list {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-entry {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--vscode-sideBar-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-entry {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--vscode-sideBar-background);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.editor-frame {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-shell {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: var(--vscode-editor-background);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-content {
|
||||||
|
max-width: 720px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-content h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 400;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-content > .text-muted {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: 16px;
|
||||||
|
background-color: var(--vscode-sideBar-background);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-breakdown {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-tag {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-published {
|
||||||
|
color: var(--vscode-testing-iconPassed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-draft {
|
||||||
|
color: var(--vscode-editorWarning-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-archived {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-section {
|
||||||
|
background-color: var(--vscode-sideBar-background);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-section h4 {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-chart {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 4px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-bar-container {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-bar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 40px;
|
||||||
|
background-color: var(--vscode-activityBarBadge-background);
|
||||||
|
border-radius: 3px 3px 0 0;
|
||||||
|
margin-top: auto;
|
||||||
|
min-height: 4px;
|
||||||
|
position: relative;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-bar:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-bar-count {
|
||||||
|
position: absolute;
|
||||||
|
top: -16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-bar-label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-bar-label-month {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-bar-label-year {
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-cloud {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 10px;
|
||||||
|
align-items: baseline;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tag {
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
cursor: default;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tag:hover {
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tag.has-color {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-tag.has-color:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-cloud-more {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-count {
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.5;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-category {
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-posts-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-post-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-post-item:hover {
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-post-title {
|
||||||
|
flex: 1;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-post-status {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-post-status.status-published {
|
||||||
|
color: var(--vscode-testing-iconPassed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-post-status.status-draft {
|
||||||
|
color: var(--vscode-editorWarning-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-post-status.status-archived {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-post-date {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
807
assets/css/shell.css
Normal file
807
assets/css/shell.css
Normal file
@@ -0,0 +1,807 @@
|
|||||||
|
.app {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--vscode-editor-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar {
|
||||||
|
position: relative;
|
||||||
|
height: 34px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
background-color: var(--vscode-editorGroupHeader-tabsBackground);
|
||||||
|
border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder);
|
||||||
|
flex-shrink: 0;
|
||||||
|
app-region: drag;
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
padding-right: calc(10px + var(--bds-titlebar-overlay-right, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
margin-left: 6px;
|
||||||
|
gap: 2px;
|
||||||
|
app-region: no-drag;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-group {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-bar.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar.is-mac .window-titlebar-menu-bar {
|
||||||
|
margin-left: max(var(--bds-titlebar-macos-left-inset, 78px), calc(6px + var(--bds-titlebar-overlay-left, 0px)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-button {
|
||||||
|
height: 24px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-titleBar-activeForeground);
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-button:hover,
|
||||||
|
.window-titlebar-action-button:hover {
|
||||||
|
background-color: var(--vscode-toolbar-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-button.is-active {
|
||||||
|
background-color: var(--vscode-toolbar-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-button:focus,
|
||||||
|
.window-titlebar-menu-button:focus-visible,
|
||||||
|
.window-titlebar-action-button:focus,
|
||||||
|
.window-titlebar-action-button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 30px;
|
||||||
|
left: 0;
|
||||||
|
min-width: 210px;
|
||||||
|
padding: 6px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
background-color: var(--vscode-menu-background, var(--vscode-editorWidget-background));
|
||||||
|
border: 1px solid var(--vscode-menu-border, var(--vscode-panel-border));
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: var(--vscode-widget-shadow, 0 8px 24px rgba(0, 0, 0, 0.4));
|
||||||
|
app-region: no-drag;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-item {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-menu-foreground, var(--vscode-foreground));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-item:focus,
|
||||||
|
.window-titlebar-menu-item:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background-color: var(--vscode-toolbar-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-item:hover,
|
||||||
|
.window-titlebar-menu-item.is-keyboard-active {
|
||||||
|
background-color: var(--vscode-menu-selectionBackground, var(--vscode-toolbar-hoverBackground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-item-accelerator {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-menu-separator {
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 2px;
|
||||||
|
background-color: var(--vscode-menu-separatorBackground, rgba(255, 255, 255, 0.08));
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-drag-region {
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-title {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
max-width: 45%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--vscode-titleBar-activeForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
user-select: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-actions {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-right: 6px;
|
||||||
|
app-region: no-drag;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-action-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-sidebar-icon,
|
||||||
|
.window-titlebar-panel-icon,
|
||||||
|
.window-titlebar-assistant-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 1.5px solid currentColor;
|
||||||
|
border-radius: 2px;
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-sidebar-icon::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 33.3333%;
|
||||||
|
width: 1.5px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-panel-icon::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 66.6667%;
|
||||||
|
height: 1.5px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-assistant-icon::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 66.6667%;
|
||||||
|
width: 1.5px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-sidebar-pane,
|
||||||
|
.window-titlebar-panel-pane,
|
||||||
|
.window-titlebar-assistant-pane {
|
||||||
|
position: absolute;
|
||||||
|
background-color: currentColor;
|
||||||
|
transition: opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-sidebar-pane {
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 33.3333%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-panel-pane {
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 33.3333%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-assistant-pane {
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 33.3333%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-titlebar-sidebar-icon.is-inactive .window-titlebar-sidebar-pane,
|
||||||
|
.window-titlebar-panel-icon.is-inactive .window-titlebar-panel-pane,
|
||||||
|
.window-titlebar-assistant-icon.is-inactive .window-titlebar-assistant-pane {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-shell {
|
||||||
|
height: 200px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
background: var(--vscode-panel-background);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-shell.is-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar-button.is-destructive {
|
||||||
|
color: #f48771;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-overlay-backdrop,
|
||||||
|
.gallery-overlay-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.68);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shell-overlay-dismiss {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-overlay {
|
||||||
|
position: relative;
|
||||||
|
width: min(980px, calc(100vw - 48px));
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-media-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-media-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #252526;
|
||||||
|
color: inherit;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-media-thumb {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 112px;
|
||||||
|
border-radius: 6px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-media-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-picker-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-picker-option {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 28px 1fr auto;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-picker-label,
|
||||||
|
.language-picker-status,
|
||||||
|
.lightbox-counter {
|
||||||
|
color: #9d9d9d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-counter {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.insert-modal-media-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
height: 35px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 8px;
|
||||||
|
background-color: var(--vscode-sideBar-background);
|
||||||
|
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 {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 18px;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-close:hover {
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-entry,
|
||||||
|
.assistant-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-list,
|
||||||
|
.git-log-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-entry-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status {
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status-running {
|
||||||
|
color: var(--vscode-terminal-ansiGreen, var(--vscode-statusBar-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-status-pending {
|
||||||
|
color: var(--vscode-terminal-ansiYellow, var(--vscode-statusBar-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-empty-state {
|
||||||
|
min-height: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar {
|
||||||
|
height: 22px;
|
||||||
|
background: var(--vscode-statusBar-background);
|
||||||
|
color: var(--vscode-statusBar-foreground);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-left,
|
||||||
|
.status-bar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-left {
|
||||||
|
flex-shrink: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-shell-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-shell-toggle-button {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-shell-toggle-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-shell-toggle-button:focus,
|
||||||
|
.status-shell-toggle-button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-shell-toggle-button .window-titlebar-sidebar-icon,
|
||||||
|
.status-shell-toggle-button .window-titlebar-panel-icon,
|
||||||
|
.status-shell-toggle-button .window-titlebar-assistant-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-item:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-task-button {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-item.theme-badge {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-item.language-badge {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 3px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-item.offline-badge {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.4;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-item.offline-badge.active {
|
||||||
|
background-color: rgba(255, 196, 0, 0.28);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-selector {
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-selector-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
height: 22px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-statusBar-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-selector-trigger:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-selector-trigger:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-icon,
|
||||||
|
.dropdown-arrow,
|
||||||
|
.project-check-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name,
|
||||||
|
.project-item-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-name {
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-arrow {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 100%;
|
||||||
|
min-width: 220px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
background-color: #252526;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
z-index: 1000;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-dropdown-header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-list {
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item:hover,
|
||||||
|
.project-item.active {
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-item.active .project-check-icon {
|
||||||
|
color: #89d185;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-dropdown-footer {
|
||||||
|
padding: 8px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-project-btn,
|
||||||
|
.existing-project-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.12);
|
||||||
|
color: inherit;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.create-project-btn:hover,
|
||||||
|
.existing-project-btn:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-language-select {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-language-select:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-count {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-item.brand {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.editor-frame {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-meta {
|
||||||
|
border-left: none;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-section ul {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
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 {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-meta-card,
|
||||||
|
.assistant-card,
|
||||||
|
.panel-entry {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
1049
assets/css/sidebar.css
Normal file
1049
assets/css/sidebar.css
Normal file
File diff suppressed because it is too large
Load Diff
189
assets/css/tabs.css
Normal file
189
assets/css/tabs.css
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
.tab-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: var(--vscode-editorGroupHeader-tabsBackground);
|
||||||
|
border-bottom: 1px solid var(--vscode-editorGroupHeader-tabsBorder);
|
||||||
|
height: 35px;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar-tabs {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar-tabs::-webkit-scrollbar {
|
||||||
|
height: 0;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-bar-empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 6px 0 10px;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 100px;
|
||||||
|
max-width: 180px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--vscode-tab-inactiveBackground);
|
||||||
|
border: none;
|
||||||
|
border-right: 1px solid var(--vscode-tab-border);
|
||||||
|
color: var(--vscode-tab-inactiveForeground);
|
||||||
|
font-size: 13px;
|
||||||
|
user-select: none;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
cursor: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background-color: var(--vscode-tab-activeBackground);
|
||||||
|
color: var(--vscode-tab-activeForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.transient .tab-title {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-dirty-indicator {
|
||||||
|
color: var(--vscode-editorWarning-foreground, #e2c08d);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-title,
|
||||||
|
.status-bar-item {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--vscode-icon-foreground, #c5c5c5);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover .tab-close {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active .tab-close {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close:hover {
|
||||||
|
opacity: 1 !important;
|
||||||
|
background-color: var(--vscode-toolbar-hoverBackground);
|
||||||
|
color: var(--vscode-tab-activeForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-close:active {
|
||||||
|
background-color: var(--vscode-toolbar-activeBackground, rgba(99, 102, 103, 0.31));
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.dirty .tab-dirty-indicator {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.dirty .tab-close {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.dirty:hover .tab-close {
|
||||||
|
display: flex;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.dirty:hover .tab-dirty-indicator {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:focus-visible {
|
||||||
|
outline: 1px solid var(--vscode-focusBorder, #007fd4);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-item-details {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
color: inherit;
|
||||||
|
font: 11px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
|
||||||
166
assets/css/tokens.css
Normal file
166
assets/css/tokens.css
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
@theme {
|
||||||
|
--color-shell-bg: #1e1e1e;
|
||||||
|
--color-sidebar-bg: #252526;
|
||||||
|
--color-activity-bg: #333333;
|
||||||
|
--color-panel-bg: #1e1e1e;
|
||||||
|
--color-tab-active-bg: #1e1e1e;
|
||||||
|
--color-tab-inactive-bg: #2d2d2d;
|
||||||
|
--color-focus-border: #007fd4;
|
||||||
|
--color-input-bg: rgba(255, 255, 255, 0.06);
|
||||||
|
--color-input-border: rgba(255, 255, 255, 0.12);
|
||||||
|
--color-status-bg: #007acc;
|
||||||
|
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||||
|
--text-shell: 13px;
|
||||||
|
--spacing-titlebar: 34px;
|
||||||
|
--spacing-tabbar: 35px;
|
||||||
|
--spacing-statusbar: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--accent-color: #007acc;
|
||||||
|
--accent-color-transparent: rgba(0, 122, 204, 0.25);
|
||||||
|
--vscode-editor-background: #1e1e1e;
|
||||||
|
--vscode-editor-foreground: #cccccc;
|
||||||
|
--vscode-sideBar-background: #252526;
|
||||||
|
--vscode-activityBar-background: #333333;
|
||||||
|
--vscode-activityBar-foreground: #ffffff;
|
||||||
|
--vscode-panel-background: #1e1e1e;
|
||||||
|
--vscode-titleBar-activeBackground: #252526;
|
||||||
|
--vscode-titleBar-activeForeground: #cccccc;
|
||||||
|
--vscode-statusBar-background: #007acc;
|
||||||
|
--vscode-statusBar-foreground: #ffffff;
|
||||||
|
--vscode-tab-activeBackground: #1e1e1e;
|
||||||
|
--vscode-tab-inactiveBackground: #2d2d2d;
|
||||||
|
--vscode-tab-activeForeground: #ffffff;
|
||||||
|
--vscode-tab-inactiveForeground: #969696;
|
||||||
|
--vscode-editorGroupHeader-tabsBackground: #252526;
|
||||||
|
--vscode-editorGroupHeader-tabsBorder: #1e1e1e;
|
||||||
|
--vscode-toolbar-hoverBackground: rgba(90, 93, 94, 0.31);
|
||||||
|
--vscode-toolbar-activeBackground: rgba(99, 102, 103, 0.31);
|
||||||
|
--vscode-foreground: #cccccc;
|
||||||
|
--vscode-descriptionForeground: #858585;
|
||||||
|
--vscode-panel-border: #80808059;
|
||||||
|
--vscode-sideBar-border: #80808059;
|
||||||
|
--vscode-tab-border: #252526;
|
||||||
|
--vscode-focusBorder: #007fd4;
|
||||||
|
--vscode-input-background: rgba(255, 255, 255, 0.06);
|
||||||
|
--vscode-input-border: rgba(255, 255, 255, 0.12);
|
||||||
|
--vscode-list-hoverBackground: #2a2d2e;
|
||||||
|
--vscode-list-activeSelectionBackground: #094771;
|
||||||
|
--vscode-list-activeSelectionForeground: #ffffff;
|
||||||
|
--vscode-activityBarBadge-background: #007acc;
|
||||||
|
--vscode-activityBarBadge-foreground: #ffffff;
|
||||||
|
--vscode-testing-iconPassed: #73c991;
|
||||||
|
--vscode-editorWarning-foreground: #cca700;
|
||||||
|
--vscode-input-foreground: #cccccc;
|
||||||
|
--vscode-input-placeholderForeground: #a6a6a6;
|
||||||
|
--vscode-font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
--vscode-font-size: 13px;
|
||||||
|
--panel-1: var(--vscode-editor-background);
|
||||||
|
--panel-2: var(--vscode-sideBar-background);
|
||||||
|
--panel-3: var(--vscode-input-background);
|
||||||
|
--ink: var(--vscode-foreground);
|
||||||
|
--line: var(--vscode-panel-border);
|
||||||
|
--accent: var(--vscode-focusBorder);
|
||||||
|
--accent-soft: var(--vscode-list-hoverBackground);
|
||||||
|
--success: var(--vscode-testing-iconPassed);
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
--assistant-width: 360px;
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
font-family: var(--vscode-font-family);
|
||||||
|
font-size: var(--vscode-font-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > [data-phx-session],
|
||||||
|
body > [data-phx-main] {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: var(--vscode-font-family);
|
||||||
|
font-size: var(--vscode-font-size);
|
||||||
|
color: var(--vscode-button-foreground);
|
||||||
|
background-color: var(--vscode-button-background);
|
||||||
|
border: none;
|
||||||
|
padding: 6px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: var(--vscode-button-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus {
|
||||||
|
outline: 1px solid var(--vscode-focusBorder);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background-color: var(--vscode-button-secondaryBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary:hover {
|
||||||
|
background-color: #4a4d51;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.compact {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background-color: var(--vscode-button-background);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
background-color: var(--vscode-button-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
button.success {
|
||||||
|
background-color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.success:hover {
|
||||||
|
background-color: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger {
|
||||||
|
background-color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.danger:hover {
|
||||||
|
background-color: #c82333;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
button svg,
|
||||||
|
button svg * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
301
assets/css/utilities.css
Normal file
301
assets/css/utilities.css
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
@layer components {
|
||||||
|
.ui-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button:hover:not(:disabled) {
|
||||||
|
background: var(--vscode-button-hoverBackground, #0e639c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-primary {
|
||||||
|
color: var(--vscode-button-foreground, #ffffff);
|
||||||
|
background: var(--vscode-button-background, var(--vscode-focusBorder));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-primary:hover:not(:disabled) {
|
||||||
|
background: var(--vscode-button-hoverBackground, #0e639c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-secondary {
|
||||||
|
color: var(--vscode-button-secondaryForeground, var(--vscode-foreground));
|
||||||
|
background: var(--vscode-button-secondaryBackground, rgba(255, 255, 255, 0.08));
|
||||||
|
border-color: var(--vscode-button-border, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-secondary:hover:not(:disabled) {
|
||||||
|
background: var(--vscode-button-secondaryHoverBackground, #4a4d51);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-danger {
|
||||||
|
color: var(--vscode-errorForeground, #f48771);
|
||||||
|
background: transparent;
|
||||||
|
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-danger:hover:not(:disabled) {
|
||||||
|
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-compact {
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-input,
|
||||||
|
.ui-textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--vscode-input-border, var(--vscode-panel-border));
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background, rgba(255, 255, 255, 0.06));
|
||||||
|
color: var(--vscode-input-foreground, var(--vscode-foreground));
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-textarea {
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-input:focus,
|
||||||
|
.ui-textarea:focus {
|
||||||
|
outline: 1px solid var(--vscode-focusBorder, #007fd4);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-input-readonly,
|
||||||
|
.ui-input[readonly] {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-input-disabled,
|
||||||
|
.ui-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tab {
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-tab-inactiveForeground, var(--vscode-foreground));
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tab:hover {
|
||||||
|
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-tab-active {
|
||||||
|
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-panel-entry {
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--vscode-sideBar-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-editor-shell {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 35px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
background: var(--vscode-tab-activeBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-editor-tab-current {
|
||||||
|
display: inline-flex;
|
||||||
|
max-width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 4px 4px 0 0;
|
||||||
|
background: var(--vscode-tab-activeBackground);
|
||||||
|
color: var(--vscode-tab-activeForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-editor-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-toolbar-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field-stack > label,
|
||||||
|
.ui-field-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field-grid-2,
|
||||||
|
.ui-field-grid-3 {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-dropdown-menu {
|
||||||
|
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
|
||||||
|
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-dropdown-foreground, var(--vscode-foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-dropdown-item:hover:not(:disabled) {
|
||||||
|
background: var(--vscode-list-hoverBackground, #2a2d2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-dropdown-item:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-section-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));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-base {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
font: inherit;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-theme-primary {
|
||||||
|
color: var(--vscode-button-foreground, #ffffff);
|
||||||
|
background: var(--vscode-button-background, var(--vscode-focusBorder));
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-theme-primary:hover {
|
||||||
|
background: var(--vscode-button-hoverBackground, #0e639c);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-theme-danger {
|
||||||
|
color: var(--vscode-errorForeground, #f48771);
|
||||||
|
background: transparent;
|
||||||
|
border-color: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-theme-danger:hover {
|
||||||
|
background: color-mix(in srgb, var(--vscode-errorForeground, #f48771) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-entry {
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: var(--vscode-sideBar-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monaco-host {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.ui-field-grid-2 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-field-grid-3 {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
29
assets/js/app.js
Normal file
29
assets/js/app.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Socket } from "phoenix";
|
||||||
|
import { LiveSocket } from "phoenix_live_view";
|
||||||
|
import "phoenix_html";
|
||||||
|
import { Hooks } from "./hooks/index.js";
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const csrfToken = document
|
||||||
|
.querySelector("meta[name='csrf-token']")
|
||||||
|
.getAttribute("content");
|
||||||
|
|
||||||
|
const liveSocket = new LiveSocket("/live", Socket, {
|
||||||
|
params: { _csrf_token: csrfToken },
|
||||||
|
hooks: Hooks,
|
||||||
|
metadata: {
|
||||||
|
keydown: (event) => ({
|
||||||
|
key: event.key,
|
||||||
|
meta: event.metaKey,
|
||||||
|
ctrl: event.ctrlKey,
|
||||||
|
alt: event.altKey,
|
||||||
|
shift: event.shiftKey,
|
||||||
|
tag: event.target?.tagName || null,
|
||||||
|
contentEditable: event.target?.isContentEditable || false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
liveSocket.connect();
|
||||||
|
window.liveSocket = liveSocket;
|
||||||
|
});
|
||||||
19
assets/js/bridges/document_commands.js
Normal file
19
assets/js/bridges/document_commands.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { clamp } from "../utils/dom.js";
|
||||||
|
|
||||||
|
export const applyAppZoom = (nextZoom) => {
|
||||||
|
const zoom = clamp(Math.round(nextZoom * 100) / 100, 0.5, 2);
|
||||||
|
window.__bdsAppZoom = zoom;
|
||||||
|
document.documentElement.style.zoom = String(zoom);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runDocumentCommand = (command) => {
|
||||||
|
if (typeof document.execCommand !== "function") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return document.execCommand(command);
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
58
assets/js/bridges/menu_runtime.js
Normal file
58
assets/js/bridges/menu_runtime.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { activeMonacoEditor, runMonacoEditorAction } from "../monaco/services.js";
|
||||||
|
import { applyAppZoom, runDocumentCommand } from "./document_commands.js";
|
||||||
|
|
||||||
|
export const runMenuRuntimeCommand = (action) => {
|
||||||
|
const editor = activeMonacoEditor();
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case "undo":
|
||||||
|
return editor ? runMonacoEditorAction(editor, "undo") : runDocumentCommand("undo");
|
||||||
|
case "redo":
|
||||||
|
return editor ? runMonacoEditorAction(editor, "redo") : runDocumentCommand("redo");
|
||||||
|
case "cut":
|
||||||
|
return editor
|
||||||
|
? runMonacoEditorAction(editor, "editor.action.clipboardCutAction")
|
||||||
|
: runDocumentCommand("cut");
|
||||||
|
case "copy":
|
||||||
|
return editor
|
||||||
|
? runMonacoEditorAction(editor, "editor.action.clipboardCopyAction")
|
||||||
|
: runDocumentCommand("copy");
|
||||||
|
case "paste":
|
||||||
|
return editor
|
||||||
|
? runMonacoEditorAction(editor, "editor.action.clipboardPasteAction")
|
||||||
|
: runDocumentCommand("paste");
|
||||||
|
case "delete":
|
||||||
|
return editor ? runMonacoEditorAction(editor, "deleteLeft") : runDocumentCommand("delete");
|
||||||
|
case "select_all":
|
||||||
|
return editor
|
||||||
|
? runMonacoEditorAction(editor, "editor.action.selectAll")
|
||||||
|
: runDocumentCommand("selectAll");
|
||||||
|
case "find":
|
||||||
|
return editor ? runMonacoEditorAction(editor, "actions.find") : false;
|
||||||
|
case "replace":
|
||||||
|
return editor ? runMonacoEditorAction(editor, "editor.action.startFindReplaceAction") : false;
|
||||||
|
case "reload":
|
||||||
|
case "force_reload":
|
||||||
|
window.location.reload();
|
||||||
|
return true;
|
||||||
|
case "reset_zoom":
|
||||||
|
applyAppZoom(1);
|
||||||
|
return true;
|
||||||
|
case "zoom_in":
|
||||||
|
applyAppZoom((window.__bdsAppZoom || 1) + 0.1);
|
||||||
|
return true;
|
||||||
|
case "zoom_out":
|
||||||
|
applyAppZoom((window.__bdsAppZoom || 1) - 0.1);
|
||||||
|
return true;
|
||||||
|
case "toggle_full_screen":
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
document.exitFullscreen?.();
|
||||||
|
} else {
|
||||||
|
document.documentElement.requestFullscreen?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
39
assets/js/bridges/titlebar_overlay.js
Normal file
39
assets/js/bridges/titlebar_overlay.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
export const syncTitlebarOverlayInsets = () => {
|
||||||
|
const rootStyle = document.documentElement.style;
|
||||||
|
const setInsets = (left, right) => {
|
||||||
|
rootStyle.setProperty("--bds-titlebar-overlay-left", `${left}px`);
|
||||||
|
rootStyle.setProperty("--bds-titlebar-overlay-right", `${right}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const overlay = navigator.windowControlsOverlay;
|
||||||
|
|
||||||
|
if (!overlay) {
|
||||||
|
setInsets(0, 0);
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateInsets = () => {
|
||||||
|
if (!overlay.visible) {
|
||||||
|
setInsets(0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const titlebarRect = overlay.getTitlebarAreaRect();
|
||||||
|
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || titlebarRect.right;
|
||||||
|
const leftInset = Math.max(0, Math.round(titlebarRect.left));
|
||||||
|
const rightInset = Math.max(0, Math.round(viewportWidth - titlebarRect.right));
|
||||||
|
setInsets(leftInset, rightInset);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onGeometryChange = () => updateInsets();
|
||||||
|
const onResize = () => updateInsets();
|
||||||
|
|
||||||
|
updateInsets();
|
||||||
|
overlay.addEventListener("geometrychange", onGeometryChange);
|
||||||
|
window.addEventListener("resize", onResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
overlay.removeEventListener("geometrychange", onGeometryChange);
|
||||||
|
window.removeEventListener("resize", onResize);
|
||||||
|
};
|
||||||
|
};
|
||||||
4
assets/js/constants.js
Normal file
4
assets/js/constants.js
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export const SIDEBAR_STORAGE_KEY = "bds-panel-sidebar";
|
||||||
|
export const ASSISTANT_STORAGE_KEY = "bds-panel-assistant-sidebar";
|
||||||
|
export const UI_LANGUAGE_STORAGE_KEY = "bds-ui-language";
|
||||||
|
export const WORKBENCH_SESSION_STORAGE_KEY_PREFIX = "bds-workbench-";
|
||||||
232
assets/js/hooks/app_shell.js
Normal file
232
assets/js/hooks/app_shell.js
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import {
|
||||||
|
SIDEBAR_STORAGE_KEY,
|
||||||
|
ASSISTANT_STORAGE_KEY,
|
||||||
|
UI_LANGUAGE_STORAGE_KEY,
|
||||||
|
WORKBENCH_SESSION_STORAGE_KEY_PREFIX
|
||||||
|
} from "../constants.js";
|
||||||
|
import {
|
||||||
|
parseJsonObject,
|
||||||
|
setMediaThumbnailLoaded,
|
||||||
|
syncMediaThumbnailState,
|
||||||
|
clamp
|
||||||
|
} from "../utils/dom.js";
|
||||||
|
import { shellWidth, setShellWidth, persistWidth, readStoredSize } from "../utils/layout.js";
|
||||||
|
import {
|
||||||
|
parseShortcutConfig,
|
||||||
|
normalizeShortcutKey,
|
||||||
|
shortcutMatchesEvent,
|
||||||
|
shortcutTargetIsEditable
|
||||||
|
} from "../utils/shortcuts.js";
|
||||||
|
import { syncTitlebarOverlayInsets } from "../bridges/titlebar_overlay.js";
|
||||||
|
import { runMenuRuntimeCommand } from "../bridges/menu_runtime.js";
|
||||||
|
|
||||||
|
export const AppShell = {
|
||||||
|
mounted() {
|
||||||
|
this.shortcuts = parseShortcutConfig(this.el.dataset.shortcuts);
|
||||||
|
this.currentProjectId = this.el.dataset.projectId || "";
|
||||||
|
this.syncStoredLayout();
|
||||||
|
this.syncStoredUiLanguage();
|
||||||
|
this.destroyOverlaySync = syncTitlebarOverlayInsets();
|
||||||
|
|
||||||
|
this.workbenchStorageKey = (projectId) =>
|
||||||
|
projectId ? `${WORKBENCH_SESSION_STORAGE_KEY_PREFIX}${projectId}` : null;
|
||||||
|
|
||||||
|
this.restoreStoredWorkbenchSession = () => {
|
||||||
|
const projectId = this.el.dataset.projectId || "";
|
||||||
|
const storageKey = this.workbenchStorageKey(projectId);
|
||||||
|
|
||||||
|
if (!storageKey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = parseJsonObject(window.localStorage.getItem(storageKey));
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pushEvent("restore_workbench_session", { session });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.persistWorkbenchSession = () => {
|
||||||
|
const projectId = this.el.dataset.projectId || "";
|
||||||
|
const storageKey = this.workbenchStorageKey(projectId);
|
||||||
|
const session = this.el.dataset.workbenchSession;
|
||||||
|
|
||||||
|
if (!storageKey || !session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.localStorage.setItem(storageKey, session);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleMouseDown = (event) => {
|
||||||
|
const handle = event.target.closest("[data-role='resize-handle']");
|
||||||
|
|
||||||
|
if (!handle || !this.el.contains(handle)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const target = handle.dataset.resize;
|
||||||
|
const startX = event.clientX;
|
||||||
|
const startWidth =
|
||||||
|
target === "assistant"
|
||||||
|
? shellWidth("[data-testid='assistant-shell']")
|
||||||
|
: shellWidth("[data-testid='sidebar-shell']");
|
||||||
|
|
||||||
|
const min = target === "assistant" ? 280 : 200;
|
||||||
|
const max = target === "assistant" ? 640 : 500;
|
||||||
|
const invert = target === "assistant";
|
||||||
|
|
||||||
|
const onMouseMove = (moveEvent) => {
|
||||||
|
const delta = invert ? startX - moveEvent.clientX : moveEvent.clientX - startX;
|
||||||
|
const width = clamp(startWidth + delta, min, max);
|
||||||
|
const selector = target === "assistant" ? "[data-testid='assistant-shell']" : "[data-testid='sidebar-shell']";
|
||||||
|
|
||||||
|
setShellWidth(selector, width);
|
||||||
|
persistWidth(target, width);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMouseUp = (upEvent) => {
|
||||||
|
const delta = invert ? startX - upEvent.clientX : upEvent.clientX - startX;
|
||||||
|
const width = clamp(startWidth + delta, min, max);
|
||||||
|
|
||||||
|
persistWidth(target, width);
|
||||||
|
this.pushEvent("resize_panel", { target, width });
|
||||||
|
|
||||||
|
window.removeEventListener("mousemove", onMouseMove);
|
||||||
|
window.removeEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("mousemove", onMouseMove);
|
||||||
|
window.addEventListener("mouseup", onMouseUp);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.el.addEventListener("mousedown", this.handleMouseDown);
|
||||||
|
|
||||||
|
this.handleNativeMenuAction = (event) => {
|
||||||
|
const action = event.detail?.action;
|
||||||
|
const ackId = event.detail?.ackId;
|
||||||
|
|
||||||
|
if (action) {
|
||||||
|
this.pushEvent("native_menu_action", { action }, () => {
|
||||||
|
if (ackId) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("bds:native-menu-action-ack", { detail: { ackId } })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleChange = (event) => {
|
||||||
|
const select = event.target.closest(".status-bar-language-select");
|
||||||
|
|
||||||
|
if (select && this.el.contains(select)) {
|
||||||
|
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, select.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleShortcutKeyDown = (event) => {
|
||||||
|
if (shortcutTargetIsEditable(event)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortcut = this.shortcuts.find((candidate) => shortcutMatchesEvent(candidate, event));
|
||||||
|
|
||||||
|
if (!shortcut) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
this.pushEvent("shortcut", {
|
||||||
|
key: normalizeShortcutKey(event.key),
|
||||||
|
meta: event.metaKey,
|
||||||
|
ctrl: event.ctrlKey,
|
||||||
|
alt: event.altKey,
|
||||||
|
shift: event.shiftKey,
|
||||||
|
tag: event.target?.tagName || null,
|
||||||
|
contentEditable: event.target?.isContentEditable || false
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleThumbnailLoad = (event) => {
|
||||||
|
if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) {
|
||||||
|
setMediaThumbnailLoaded(event.target, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleThumbnailError = (event) => {
|
||||||
|
if (event.target instanceof HTMLImageElement && event.target.classList.contains("media-thumbnail-image")) {
|
||||||
|
setMediaThumbnailLoaded(event.target, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleEvent("menu-runtime-command", ({ action }) => {
|
||||||
|
if (action) {
|
||||||
|
runMenuRuntimeCommand(String(action));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.handleEvent("url-state", ({ path }) => {
|
||||||
|
if (path && window.location.pathname + window.location.search !== path) {
|
||||||
|
window.history.replaceState({}, "", path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("bds:native-menu-action", this.handleNativeMenuAction);
|
||||||
|
window.addEventListener("keydown", this.handleShortcutKeyDown, true);
|
||||||
|
this.el.addEventListener("load", this.handleThumbnailLoad, true);
|
||||||
|
this.el.addEventListener("error", this.handleThumbnailError, true);
|
||||||
|
this.el.addEventListener("change", this.handleChange);
|
||||||
|
syncMediaThumbnailState(this.el);
|
||||||
|
this.restoreStoredWorkbenchSession();
|
||||||
|
},
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
const nextProjectId = this.el.dataset.projectId || "";
|
||||||
|
|
||||||
|
if (nextProjectId !== this.currentProjectId) {
|
||||||
|
this.currentProjectId = nextProjectId;
|
||||||
|
|
||||||
|
if (this.restoreStoredWorkbenchSession()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
syncMediaThumbnailState(this.el);
|
||||||
|
this.persistWorkbenchSession();
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
this.el.removeEventListener("mousedown", this.handleMouseDown);
|
||||||
|
this.el.removeEventListener("load", this.handleThumbnailLoad, true);
|
||||||
|
this.el.removeEventListener("error", this.handleThumbnailError, true);
|
||||||
|
this.el.removeEventListener("change", this.handleChange);
|
||||||
|
window.removeEventListener("bds:native-menu-action", this.handleNativeMenuAction);
|
||||||
|
window.removeEventListener("keydown", this.handleShortcutKeyDown, true);
|
||||||
|
if (this.destroyOverlaySync) {
|
||||||
|
this.destroyOverlaySync();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
syncStoredLayout() {
|
||||||
|
this.pushEvent("sync_layout", {
|
||||||
|
sidebar_width: readStoredSize(SIDEBAR_STORAGE_KEY, shellWidth("[data-testid='sidebar-shell']"), 200, 500),
|
||||||
|
assistant_sidebar_width: readStoredSize(ASSISTANT_STORAGE_KEY, 360, 280, 640)
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
syncStoredUiLanguage() {
|
||||||
|
const stored = window.localStorage.getItem(UI_LANGUAGE_STORAGE_KEY);
|
||||||
|
|
||||||
|
if (stored) {
|
||||||
|
this.pushEvent("sync_ui_language", { language: stored });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
139
assets/js/hooks/chat_surface.js
Normal file
139
assets/js/hooks/chat_surface.js
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
export const ChatSurface = {
|
||||||
|
mounted() {
|
||||||
|
this.stickToBottom = true;
|
||||||
|
this.scrollContainer = null;
|
||||||
|
|
||||||
|
this.autoResize = () => {
|
||||||
|
const textarea = this.el.querySelector(".chat-input");
|
||||||
|
|
||||||
|
if (!textarea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = getComputedStyle(textarea);
|
||||||
|
const minHeight = parseFloat(styles.getPropertyValue("--chat-input-min-height")) || 20;
|
||||||
|
const maxHeight = parseFloat(styles.getPropertyValue("--chat-input-max-height")) || 160;
|
||||||
|
|
||||||
|
textarea.rows = 1;
|
||||||
|
textarea.style.minHeight = `${minHeight}px`;
|
||||||
|
|
||||||
|
if (textarea.value.trim() === "") {
|
||||||
|
textarea.style.height = `${minHeight}px`;
|
||||||
|
textarea.style.maxHeight = `${minHeight}px`;
|
||||||
|
textarea.style.overflowY = "hidden";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.style.maxHeight = `${maxHeight}px`;
|
||||||
|
textarea.style.height = "0px";
|
||||||
|
const nextHeight = Math.min(Math.max(textarea.scrollHeight, minHeight), maxHeight);
|
||||||
|
textarea.style.height = `${nextHeight}px`;
|
||||||
|
textarea.style.overflowY = nextHeight >= maxHeight ? "auto" : "hidden";
|
||||||
|
};
|
||||||
|
|
||||||
|
this.syncScrollContainer = () => {
|
||||||
|
const nextContainer = this.el.querySelector(".chat-messages");
|
||||||
|
|
||||||
|
if (nextContainer === this.scrollContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.scrollContainer) {
|
||||||
|
this.scrollContainer.removeEventListener("scroll", this.handleScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scrollContainer = nextContainer;
|
||||||
|
|
||||||
|
if (this.scrollContainer) {
|
||||||
|
this.scrollContainer.addEventListener("scroll", this.handleScroll);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.scrollToBottom = (force = false) => {
|
||||||
|
if (!this.scrollContainer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (force || this.stickToBottom) {
|
||||||
|
this.scrollContainer.scrollTop = this.scrollContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.syncExpandedSurfaces = () => {
|
||||||
|
this.el
|
||||||
|
.querySelectorAll(".chat-inline-surface[data-expanded='true']")
|
||||||
|
.forEach((surface) => {
|
||||||
|
surface.open = true;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.surfaceObserver = new MutationObserver(() => {
|
||||||
|
this.syncExpandedSurfaces();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.handleScroll = () => {
|
||||||
|
if (!this.scrollContainer) {
|
||||||
|
this.stickToBottom = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const distanceFromBottom =
|
||||||
|
this.scrollContainer.scrollHeight -
|
||||||
|
this.scrollContainer.scrollTop -
|
||||||
|
this.scrollContainer.clientHeight;
|
||||||
|
|
||||||
|
this.stickToBottom = distanceFromBottom < 48;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleInput = (event) => {
|
||||||
|
if (!event.target.closest(".chat-input")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.stickToBottom = true;
|
||||||
|
this.autoResize();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleKeyDown = (event) => {
|
||||||
|
if (!event.target.closest(".chat-input")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const sendButton = this.el.querySelector("[data-testid='chat-send-button']");
|
||||||
|
|
||||||
|
if (sendButton && !sendButton.disabled) {
|
||||||
|
sendButton.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.el.addEventListener("input", this.handleInput);
|
||||||
|
this.el.addEventListener("keydown", this.handleKeyDown);
|
||||||
|
|
||||||
|
this.syncScrollContainer();
|
||||||
|
this.syncExpandedSurfaces();
|
||||||
|
this.surfaceObserver.observe(this.el, { childList: true, subtree: true });
|
||||||
|
this.autoResize();
|
||||||
|
window.requestAnimationFrame(() => this.scrollToBottom(true));
|
||||||
|
},
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
this.syncScrollContainer();
|
||||||
|
this.syncExpandedSurfaces();
|
||||||
|
this.autoResize();
|
||||||
|
window.requestAnimationFrame(() => this.scrollToBottom());
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
this.surfaceObserver.disconnect();
|
||||||
|
this.el.removeEventListener("input", this.handleInput);
|
||||||
|
this.el.removeEventListener("keydown", this.handleKeyDown);
|
||||||
|
|
||||||
|
if (this.scrollContainer) {
|
||||||
|
this.scrollContainer.removeEventListener("scroll", this.handleScroll);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
18
assets/js/hooks/index.js
Normal file
18
assets/js/hooks/index.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { AppShell } from "./app_shell.js";
|
||||||
|
import { SidebarInteractions } from "./sidebar_interactions.js";
|
||||||
|
import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js";
|
||||||
|
import { ChatSurface } from "./chat_surface.js";
|
||||||
|
import { MenuEditorTree } from "./menu_editor_tree.js";
|
||||||
|
import { MonacoEditor } from "./monaco_editor.js";
|
||||||
|
import { MonacoDiffEditor } from "./monaco_diff_editor.js";
|
||||||
|
|
||||||
|
export const Hooks = {
|
||||||
|
AppShell,
|
||||||
|
SidebarInteractions,
|
||||||
|
SettingsSectionScroll,
|
||||||
|
TagsSectionScroll,
|
||||||
|
ChatSurface,
|
||||||
|
MenuEditorTree,
|
||||||
|
MonacoEditor,
|
||||||
|
MonacoDiffEditor
|
||||||
|
};
|
||||||
134
assets/js/hooks/menu_editor_tree.js
Normal file
134
assets/js/hooks/menu_editor_tree.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
export const MenuEditorTree = {
|
||||||
|
mounted() {
|
||||||
|
this.dragItemId = null;
|
||||||
|
this.dragSourceEl = null;
|
||||||
|
this.dropTargetEl = null;
|
||||||
|
this.dropPosition = null;
|
||||||
|
|
||||||
|
this.clearDropTarget = () => {
|
||||||
|
if (this.dropTargetEl) {
|
||||||
|
this.dropTargetEl.classList.remove("is-drop-before", "is-drop-after", "is-drop-inside");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dropTargetEl = null;
|
||||||
|
this.dropPosition = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setDropTarget = (row, position) => {
|
||||||
|
if (this.dropTargetEl === row && this.dropPosition === position) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearDropTarget();
|
||||||
|
this.dropTargetEl = row;
|
||||||
|
this.dropPosition = position;
|
||||||
|
row.classList.add(`is-drop-${position}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleDragStart = (event) => {
|
||||||
|
const handle = event.target.closest("[data-menu-drag-handle='true']");
|
||||||
|
const row = event.target.closest("[data-menu-item-id]");
|
||||||
|
|
||||||
|
if (!handle || !row || !this.el.contains(row)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dragItemId = row.dataset.menuItemId || null;
|
||||||
|
this.dragSourceEl = row;
|
||||||
|
row.classList.add("is-dragging");
|
||||||
|
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.effectAllowed = "move";
|
||||||
|
event.dataTransfer.setData("text/plain", this.dragItemId || "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleDragOver = (event) => {
|
||||||
|
const row = event.target.closest("[data-menu-item-id]");
|
||||||
|
|
||||||
|
if (!this.dragItemId || !row || !this.el.contains(row)) {
|
||||||
|
this.clearDropTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetItemId = row.dataset.menuItemId || "";
|
||||||
|
|
||||||
|
if (!targetItemId || targetItemId === this.dragItemId) {
|
||||||
|
this.clearDropTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const rect = row.getBoundingClientRect();
|
||||||
|
const offsetY = event.clientY - rect.top;
|
||||||
|
const allowInside = row.dataset.menuCanDropInside === "true";
|
||||||
|
const insideBandTop = rect.height * 0.3;
|
||||||
|
const insideBandBottom = rect.height * 0.7;
|
||||||
|
|
||||||
|
const position =
|
||||||
|
allowInside && offsetY >= insideBandTop && offsetY <= insideBandBottom
|
||||||
|
? "inside"
|
||||||
|
: offsetY < rect.height / 2
|
||||||
|
? "before"
|
||||||
|
: "after";
|
||||||
|
|
||||||
|
this.setDropTarget(row, position);
|
||||||
|
|
||||||
|
if (event.dataTransfer) {
|
||||||
|
event.dataTransfer.dropEffect = "move";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleDrop = (event) => {
|
||||||
|
const row = event.target.closest("[data-menu-item-id]");
|
||||||
|
|
||||||
|
if (!this.dragItemId || !row || !this.el.contains(row) || !this.dropPosition) {
|
||||||
|
this.clearDropTarget();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
this.pushEvent("menu_editor_drop_item", {
|
||||||
|
drag_item_id: this.dragItemId,
|
||||||
|
target_item_id: row.dataset.menuItemId,
|
||||||
|
position: this.dropPosition
|
||||||
|
});
|
||||||
|
|
||||||
|
this.clearDropTarget();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleDragLeave = (event) => {
|
||||||
|
const related = event.relatedTarget;
|
||||||
|
|
||||||
|
if (this.dropTargetEl && (!related || !this.dropTargetEl.contains(related))) {
|
||||||
|
this.clearDropTarget();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleDragEnd = () => {
|
||||||
|
if (this.dragSourceEl) {
|
||||||
|
this.dragSourceEl.classList.remove("is-dragging");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dragItemId = null;
|
||||||
|
this.dragSourceEl = null;
|
||||||
|
this.clearDropTarget();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.el.addEventListener("dragstart", this.handleDragStart);
|
||||||
|
this.el.addEventListener("dragover", this.handleDragOver);
|
||||||
|
this.el.addEventListener("drop", this.handleDrop);
|
||||||
|
this.el.addEventListener("dragleave", this.handleDragLeave);
|
||||||
|
this.el.addEventListener("dragend", this.handleDragEnd);
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
this.el.removeEventListener("dragstart", this.handleDragStart);
|
||||||
|
this.el.removeEventListener("dragover", this.handleDragOver);
|
||||||
|
this.el.removeEventListener("drop", this.handleDrop);
|
||||||
|
this.el.removeEventListener("dragleave", this.handleDragLeave);
|
||||||
|
this.el.removeEventListener("dragend", this.handleDragEnd);
|
||||||
|
}
|
||||||
|
};
|
||||||
129
assets/js/hooks/monaco_diff_editor.js
Normal file
129
assets/js/hooks/monaco_diff_editor.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { loadMonaco, ensureMonacoTheme, diffModelPath } from "../monaco/services.js";
|
||||||
|
|
||||||
|
export const MonacoDiffEditor = {
|
||||||
|
mounted() {
|
||||||
|
this.host = this.el.querySelector(".monaco-diff-editor-instance");
|
||||||
|
this.originalInput = this.el.querySelector(".monaco-diff-original");
|
||||||
|
this.modifiedInput = this.el.querySelector(".monaco-diff-modified");
|
||||||
|
this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree";
|
||||||
|
this.language = this.el.dataset.monacoDiffLanguage || "plaintext";
|
||||||
|
this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline";
|
||||||
|
this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off";
|
||||||
|
this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true";
|
||||||
|
|
||||||
|
this.readValues = () => ({
|
||||||
|
original: this.originalInput?.value || "",
|
||||||
|
modified: this.modifiedInput?.value || ""
|
||||||
|
});
|
||||||
|
|
||||||
|
this.applyDataset = () => {
|
||||||
|
this.filePath = this.el.dataset.monacoDiffFilePath || "working-tree";
|
||||||
|
this.language = this.el.dataset.monacoDiffLanguage || "plaintext";
|
||||||
|
this.viewStyle = this.el.dataset.monacoDiffViewStyle || "inline";
|
||||||
|
this.wordWrap = this.el.dataset.monacoDiffWordWrap || "off";
|
||||||
|
this.hideUnchanged = this.el.dataset.monacoDiffHideUnchanged === "true";
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setModels = (monaco) => {
|
||||||
|
const values = this.readValues();
|
||||||
|
|
||||||
|
this.originalModel?.dispose();
|
||||||
|
this.modifiedModel?.dispose();
|
||||||
|
|
||||||
|
this.originalModel = monaco.editor.createModel(
|
||||||
|
values.original,
|
||||||
|
this.language,
|
||||||
|
monaco.Uri.parse(diffModelPath(this.filePath, "original"))
|
||||||
|
);
|
||||||
|
|
||||||
|
this.modifiedModel = monaco.editor.createModel(
|
||||||
|
values.modified,
|
||||||
|
this.language,
|
||||||
|
monaco.Uri.parse(diffModelPath(this.filePath, "modified"))
|
||||||
|
);
|
||||||
|
|
||||||
|
this.editor.setModel({ original: this.originalModel, modified: this.modifiedModel });
|
||||||
|
this.lastFilePath = this.filePath;
|
||||||
|
};
|
||||||
|
|
||||||
|
loadMonaco()
|
||||||
|
.then((monaco) => {
|
||||||
|
if (!this.host) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureMonacoTheme(monaco);
|
||||||
|
|
||||||
|
this.editor = monaco.editor.createDiffEditor(this.host, {
|
||||||
|
theme: "bds-theme",
|
||||||
|
automaticLayout: true,
|
||||||
|
readOnly: true,
|
||||||
|
renderSideBySide: this.viewStyle === "side-by-side",
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
lineNumbers: "on",
|
||||||
|
diffCodeLens: false,
|
||||||
|
originalEditable: false,
|
||||||
|
wordWrap: this.wordWrap,
|
||||||
|
hideUnchangedRegions: { enabled: this.hideUnchanged },
|
||||||
|
ignoreTrimWhitespace: false
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setModels(monaco);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to load Monaco diff editor", error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
this.host = this.el.querySelector(".monaco-diff-editor-instance");
|
||||||
|
this.originalInput = this.el.querySelector(".monaco-diff-original");
|
||||||
|
this.modifiedInput = this.el.querySelector(".monaco-diff-modified");
|
||||||
|
this.applyDataset();
|
||||||
|
|
||||||
|
if (!this.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMonaco().then((monaco) => {
|
||||||
|
ensureMonacoTheme(monaco);
|
||||||
|
monaco.editor.setTheme("bds-theme");
|
||||||
|
|
||||||
|
this.editor.updateOptions({
|
||||||
|
renderSideBySide: this.viewStyle === "side-by-side",
|
||||||
|
wordWrap: this.wordWrap,
|
||||||
|
hideUnchangedRegions: { enabled: this.hideUnchanged }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.lastFilePath !== this.filePath) {
|
||||||
|
this.setModels(monaco);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const values = this.readValues();
|
||||||
|
|
||||||
|
if (this.originalModel && this.originalModel.getLanguageId() !== this.language) {
|
||||||
|
monaco.editor.setModelLanguage(this.originalModel, this.language);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.modifiedModel && this.modifiedModel.getLanguageId() !== this.language) {
|
||||||
|
monaco.editor.setModelLanguage(this.modifiedModel, this.language);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.originalModel && this.originalModel.getValue() !== values.original) {
|
||||||
|
this.originalModel.setValue(values.original);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.modifiedModel && this.modifiedModel.getValue() !== values.modified) {
|
||||||
|
this.modifiedModel.setValue(values.modified);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
this.originalModel?.dispose();
|
||||||
|
this.modifiedModel?.dispose();
|
||||||
|
this.editor?.dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
238
assets/js/hooks/monaco_editor.js
Normal file
238
assets/js/hooks/monaco_editor.js
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
import {
|
||||||
|
loadMonaco,
|
||||||
|
ensureMonacoTheme,
|
||||||
|
registerMonacoEditor,
|
||||||
|
unregisterMonacoEditor
|
||||||
|
} from "../monaco/services.js";
|
||||||
|
|
||||||
|
export const MonacoEditor = {
|
||||||
|
mounted() {
|
||||||
|
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
||||||
|
this.host = this.el.querySelector(".monaco-editor-instance");
|
||||||
|
this.language = this.el.dataset.monacoLanguage || "plaintext";
|
||||||
|
this.wordWrap = this.el.dataset.monacoWordWrap || "off";
|
||||||
|
this.editorId = this.el.dataset.monacoEditorId || "";
|
||||||
|
this.insertEvent = this.el.dataset.monacoInsertEvent || "";
|
||||||
|
this.syncTimer = null;
|
||||||
|
this.isApplyingRemoteUpdate = false;
|
||||||
|
this.lastKnownValue = this.textarea?.value || "";
|
||||||
|
|
||||||
|
this.syncEditorFromTextarea = () => {
|
||||||
|
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
||||||
|
|
||||||
|
if (!this.textarea || !this.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = this.textarea.value || "";
|
||||||
|
|
||||||
|
if (this.editor.getValue() !== value) {
|
||||||
|
this.isApplyingRemoteUpdate = true;
|
||||||
|
this.editor.setValue(value);
|
||||||
|
this.isApplyingRemoteUpdate = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastKnownValue = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
this.layoutEditorSoon = () => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (!this.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editor.layout();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.waitForMonacoVisibleSize = () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let attempts = 0;
|
||||||
|
|
||||||
|
const hasVisibleSize = () => {
|
||||||
|
const rect = this.host?.getBoundingClientRect();
|
||||||
|
return Boolean(rect && rect.width > 0 && rect.height > 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = () => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
this.visibleSizeObserver?.disconnect();
|
||||||
|
this.visibleSizeObserver = null;
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
if (hasVisibleSize() || attempts >= 20) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts += 1;
|
||||||
|
window.requestAnimationFrame(check);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasVisibleSize()) {
|
||||||
|
finish();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.ResizeObserver && this.host) {
|
||||||
|
this.visibleSizeObserver = new ResizeObserver(() => {
|
||||||
|
if (hasVisibleSize()) {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.visibleSizeObserver.observe(this.host);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.requestAnimationFrame(check);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.queueSync = () => {
|
||||||
|
if (!this.textarea || !this.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearTimeout(this.syncTimer);
|
||||||
|
this.syncTimer = window.setTimeout(() => {
|
||||||
|
if (!this.textarea || !this.editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = this.editor.getValue();
|
||||||
|
|
||||||
|
if (this.textarea.value === value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastKnownValue = value;
|
||||||
|
this.textarea.value = value;
|
||||||
|
this.textarea.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
}, 120);
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleInsert = ({ id, content }) => {
|
||||||
|
if (!this.editor || !content || String(id) !== String(this.editorId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = this.editor.getModel();
|
||||||
|
const selection = this.editor.getSelection();
|
||||||
|
|
||||||
|
if (!model || !selection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = this.editor.getValue();
|
||||||
|
const start = model.getOffsetAt(selection.getStartPosition());
|
||||||
|
const end = model.getOffsetAt(selection.getEndPosition());
|
||||||
|
const before = value.slice(0, start);
|
||||||
|
const after = value.slice(end);
|
||||||
|
const separator = before !== "" && !before.endsWith("\n") ? "\n" : "";
|
||||||
|
const suffix = after !== "" && !content.endsWith("\n") ? "\n" : "";
|
||||||
|
const inserted = `${separator}${content}${suffix}`;
|
||||||
|
this.editor.executeEdits("bds-insert-content", [
|
||||||
|
{
|
||||||
|
range: selection,
|
||||||
|
text: inserted,
|
||||||
|
forceMoveMarkers: true
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
this.editor.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
loadMonaco()
|
||||||
|
.then(async (monaco) => {
|
||||||
|
if (!this.host || !this.textarea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.waitForMonacoVisibleSize();
|
||||||
|
|
||||||
|
ensureMonacoTheme(monaco);
|
||||||
|
|
||||||
|
this.editor = monaco.editor.create(this.host, {
|
||||||
|
value: this.textarea.value || "",
|
||||||
|
language: this.language,
|
||||||
|
theme: "bds-theme",
|
||||||
|
automaticLayout: true,
|
||||||
|
minimap: { enabled: false },
|
||||||
|
scrollBeyondLastLine: false,
|
||||||
|
wordWrap: this.wordWrap,
|
||||||
|
lineNumbers: "on",
|
||||||
|
lineNumbersMinChars: 3,
|
||||||
|
fontSize: 14,
|
||||||
|
fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace",
|
||||||
|
padding: { top: 12, bottom: 12 },
|
||||||
|
roundedSelection: false,
|
||||||
|
renderLineHighlight: "line",
|
||||||
|
formatOnPaste: true,
|
||||||
|
cursorStyle: "line",
|
||||||
|
cursorBlinking: "smooth",
|
||||||
|
quickSuggestions: this.language === "markdown-with-macros" ? false : true,
|
||||||
|
tabSize: 2,
|
||||||
|
insertSpaces: true
|
||||||
|
});
|
||||||
|
|
||||||
|
registerMonacoEditor(this.editorId || this.el.id, this.editor);
|
||||||
|
monaco.editor.setTheme("bds-theme");
|
||||||
|
this.syncEditorFromTextarea();
|
||||||
|
this.layoutEditorSoon();
|
||||||
|
|
||||||
|
this.changeSubscription = this.editor.onDidChangeModelContent(() => {
|
||||||
|
if (this.isApplyingRemoteUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queueSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.insertEvent) {
|
||||||
|
this.handleEvent(this.insertEvent, this.handleInsert);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to load Monaco editor", error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
this.textarea = document.getElementById(this.el.dataset.monacoInputId) || this.el.querySelector("textarea");
|
||||||
|
this.host = this.el.querySelector(".monaco-editor-instance");
|
||||||
|
this.language = this.el.dataset.monacoLanguage || this.language || "plaintext";
|
||||||
|
this.wordWrap = this.el.dataset.monacoWordWrap || this.wordWrap || "off";
|
||||||
|
|
||||||
|
if (!this.editor || !this.textarea) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadMonaco().then((monaco) => {
|
||||||
|
ensureMonacoTheme(monaco);
|
||||||
|
monaco.editor.setTheme("bds-theme");
|
||||||
|
|
||||||
|
if (this.editor.getModel()?.getLanguageId() !== this.language) {
|
||||||
|
monaco.editor.setModelLanguage(this.editor.getModel(), this.language);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.editor.updateOptions({ wordWrap: this.wordWrap });
|
||||||
|
});
|
||||||
|
|
||||||
|
this.syncEditorFromTextarea();
|
||||||
|
this.layoutEditorSoon();
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
window.clearTimeout(this.syncTimer);
|
||||||
|
this.visibleSizeObserver?.disconnect();
|
||||||
|
this.changeSubscription?.dispose();
|
||||||
|
unregisterMonacoEditor(this.editorId || this.el.id);
|
||||||
|
this.editor?.dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
31
assets/js/hooks/section_scroll.js
Normal file
31
assets/js/hooks/section_scroll.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const makeSectionScrollHook = (datasetKey) => ({
|
||||||
|
mounted() {
|
||||||
|
this.lastTargetId = null;
|
||||||
|
this.scrollToSelectedSection();
|
||||||
|
},
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
this.scrollToSelectedSection();
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollToSelectedSection() {
|
||||||
|
const targetId = this.el.dataset[datasetKey];
|
||||||
|
|
||||||
|
if (!targetId || targetId === this.lastTargetId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastTargetId = targetId;
|
||||||
|
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const target = document.getElementById(targetId);
|
||||||
|
|
||||||
|
if (target && this.el.contains(target)) {
|
||||||
|
target.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SettingsSectionScroll = makeSectionScrollHook("settingsScrollTarget");
|
||||||
|
export const TagsSectionScroll = makeSectionScrollHook("tagsScrollTarget");
|
||||||
24
assets/js/hooks/sidebar_interactions.js
Normal file
24
assets/js/hooks/sidebar_interactions.js
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export const SidebarInteractions = {
|
||||||
|
mounted() {
|
||||||
|
this.handleDblClick = (event) => {
|
||||||
|
const button = event.target.closest("[data-testid='sidebar-open-item']");
|
||||||
|
|
||||||
|
if (!button || !this.el.contains(button)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pushEvent("pin_sidebar_item", {
|
||||||
|
route: button.dataset.route,
|
||||||
|
id: button.dataset.itemId,
|
||||||
|
title: button.dataset.openTitle || "",
|
||||||
|
subtitle: button.dataset.openSubtitle || ""
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.el.addEventListener("dblclick", this.handleDblClick);
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
this.el.removeEventListener("dblclick", this.handleDblClick);
|
||||||
|
}
|
||||||
|
};
|
||||||
145
assets/js/monaco/languages.js
Normal file
145
assets/js/monaco/languages.js
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
let liquidLanguageRegistered = false;
|
||||||
|
let markdownWithMacrosRegistered = false;
|
||||||
|
|
||||||
|
export const registerLiquidLanguage = (monaco) => {
|
||||||
|
if (liquidLanguageRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
monaco.languages.register({ id: "liquid" });
|
||||||
|
monaco.languages.setLanguageConfiguration("liquid", {
|
||||||
|
comments: {
|
||||||
|
blockComment: ["{% comment %}", "{% endcomment %}"]
|
||||||
|
},
|
||||||
|
brackets: [
|
||||||
|
["{", "}"],
|
||||||
|
["[", "]"],
|
||||||
|
["(", ")"]
|
||||||
|
],
|
||||||
|
autoClosingPairs: [
|
||||||
|
{ open: "{", close: "}" },
|
||||||
|
{ open: "[", close: "]" },
|
||||||
|
{ open: "(", close: ")" },
|
||||||
|
{ open: '"', close: '"' },
|
||||||
|
{ open: "'", close: "'" }
|
||||||
|
],
|
||||||
|
surroundingPairs: [
|
||||||
|
{ open: "{", close: "}" },
|
||||||
|
{ open: "[", close: "]" },
|
||||||
|
{ open: "(", close: ")" },
|
||||||
|
{ open: '"', close: '"' },
|
||||||
|
{ open: "'", close: "'" }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
monaco.languages.setMonarchTokensProvider("liquid", {
|
||||||
|
defaultToken: "",
|
||||||
|
tokenizer: {
|
||||||
|
root: [
|
||||||
|
[/\{\{-?/, { token: "delimiter.output", next: "@liquidOutput" }],
|
||||||
|
[/\{%-?\s*comment\b[^%]*-?%\}/, { token: "comment.block", next: "@liquidComment" }],
|
||||||
|
[/\{%-?/, { token: "delimiter.tag", next: "@liquidTag" }],
|
||||||
|
[/<!DOCTYPE/i, "metatag"],
|
||||||
|
[/<!--/, { token: "comment", next: "@htmlComment" }],
|
||||||
|
[/(<)(script)/i, ["delimiter.html", "tag.html"], "@scriptTag"],
|
||||||
|
[/(<)(style)/i, ["delimiter.html", "tag.html"], "@styleTag"],
|
||||||
|
[/(<\/)([\w:-]+)/, ["delimiter.html", "tag.html"]],
|
||||||
|
[/(<)([\w:-]+)/, ["delimiter.html", "tag.html"], "@htmlTag"],
|
||||||
|
[/[^<{]+/, ""],
|
||||||
|
[/./, ""]
|
||||||
|
],
|
||||||
|
liquidOutput: [
|
||||||
|
[/-?\}\}/, { token: "delimiter.output", next: "@pop" }],
|
||||||
|
[/\|\s*[a-zA-Z_][\w-]*/, "keyword"],
|
||||||
|
[/\b(?:true|false|nil|blank|empty)\b/, "keyword"],
|
||||||
|
[/\b\d+(?:\.\d+)?\b/, "number"],
|
||||||
|
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "string"],
|
||||||
|
[/[a-zA-Z_][\w.-]*/, "identifier"],
|
||||||
|
[/[,:()[\]]/, "delimiter"]
|
||||||
|
],
|
||||||
|
liquidTag: [
|
||||||
|
[/-?%\}/, { token: "delimiter.tag", next: "@pop" }],
|
||||||
|
[/\b(?:assign|capture|case|comment|cycle|decrement|echo|elsif|else|endcase|endcapture|endif|endfor|endunless|endcomment|for|if|include|increment|liquid|paginate|raw|render|tablerow|unless|when)\b/, "keyword"],
|
||||||
|
[/\|\s*[a-zA-Z_][\w-]*/, "keyword"],
|
||||||
|
[/\b(?:true|false|nil|blank|empty|contains)\b/, "keyword"],
|
||||||
|
[/\b\d+(?:\.\d+)?\b/, "number"],
|
||||||
|
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "string"],
|
||||||
|
[/[><=!]=?|\.|:/, "operator"],
|
||||||
|
[/[a-zA-Z_][\w.-]*/, "identifier"],
|
||||||
|
[/[,:()[\]]/, "delimiter"]
|
||||||
|
],
|
||||||
|
liquidComment: [
|
||||||
|
[/\{%-?\s*endcomment\s*-?%\}/, { token: "comment.block", next: "@pop" }],
|
||||||
|
[/./, "comment.block"]
|
||||||
|
],
|
||||||
|
htmlComment: [
|
||||||
|
[/-->/, { token: "comment", next: "@pop" }],
|
||||||
|
[/./, "comment"]
|
||||||
|
],
|
||||||
|
htmlTag: [
|
||||||
|
[/\/>/, { token: "delimiter.html", next: "@pop" }],
|
||||||
|
[/>/, { token: "delimiter.html", next: "@pop" }],
|
||||||
|
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"],
|
||||||
|
[/[\w:-]+/, "attribute.name"],
|
||||||
|
[/=/, "delimiter"]
|
||||||
|
],
|
||||||
|
scriptTag: [
|
||||||
|
[/>/, { token: "delimiter.html", next: "@pop" }],
|
||||||
|
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"],
|
||||||
|
[/[\w:-]+/, "attribute.name"],
|
||||||
|
[/=/, "delimiter"]
|
||||||
|
],
|
||||||
|
styleTag: [
|
||||||
|
[/>/, { token: "delimiter.html", next: "@pop" }],
|
||||||
|
[/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/, "attribute.value"],
|
||||||
|
[/[\w:-]+/, "attribute.name"],
|
||||||
|
[/=/, "delimiter"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
liquidLanguageRegistered = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const registerMarkdownWithMacrosLanguage = (monaco) => {
|
||||||
|
if (markdownWithMacrosRegistered) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
monaco.languages.register({ id: "markdown-with-macros" });
|
||||||
|
monaco.languages.setMonarchTokensProvider("markdown-with-macros", {
|
||||||
|
defaultToken: "",
|
||||||
|
tokenPostfix: ".md",
|
||||||
|
tokenizer: {
|
||||||
|
root: [
|
||||||
|
[/\[\[[a-zA-Z][\w-]*/, { token: "keyword.macro", next: "@macroParams" }],
|
||||||
|
[/^#{1,6}\s.*$/, "keyword.header"],
|
||||||
|
[/^\s*>+/, "string.quote"],
|
||||||
|
[/^\s*[-+*]\s/, "keyword"],
|
||||||
|
[/^\s*\d+\.\s/, "keyword"],
|
||||||
|
[/^\s*```\w*/, { token: "string.code", next: "@codeblock" }],
|
||||||
|
[/\*\*[^*]+\*\*/, "strong"],
|
||||||
|
[/\*[^*]+\*/, "emphasis"],
|
||||||
|
[/__[^_]+__/, "strong"],
|
||||||
|
[/_[^_]+_/, "emphasis"],
|
||||||
|
[/`[^`]+`/, "variable"],
|
||||||
|
[/!?\[[^\]]*\]\([^)]*\)/, "string.link"],
|
||||||
|
[/!?\[[^\]]*\]\[[^\]]*\]/, "string.link"]
|
||||||
|
],
|
||||||
|
macroParams: [
|
||||||
|
[/\]\]/, { token: "keyword.macro", next: "@root" }],
|
||||||
|
[/[a-zA-Z][\w-]*(?=\s*=)/, "attribute.name"],
|
||||||
|
[/=/, "delimiter"],
|
||||||
|
[/"[^"]*"/, "string"],
|
||||||
|
[/\s+/, "white"],
|
||||||
|
[/[^\]"=\s]+/, "attribute.value"]
|
||||||
|
],
|
||||||
|
codeblock: [
|
||||||
|
[/^\s*```\s*$/, { token: "string.code", next: "@root" }],
|
||||||
|
[/.*$/, "variable.source"]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
markdownWithMacrosRegistered = true;
|
||||||
|
};
|
||||||
88
assets/js/monaco/services.js
Normal file
88
assets/js/monaco/services.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { loadScript } from "../utils/script_loader.js";
|
||||||
|
import { ensureMonacoTheme } from "./theme.js";
|
||||||
|
import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./languages.js";
|
||||||
|
|
||||||
|
let monacoLoaderPromise;
|
||||||
|
const monacoEditors = new Map();
|
||||||
|
|
||||||
|
export const loadMonaco = () => {
|
||||||
|
if (window.monaco?.editor) {
|
||||||
|
ensureMonacoTheme(window.monaco);
|
||||||
|
registerLiquidLanguage(window.monaco);
|
||||||
|
registerMarkdownWithMacrosLanguage(window.monaco);
|
||||||
|
return Promise.resolve(window.monaco);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monacoLoaderPromise) {
|
||||||
|
return monacoLoaderPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
monacoLoaderPromise = loadScript("/monaco/vs/loader.js")
|
||||||
|
.then(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
window.require.config({ paths: { vs: "/monaco/vs" } });
|
||||||
|
window.require(["vs/editor/editor.main"], () => {
|
||||||
|
ensureMonacoTheme(window.monaco);
|
||||||
|
registerLiquidLanguage(window.monaco);
|
||||||
|
registerMarkdownWithMacrosLanguage(window.monaco);
|
||||||
|
resolve(window.monaco);
|
||||||
|
}, reject);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.catch((error) => {
|
||||||
|
monacoLoaderPromise = null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
return monacoLoaderPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const registerMonacoEditor = (key, editor) => {
|
||||||
|
if (key) {
|
||||||
|
monacoEditors.set(key, editor);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const unregisterMonacoEditor = (key) => {
|
||||||
|
if (key) {
|
||||||
|
monacoEditors.delete(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const activeMonacoEditor = () => {
|
||||||
|
for (const editor of monacoEditors.values()) {
|
||||||
|
if (typeof editor?.hasTextFocus === "function" && editor.hasTextFocus()) {
|
||||||
|
return editor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const runMonacoEditorAction = (editor, actionId, triggerId = actionId) => {
|
||||||
|
if (!editor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = typeof editor.getAction === "function" ? editor.getAction(actionId) : null;
|
||||||
|
|
||||||
|
if (action && typeof action.run === "function") {
|
||||||
|
action.run();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof editor.trigger === "function") {
|
||||||
|
editor.trigger("bds-menu", triggerId, null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const diffModelPath = (filePath, side) => {
|
||||||
|
const normalized = String(filePath || "working-tree").replace(/^\/+/, "");
|
||||||
|
return `inmemory://model/git-diff/${side}/${normalized}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export { ensureMonacoTheme };
|
||||||
62
assets/js/monaco/theme.js
Normal file
62
assets/js/monaco/theme.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { cssVar, normalizeMonacoColor } from "../utils/color.js";
|
||||||
|
|
||||||
|
let monacoThemeSignature = null;
|
||||||
|
|
||||||
|
export const ensureMonacoTheme = (monaco) => {
|
||||||
|
const background = normalizeMonacoColor(
|
||||||
|
cssVar("--vscode-editor-background", cssVar("--vscode-input-background", "#1e1e1e")),
|
||||||
|
"#1e1e1e"
|
||||||
|
);
|
||||||
|
const foreground = normalizeMonacoColor(cssVar("--vscode-editor-foreground", "#d4d4d4"), "#d4d4d4");
|
||||||
|
const lineNumber = normalizeMonacoColor(cssVar("--vscode-editorLineNumber-foreground", "#858585"), "#858585");
|
||||||
|
const activeLineNumber = normalizeMonacoColor(
|
||||||
|
cssVar("--vscode-editorLineNumber-activeForeground", foreground),
|
||||||
|
foreground
|
||||||
|
);
|
||||||
|
const selection = normalizeMonacoColor(cssVar("--vscode-editor-selectionBackground", "#264f78"), "#264f78");
|
||||||
|
const inactiveSelection = normalizeMonacoColor(
|
||||||
|
cssVar("--vscode-editor-inactiveSelectionBackground", "#3a3d41"),
|
||||||
|
"#3a3d41"
|
||||||
|
);
|
||||||
|
const cursor = normalizeMonacoColor(cssVar("--vscode-editorCursor-foreground", foreground), foreground);
|
||||||
|
const border = normalizeMonacoColor(cssVar("--vscode-panel-border", "#3c3c3c"), "#3c3c3c");
|
||||||
|
const lineHighlight = normalizeMonacoColor(
|
||||||
|
cssVar("--vscode-editor-lineHighlightBackground", background),
|
||||||
|
background
|
||||||
|
);
|
||||||
|
const signature = [background, foreground, lineNumber, activeLineNumber, selection, inactiveSelection, cursor, border].join("|");
|
||||||
|
|
||||||
|
if (signature === monacoThemeSignature) {
|
||||||
|
monaco.editor.setTheme("bds-theme");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
monaco.editor.defineTheme("bds-theme", {
|
||||||
|
base: "vs-dark",
|
||||||
|
inherit: true,
|
||||||
|
rules: [
|
||||||
|
{ token: "keyword.macro", foreground: "C586C0", fontStyle: "bold" },
|
||||||
|
{ token: "attribute.name", foreground: "9CDCFE" },
|
||||||
|
{ token: "attribute.value", foreground: "CE9178" }
|
||||||
|
],
|
||||||
|
colors: {
|
||||||
|
"editor.background": background,
|
||||||
|
"editor.foreground": foreground,
|
||||||
|
"editor.lineHighlightBackground": lineHighlight,
|
||||||
|
"editorCursor.foreground": cursor,
|
||||||
|
"editor.selectionBackground": selection,
|
||||||
|
"editor.inactiveSelectionBackground": inactiveSelection,
|
||||||
|
"editorLineNumber.foreground": lineNumber,
|
||||||
|
"editorLineNumber.activeForeground": activeLineNumber,
|
||||||
|
"editorIndentGuide.background1": border,
|
||||||
|
"editorIndentGuide.activeBackground1": foreground,
|
||||||
|
"editorWidget.border": border,
|
||||||
|
"editorGutter.background": background,
|
||||||
|
"focusBorder": border,
|
||||||
|
"input.border": border
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
monacoThemeSignature = signature;
|
||||||
|
monaco.editor.setTheme("bds-theme");
|
||||||
|
};
|
||||||
46
assets/js/utils/color.js
Normal file
46
assets/js/utils/color.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { clamp } from "./dom.js";
|
||||||
|
|
||||||
|
export const cssVar = (name, fallback) => {
|
||||||
|
const value = window.getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
return value || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseRgbColor = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hex = value.match(/^#([0-9a-f]{6})$/i);
|
||||||
|
|
||||||
|
if (hex) {
|
||||||
|
return {
|
||||||
|
r: Number.parseInt(hex[1].slice(0, 2), 16),
|
||||||
|
g: Number.parseInt(hex[1].slice(2, 4), 16),
|
||||||
|
b: Number.parseInt(hex[1].slice(4, 6), 16)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rgb = value.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
|
||||||
|
|
||||||
|
if (!rgb) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
r: Number.parseInt(rgb[1], 10),
|
||||||
|
g: Number.parseInt(rgb[2], 10),
|
||||||
|
b: Number.parseInt(rgb[3], 10)
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeMonacoColor = (value, fallback) => {
|
||||||
|
const rgb = parseRgbColor(value);
|
||||||
|
|
||||||
|
if (!rgb) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `#${[rgb.r, rgb.g, rgb.b]
|
||||||
|
.map((channel) => clamp(channel, 0, 255).toString(16).padStart(2, "0"))
|
||||||
|
.join("")}`;
|
||||||
|
};
|
||||||
34
assets/js/utils/dom.js
Normal file
34
assets/js/utils/dom.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
export const clamp = (value, min, max) => Math.max(min, Math.min(value, max));
|
||||||
|
|
||||||
|
export const parseJsonObject = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setMediaThumbnailLoaded = (image, loaded) => {
|
||||||
|
const thumbnail = image?.closest(".media-thumbnail");
|
||||||
|
|
||||||
|
if (!thumbnail) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loaded) {
|
||||||
|
thumbnail.classList.add("is-loaded");
|
||||||
|
} else {
|
||||||
|
thumbnail.classList.remove("is-loaded");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const syncMediaThumbnailState = (root) => {
|
||||||
|
root.querySelectorAll(".media-thumbnail-image").forEach((image) => {
|
||||||
|
setMediaThumbnailLoaded(image, Boolean(image.complete && image.naturalWidth > 0));
|
||||||
|
});
|
||||||
|
};
|
||||||
43
assets/js/utils/layout.js
Normal file
43
assets/js/utils/layout.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { clamp } from "./dom.js";
|
||||||
|
import { SIDEBAR_STORAGE_KEY, ASSISTANT_STORAGE_KEY } from "../constants.js";
|
||||||
|
|
||||||
|
export const shellWidth = (selector) => {
|
||||||
|
const shell = document.querySelector(selector);
|
||||||
|
|
||||||
|
if (!shell) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = Number.parseInt(shell.style.width || "0", 10);
|
||||||
|
return Number.isNaN(width) ? Math.round(shell.getBoundingClientRect().width) : width;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setShellWidth = (selector, width) => {
|
||||||
|
const shell = document.querySelector(selector);
|
||||||
|
|
||||||
|
if (shell) {
|
||||||
|
shell.style.width = `${width}px`;
|
||||||
|
shell.classList.remove("is-hidden");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const persistWidth = (target, width) => {
|
||||||
|
const key = target === "assistant" ? ASSISTANT_STORAGE_KEY : SIDEBAR_STORAGE_KEY;
|
||||||
|
window.localStorage.setItem(key, String(width));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const readStoredSize = (key, fallback, min, max) => {
|
||||||
|
const raw = window.localStorage.getItem(key);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(raw, 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(parsed)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clamp(parsed, min, max);
|
||||||
|
};
|
||||||
33
assets/js/utils/script_loader.js
Normal file
33
assets/js/utils/script_loader.js
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
30
assets/js/utils/shortcuts.js
Normal file
30
assets/js/utils/shortcuts.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
export const normalizeShortcutKey = (key) => String(key || "").toLowerCase();
|
||||||
|
|
||||||
|
export const shortcutTargetIsEditable = (event) => {
|
||||||
|
const tag = event.target?.tagName || null;
|
||||||
|
return event.target?.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(tag);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const shortcutMatchesEvent = (shortcut, event) => {
|
||||||
|
const primary = event.metaKey || event.ctrlKey;
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalizeShortcutKey(event.key) === normalizeShortcutKey(shortcut.key) &&
|
||||||
|
primary === Boolean(shortcut.primary) &&
|
||||||
|
event.shiftKey === Boolean(shortcut.shift) &&
|
||||||
|
event.altKey === Boolean(shortcut.alt)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const parseShortcutConfig = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value);
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch (_error) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ config :bds,
|
|||||||
config :bds, BDS.Repo,
|
config :bds, BDS.Repo,
|
||||||
database: Path.expand("../priv/data/bds_dev.db", __DIR__),
|
database: Path.expand("../priv/data/bds_dev.db", __DIR__),
|
||||||
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,
|
||||||
@@ -27,6 +28,31 @@ config :bds, BDS.Desktop.Endpoint,
|
|||||||
pubsub_server: BDS.PubSub,
|
pubsub_server: BDS.PubSub,
|
||||||
live_view: [signing_salt: "desktop-live-view"]
|
live_view: [signing_salt: "desktop-live-view"]
|
||||||
|
|
||||||
|
config :tailwind,
|
||||||
|
version: "4.1.14",
|
||||||
|
default: [
|
||||||
|
cd: Path.expand("..", __DIR__),
|
||||||
|
args: ~w(
|
||||||
|
--input=assets/css/app.css
|
||||||
|
--output=priv/static/assets/app.css
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
config :esbuild,
|
||||||
|
version: "0.25.4",
|
||||||
|
default: [
|
||||||
|
cd: Path.expand("../assets", __DIR__),
|
||||||
|
args: ~w(
|
||||||
|
js/app.js
|
||||||
|
--bundle
|
||||||
|
--target=es2022
|
||||||
|
--outdir=../priv/static/assets
|
||||||
|
--external:/fonts/*
|
||||||
|
--external:/images/*
|
||||||
|
),
|
||||||
|
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
|
||||||
|
]
|
||||||
|
|
||||||
config :bds, :scripting,
|
config :bds, :scripting,
|
||||||
runtime: BDS.Scripting.Lua,
|
runtime: BDS.Scripting.Lua,
|
||||||
timeout: 300_000,
|
timeout: 300_000,
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
import Config
|
import Config
|
||||||
|
|
||||||
config :bds, BDS.Repo, pool_size: 5
|
config :bds, BDS.Repo, pool_size: 5
|
||||||
|
|
||||||
|
config :bds, BDS.Desktop.Endpoint,
|
||||||
|
watchers: [
|
||||||
|
tailwind: {Tailwind, :install_and_run, [:default, ~w(--watch)]},
|
||||||
|
esbuild: {Esbuild, :install_and_run, [:default, ~w(--watch)]}
|
||||||
|
]
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import Config
|
|||||||
config :bds, BDS.Repo,
|
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,
|
||||||
|
journal_mode: :wal,
|
||||||
|
busy_timeout: 15_000
|
||||||
|
|
||||||
config :logger, level: :warning
|
config :logger, level: :warning
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
defmodule BDS.AI do
|
defmodule BDS.AI do
|
||||||
@moduledoc false
|
@moduledoc """
|
||||||
|
Public interface for AI features — endpoint configuration, secret management,
|
||||||
|
model catalog access, and dispatching chat and one-shot inference requests.
|
||||||
|
"""
|
||||||
|
|
||||||
alias BDS.AI.Catalog
|
alias BDS.AI.Catalog
|
||||||
alias BDS.AI.Chat
|
alias BDS.AI.Chat
|
||||||
alias BDS.AI.OneShot
|
alias BDS.AI.OneShot
|
||||||
alias BDS.AI.Runtime
|
alias BDS.AI.Runtime
|
||||||
alias BDS.AI.SecretBackend
|
alias BDS.AI.SecretBackend
|
||||||
|
alias BDS.MapUtils
|
||||||
|
|
||||||
import BDS.AI.SettingsStore,
|
import BDS.AI.SettingsStore,
|
||||||
only: [
|
only: [
|
||||||
@@ -21,20 +25,26 @@ defmodule BDS.AI do
|
|||||||
@type endpoint_kind :: atom()
|
@type endpoint_kind :: atom()
|
||||||
|
|
||||||
@typedoc "Endpoint configuration map."
|
@typedoc "Endpoint configuration map."
|
||||||
@type endpoint :: %{kind: endpoint_kind(), url: String.t() | nil, api_key: String.t() | nil, model: String.t() | nil}
|
@type endpoint :: %{
|
||||||
|
kind: endpoint_kind(),
|
||||||
|
url: String.t() | nil,
|
||||||
|
api_key: String.t() | nil,
|
||||||
|
model: String.t() | nil
|
||||||
|
}
|
||||||
|
|
||||||
@typedoc "Attribute map for endpoint operations."
|
@typedoc "Attribute map for endpoint operations."
|
||||||
@type endpoint_attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
|
@type endpoint_attrs :: %{optional(atom()) => term(), optional(String.t()) => term()}
|
||||||
|
|
||||||
@spec put_endpoint(endpoint_kind(), endpoint_attrs(), keyword()) ::
|
@spec put_endpoint(endpoint_kind(), endpoint_attrs(), keyword()) ::
|
||||||
{:ok, endpoint()} | {:error, term()}
|
{:ok, endpoint()} | {:error, term()}
|
||||||
def put_endpoint(kind, attrs, opts \\ []) when is_atom(kind) and is_map(attrs) and is_list(opts) do
|
def put_endpoint(kind, attrs, opts \\ [])
|
||||||
|
when is_atom(kind) and is_map(attrs) and is_list(opts) do
|
||||||
backend = Keyword.get(opts, :secret_backend, SecretBackend)
|
backend = Keyword.get(opts, :secret_backend, SecretBackend)
|
||||||
kind_key = Atom.to_string(kind)
|
kind_key = Atom.to_string(kind)
|
||||||
|
|
||||||
url = Map.get(attrs, :url) || Map.get(attrs, "url")
|
url = MapUtils.attr(attrs, :url)
|
||||||
model = Map.get(attrs, :model) || Map.get(attrs, "model")
|
model = MapUtils.attr(attrs, :model)
|
||||||
api_key = Map.get(attrs, :api_key) || Map.get(attrs, "api_key")
|
api_key = MapUtils.attr(attrs, :api_key)
|
||||||
|
|
||||||
with :ok <- put_setting("ai.#{kind_key}.url", url),
|
with :ok <- put_setting("ai.#{kind_key}.url", url),
|
||||||
:ok <- put_setting("ai.#{kind_key}.model", model),
|
:ok <- put_setting("ai.#{kind_key}.model", model),
|
||||||
@@ -52,14 +62,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
|
||||||
|
|
||||||
@@ -103,7 +111,8 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec put_model_preference(atom(), String.t()) :: :ok | {:error, :unknown_model_preference | term()}
|
@spec put_model_preference(atom(), String.t()) ::
|
||||||
|
:ok | {:error, :unknown_model_preference | term()}
|
||||||
def put_model_preference(key, model) when is_atom(key) and is_binary(model) do
|
def put_model_preference(key, model) when is_atom(key) and is_binary(model) do
|
||||||
case Map.fetch(Runtime.model_preference_keys(), key) do
|
case Map.fetch(Runtime.model_preference_keys(), key) do
|
||||||
{:ok, setting_key} -> put_setting(setting_key, model)
|
{:ok, setting_key} -> put_setting(setting_key, model)
|
||||||
@@ -111,7 +120,8 @@ defmodule BDS.AI do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec get_model_preference(atom()) :: {:ok, String.t() | nil} | {:error, :unknown_model_preference}
|
@spec get_model_preference(atom()) ::
|
||||||
|
{:ok, String.t() | nil} | {:error, :unknown_model_preference}
|
||||||
def get_model_preference(key) when is_atom(key) do
|
def get_model_preference(key) when is_atom(key) do
|
||||||
case Map.fetch(Runtime.model_preference_keys(), key) do
|
case Map.fetch(Runtime.model_preference_keys(), key) do
|
||||||
{:ok, setting_key} -> {:ok, get_setting(setting_key)}
|
{:ok, setting_key} -> {:ok, get_setting(setting_key)}
|
||||||
@@ -134,13 +144,15 @@ defmodule BDS.AI do
|
|||||||
@spec analyze_post(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
@spec analyze_post(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
defdelegate analyze_post(post_input, opts \\ []), to: OneShot
|
defdelegate analyze_post(post_input, opts \\ []), to: OneShot
|
||||||
|
|
||||||
@spec translate_post(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
@spec translate_post(map() | String.t(), String.t(), keyword()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
defdelegate translate_post(post_input, target_language, opts \\ []), to: OneShot
|
defdelegate translate_post(post_input, target_language, opts \\ []), to: OneShot
|
||||||
|
|
||||||
@spec analyze_image(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
@spec analyze_image(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
defdelegate analyze_image(media_input, opts \\ []), to: OneShot
|
defdelegate analyze_image(media_input, opts \\ []), to: OneShot
|
||||||
|
|
||||||
@spec translate_media(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
@spec translate_media(map() | String.t(), String.t(), keyword()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
defdelegate translate_media(media_input, target_language, opts \\ []), to: OneShot
|
defdelegate translate_media(media_input, target_language, opts \\ []), to: OneShot
|
||||||
|
|
||||||
@spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
|
@spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
|
||||||
@@ -149,9 +161,18 @@ defmodule BDS.AI do
|
|||||||
@spec list_chat_conversations() :: [map()]
|
@spec list_chat_conversations() :: [map()]
|
||||||
defdelegate list_chat_conversations(), to: Chat
|
defdelegate list_chat_conversations(), to: Chat
|
||||||
|
|
||||||
|
@spec get_chat_conversation(String.t()) :: BDS.AI.ChatConversation.t() | nil
|
||||||
|
defdelegate get_chat_conversation(conversation_id), to: Chat
|
||||||
|
|
||||||
|
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
|
||||||
|
defdelegate delete_chat_conversation(conversation_id), to: Chat
|
||||||
|
|
||||||
@spec available_chat_models(String.t() | nil) :: [map()]
|
@spec available_chat_models(String.t() | nil) :: [map()]
|
||||||
defdelegate available_chat_models(current_model \\ nil), to: Chat
|
defdelegate available_chat_models(current_model \\ nil), to: Chat
|
||||||
|
|
||||||
|
@spec effective_chat_model(BDS.AI.ChatConversation.t() | map() | nil) :: String.t() | nil
|
||||||
|
defdelegate effective_chat_model(conversation), to: Chat
|
||||||
|
|
||||||
@spec set_conversation_model(String.t(), String.t()) ::
|
@spec set_conversation_model(String.t(), String.t()) ::
|
||||||
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
|
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
defdelegate set_conversation_model(conversation_id, model_id), to: Chat
|
defdelegate set_conversation_model(conversation_id, model_id), to: Chat
|
||||||
@@ -165,4 +186,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
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ defmodule BDS.AI.Catalog do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
import BDS.AI.SettingsStore,
|
import BDS.AI.SettingsStore,
|
||||||
only: [
|
only: [
|
||||||
get_setting: 1,
|
get_setting: 1,
|
||||||
@@ -14,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
|
||||||
|
|
||||||
@@ -21,7 +23,13 @@ defmodule BDS.AI.Catalog do
|
|||||||
|
|
||||||
@spec list_endpoint_models(map(), keyword()) :: {:ok, [map()]} | {:error, term()}
|
@spec list_endpoint_models(map(), keyword()) :: {:ok, [map()]} | {:error, term()}
|
||||||
def list_endpoint_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
|
def list_endpoint_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
|
||||||
http_client = Keyword.get(opts, :http_client, Application.get_env(:bds, :ai_http_client, BDS.AI.HttpClient))
|
http_client =
|
||||||
|
Keyword.get(
|
||||||
|
opts,
|
||||||
|
:http_client,
|
||||||
|
Application.get_env(:bds, :ai_http_client, BDS.AI.HttpClient)
|
||||||
|
)
|
||||||
|
|
||||||
OpenAICompatibleRuntime.list_models(endpoint, http_client: http_client)
|
OpenAICompatibleRuntime.list_models(endpoint, http_client: http_client)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -103,8 +111,9 @@ defmodule BDS.AI.Catalog do
|
|||||||
@spec put_model_capabilities(String.t(), map()) :: :ok | {:error, term()}
|
@spec put_model_capabilities(String.t(), map()) :: :ok | {:error, term()}
|
||||||
def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do
|
def put_model_capabilities(model_id, attrs) when is_binary(model_id) and is_map(attrs) do
|
||||||
capabilities = %{
|
capabilities = %{
|
||||||
supports_attachment: truthy?(Map.get(attrs, :supports_attachment) || Map.get(attrs, "supports_attachment")),
|
supports_attachment: truthy?(BDS.MapUtils.attr(attrs, :supports_attachment)),
|
||||||
supports_tool_calls: truthy?(Map.get(attrs, :supports_tool_calls) || Map.get(attrs, "supports_tool_calls"))
|
supports_tool_calls: truthy?(BDS.MapUtils.attr(attrs, :supports_tool_calls)),
|
||||||
|
disables_reasoning: truthy?(BDS.MapUtils.attr(attrs, :disables_reasoning))
|
||||||
}
|
}
|
||||||
|
|
||||||
put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities))
|
put_setting("ai.model_capabilities.#{model_id}", Jason.encode!(capabilities))
|
||||||
@@ -154,7 +163,11 @@ defmodule BDS.AI.Catalog do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec model_capabilities(String.t()) :: %{supports_attachment: boolean(), supports_tool_calls: boolean()}
|
@spec model_capabilities(String.t()) :: %{
|
||||||
|
supports_attachment: boolean(),
|
||||||
|
supports_tool_calls: boolean(),
|
||||||
|
disables_reasoning: boolean()
|
||||||
|
}
|
||||||
def model_capabilities(model_id) do
|
def model_capabilities(model_id) do
|
||||||
overrides = decode_model_capabilities_override(model_id)
|
overrides = decode_model_capabilities_override(model_id)
|
||||||
|
|
||||||
@@ -162,8 +175,9 @@ defmodule BDS.AI.Catalog do
|
|||||||
case get_catalog_model(model_id) do
|
case get_catalog_model(model_id) do
|
||||||
{:ok, model} ->
|
{:ok, model} ->
|
||||||
%{
|
%{
|
||||||
supports_attachment: model.supports_attachment or ("image" in model.input_modalities),
|
supports_attachment: model.supports_attachment or "image" in model.input_modalities,
|
||||||
supports_tool_calls: model.supports_tool_calls
|
supports_tool_calls: model.supports_tool_calls,
|
||||||
|
disables_reasoning: false
|
||||||
}
|
}
|
||||||
|
|
||||||
_other ->
|
_other ->
|
||||||
@@ -186,7 +200,8 @@ defmodule BDS.AI.Catalog do
|
|||||||
String.contains?(normalized, "llava"),
|
String.contains?(normalized, "llava"),
|
||||||
supports_tool_calls:
|
supports_tool_calls:
|
||||||
String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or
|
String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or
|
||||||
String.contains?(normalized, "tool")
|
String.contains?(normalized, "tool"),
|
||||||
|
disables_reasoning: false
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -197,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()
|
||||||
@@ -257,8 +270,19 @@ defmodule BDS.AI.Catalog do
|
|||||||
|> Model.changeset(model_attrs)
|
|> Model.changeset(model_attrs)
|
||||||
|> Repo.insert!()
|
|> Repo.insert!()
|
||||||
|
|
||||||
insert_modalities(provider_id, model_id, Map.get(model_data, "input_modalities", []), :input)
|
insert_modalities(
|
||||||
insert_modalities(provider_id, model_id, Map.get(model_data, "output_modalities", []), :output)
|
provider_id,
|
||||||
|
model_id,
|
||||||
|
Map.get(model_data, "input_modalities", []),
|
||||||
|
:input
|
||||||
|
)
|
||||||
|
|
||||||
|
insert_modalities(
|
||||||
|
provider_id,
|
||||||
|
model_id,
|
||||||
|
Map.get(model_data, "output_modalities", []),
|
||||||
|
:output
|
||||||
|
)
|
||||||
|
|
||||||
inner_count + 1
|
inner_count + 1
|
||||||
end)
|
end)
|
||||||
@@ -288,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)
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ defmodule BDS.AI.CatalogProvider do
|
|||||||
|
|
||||||
def changeset(provider, attrs) do
|
def changeset(provider, attrs) do
|
||||||
provider
|
provider
|
||||||
|> cast(attrs, [:id, :name, :env_keys, :package_ref, :api_url, :doc_url, :updated_at], empty_values: [nil])
|
|> cast(attrs, [:id, :name, :env_keys, :package_ref, :api_url, :doc_url, :updated_at],
|
||||||
|
empty_values: [nil]
|
||||||
|
)
|
||||||
|> validate_required([:id, :name, :updated_at])
|
|> validate_required([:id, :name, :updated_at])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ defmodule BDS.AI.Chat do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias BDS.AI
|
alias BDS.AI
|
||||||
alias BDS.AI.Catalog
|
alias BDS.AI.Catalog
|
||||||
@@ -13,6 +14,7 @@ defmodule BDS.AI.Chat do
|
|||||||
alias BDS.AI.OpenAICompatibleRuntime
|
alias BDS.AI.OpenAICompatibleRuntime
|
||||||
alias BDS.AI.Runtime
|
alias BDS.AI.Runtime
|
||||||
alias BDS.AI.SecretBackend
|
alias BDS.AI.SecretBackend
|
||||||
|
alias BDS.MapUtils
|
||||||
import BDS.AI.SettingsStore, only: [get_setting: 1]
|
import BDS.AI.SettingsStore, only: [get_setting: 1]
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
alias BDS.Persistence
|
alias BDS.Persistence
|
||||||
@@ -22,21 +24,23 @@ 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
|
||||||
@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 = Map.get(attrs, :model) || Map.get(attrs, "model")
|
model = MapUtils.attr(attrs, :model)
|
||||||
title = Map.get(attrs, :title) || Map.get(attrs, "title") || generated_chat_title(model)
|
title = MapUtils.attr(attrs, :title) || generated_chat_title(model)
|
||||||
|
|
||||||
%ChatConversation{}
|
%ChatConversation{}
|
||||||
|> ChatConversation.changeset(%{
|
|> ChatConversation.changeset(%{
|
||||||
id: Ecto.UUID.generate(),
|
id: Ecto.UUID.generate(),
|
||||||
title: title,
|
title: title,
|
||||||
model: model,
|
model: model,
|
||||||
copilot_session_id: Map.get(attrs, :copilot_session_id) || Map.get(attrs, "copilot_session_id"),
|
copilot_session_id: MapUtils.attr(attrs, :copilot_session_id),
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now
|
updated_at: now
|
||||||
})
|
})
|
||||||
@@ -53,6 +57,65 @@ defmodule BDS.AI.Chat do
|
|||||||
|> Enum.map(&format_conversation/1)
|
|> Enum.map(&format_conversation/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec get_chat_conversation(String.t()) :: ChatConversation.t() | nil
|
||||||
|
def get_chat_conversation(conversation_id) when is_binary(conversation_id) do
|
||||||
|
Repo.get(ChatConversation, conversation_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec get_surface_state(String.t()) :: map()
|
||||||
|
def get_surface_state(conversation_id) when is_binary(conversation_id) do
|
||||||
|
case Repo.get(ChatConversation, conversation_id) do
|
||||||
|
%ChatConversation{surface_state: state} when is_map(state) -> state
|
||||||
|
_other -> %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec put_surface_state(String.t(), map(), map(), MapSet.t()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
|
def put_surface_state(conversation_id, surface_data, surface_tabs, dismissed_surfaces)
|
||||||
|
when is_binary(conversation_id) do
|
||||||
|
case Repo.get(ChatConversation, conversation_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
%ChatConversation{} = conversation ->
|
||||||
|
state = %{
|
||||||
|
"surface_data" => surface_data,
|
||||||
|
"surface_tabs" => surface_tabs,
|
||||||
|
"dismissed_surfaces" => MapSet.to_list(dismissed_surfaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
conversation
|
||||||
|
|> ChatConversation.changeset(%{
|
||||||
|
surface_state: state,
|
||||||
|
updated_at: Persistence.now_ms()
|
||||||
|
})
|
||||||
|
|> Repo.update()
|
||||||
|
|> case do
|
||||||
|
{:ok, _updated} -> {:ok, state}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec delete_chat_conversation(String.t()) :: {:ok, :deleted} | {:error, :not_found | term()}
|
||||||
|
def delete_chat_conversation(conversation_id) when is_binary(conversation_id) do
|
||||||
|
case Repo.get(ChatConversation, conversation_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
%ChatConversation{} = conversation ->
|
||||||
|
Repo.delete_all(
|
||||||
|
from message in ChatMessage, where: message.conversation_id == ^conversation_id
|
||||||
|
)
|
||||||
|
|
||||||
|
case Repo.delete(conversation) do
|
||||||
|
{:ok, _conversation} -> {:ok, :deleted}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@spec available_chat_models(String.t() | nil) :: [map()]
|
@spec available_chat_models(String.t() | nil) :: [map()]
|
||||||
def available_chat_models(current_model \\ nil) do
|
def available_chat_models(current_model \\ nil) do
|
||||||
endpoint_models = configured_chat_models()
|
endpoint_models = configured_chat_models()
|
||||||
@@ -81,6 +144,15 @@ defmodule BDS.AI.Chat do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec effective_chat_model(ChatConversation.t() | map() | nil) :: String.t() | nil
|
||||||
|
def effective_chat_model(%ChatConversation{} = conversation) do
|
||||||
|
resolve_effective_chat_model(conversation.model)
|
||||||
|
end
|
||||||
|
|
||||||
|
def effective_chat_model(%{model: model}), do: resolve_effective_chat_model(model)
|
||||||
|
def effective_chat_model(%{"model" => model}), do: resolve_effective_chat_model(model)
|
||||||
|
def effective_chat_model(_conversation), do: resolve_effective_chat_model(nil)
|
||||||
|
|
||||||
@spec set_conversation_model(String.t(), String.t()) ::
|
@spec set_conversation_model(String.t(), String.t()) ::
|
||||||
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
|
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
def set_conversation_model(conversation_id, model_id)
|
def set_conversation_model(conversation_id, model_id)
|
||||||
@@ -115,12 +187,13 @@ defmodule BDS.AI.Chat do
|
|||||||
def send_chat_message(conversation_id, content, opts \\ [])
|
def send_chat_message(conversation_id, content, opts \\ [])
|
||||||
when is_binary(conversation_id) and is_binary(content) and is_list(opts) do
|
when is_binary(conversation_id) and is_binary(content) and is_list(opts) do
|
||||||
with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id),
|
with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id),
|
||||||
{:ok, user_message} <- persist_chat_message(%{
|
{:ok, user_message} <-
|
||||||
conversation_id: conversation.id,
|
persist_chat_message(%{
|
||||||
role: :user,
|
conversation_id: conversation.id,
|
||||||
content: content,
|
role: :user,
|
||||||
created_at: Persistence.now_ms()
|
content: content,
|
||||||
}) do
|
created_at: Persistence.now_ms()
|
||||||
|
}) do
|
||||||
task =
|
task =
|
||||||
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
||||||
receive do
|
receive do
|
||||||
@@ -148,7 +221,9 @@ defmodule BDS.AI.Chat do
|
|||||||
@spec cancel_chat(String.t()) :: :ok
|
@spec cancel_chat(String.t()) :: :ok
|
||||||
def cancel_chat(conversation_id) when is_binary(conversation_id) do
|
def cancel_chat(conversation_id) when is_binary(conversation_id) do
|
||||||
case InFlight.lookup(conversation_id) do
|
case InFlight.lookup(conversation_id) do
|
||||||
nil -> :ok
|
nil ->
|
||||||
|
:ok
|
||||||
|
|
||||||
pid ->
|
pid ->
|
||||||
_ = Task.Supervisor.terminate_child(BDS.Tasks.TaskSupervisor, pid)
|
_ = Task.Supervisor.terminate_child(BDS.Tasks.TaskSupervisor, pid)
|
||||||
:ok
|
:ok
|
||||||
@@ -157,7 +232,11 @@ defmodule BDS.AI.Chat do
|
|||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
def count_distinct_string_list(schema, field, project_id) do
|
def count_distinct_string_list(schema, field, project_id) do
|
||||||
Repo.all(from record in schema, where: field(record, :project_id) == ^project_id, select: field(record, ^field))
|
Repo.all(
|
||||||
|
from record in schema,
|
||||||
|
where: field(record, :project_id) == ^project_id,
|
||||||
|
select: field(record, ^field)
|
||||||
|
)
|
||||||
|> List.flatten()
|
|> List.flatten()
|
||||||
|> Enum.reject(&blank?/1)
|
|> Enum.reject(&blank?/1)
|
||||||
|> MapSet.new()
|
|> MapSet.new()
|
||||||
@@ -251,6 +330,25 @@ defmodule BDS.AI.Chat do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp resolve_effective_chat_model(model) when is_binary(model) and model != "", do: model
|
||||||
|
|
||||||
|
defp resolve_effective_chat_model(_model) do
|
||||||
|
mode = if AI.airplane_mode?(), do: :airplane, else: :online
|
||||||
|
|
||||||
|
preference_key = if mode == :airplane, do: :airplane_chat, else: :chat
|
||||||
|
|
||||||
|
case Runtime.model_preference_value(preference_key) do
|
||||||
|
model when is_binary(model) and model != "" ->
|
||||||
|
model
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
case AI.get_endpoint(mode) do
|
||||||
|
{:ok, %{model: model}} when is_binary(model) and model != "" -> model
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp catalog_provider_name_map do
|
defp catalog_provider_name_map do
|
||||||
Repo.all(from provider in CatalogProvider, select: {provider.id, provider.name})
|
Repo.all(from provider in CatalogProvider, select: {provider.id, provider.name})
|
||||||
|> Map.new()
|
|> Map.new()
|
||||||
@@ -262,9 +360,14 @@ defmodule BDS.AI.Chat do
|
|||||||
normalized_url = String.downcase(url)
|
normalized_url = String.downcase(url)
|
||||||
|
|
||||||
cond do
|
cond do
|
||||||
String.contains?(normalized_url, "11434") or String.contains?(normalized_url, "ollama") -> "ollama"
|
String.contains?(normalized_url, "11434") or String.contains?(normalized_url, "ollama") ->
|
||||||
String.contains?(normalized_url, "1234") or String.contains?(normalized_url, "lmstudio") -> "lmstudio"
|
"ollama"
|
||||||
true -> "generic-openai"
|
|
||||||
|
String.contains?(normalized_url, "1234") or String.contains?(normalized_url, "lmstudio") ->
|
||||||
|
"lmstudio"
|
||||||
|
|
||||||
|
true ->
|
||||||
|
"generic-openai"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -285,7 +388,7 @@ defmodule BDS.AI.Chat do
|
|||||||
|
|
||||||
defp fallback_provider_name(_provider), do: "Other"
|
defp fallback_provider_name(_provider), do: "Other"
|
||||||
|
|
||||||
defp do_send_chat_message(conversation, _user_message, opts) do
|
defp do_send_chat_message(conversation, user_message, opts) do
|
||||||
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
||||||
project_id = Keyword.get(opts, :project_id, active_project_id())
|
project_id = Keyword.get(opts, :project_id, active_project_id())
|
||||||
|
|
||||||
@@ -298,23 +401,180 @@ defmodule BDS.AI.Chat do
|
|||||||
:ok <- Runtime.validate_target(:chat, model, mode),
|
:ok <- Runtime.validate_target(:chat, model, mode),
|
||||||
messages <- load_chat_messages(conversation.id),
|
messages <- load_chat_messages(conversation.id),
|
||||||
tools <- available_chat_tools(project_id, model),
|
tools <- available_chat_tools(project_id, model),
|
||||||
{:ok, reply} <- chat_round(conversation, messages, endpoint, model, project_id, tools, runtime, opts, @chat_max_tool_rounds) do
|
{:ok, reply} <-
|
||||||
|
chat_round(
|
||||||
|
conversation,
|
||||||
|
messages,
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
project_id,
|
||||||
|
tools,
|
||||||
|
runtime,
|
||||||
|
opts,
|
||||||
|
@chat_max_tool_rounds
|
||||||
|
),
|
||||||
|
{:ok, reply} <-
|
||||||
|
maybe_generate_chat_title(conversation.id, user_message.content, reply, opts) do
|
||||||
{:ok, reply}
|
{:ok, reply}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp chat_round(_conversation, _messages, _endpoint, _model, _project_id, _tools, _runtime, _opts, 0) do
|
defp maybe_generate_chat_title(conversation_id, user_content, reply, opts) do
|
||||||
|
conversation = Repo.get!(ChatConversation, conversation_id)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
chat_user_message_count(conversation_id) < 1 ->
|
||||||
|
Logger.debug("Chat title generation skipped reason=:no_user_messages")
|
||||||
|
{:ok, reply}
|
||||||
|
|
||||||
|
not generated_chat_title?(conversation.title, conversation.model) ->
|
||||||
|
Logger.debug(
|
||||||
|
"Chat title generation skipped reason=:conversation_already_titled title=#{inspect(conversation.title)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, reply}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
Logger.debug("Chat title generation requested conversation_id=#{conversation_id}")
|
||||||
|
|
||||||
|
case generate_chat_title(user_content, opts) do
|
||||||
|
{:ok, title} when is_binary(title) and title != "" ->
|
||||||
|
now = Persistence.now_ms()
|
||||||
|
|
||||||
|
conversation
|
||||||
|
|> ChatConversation.changeset(%{title: title, updated_at: now})
|
||||||
|
|> Repo.update()
|
||||||
|
|> case do
|
||||||
|
{:ok, updated_conversation} ->
|
||||||
|
{:ok, %{reply | conversation: format_conversation(updated_conversation)}}
|
||||||
|
|
||||||
|
{:error, _reason} ->
|
||||||
|
{:ok, reply}
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
{:ok, reply}
|
||||||
|
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(
|
||||||
|
_conversation,
|
||||||
|
_messages,
|
||||||
|
_endpoint,
|
||||||
|
_model,
|
||||||
|
_project_id,
|
||||||
|
_tools,
|
||||||
|
_runtime,
|
||||||
|
_opts,
|
||||||
|
0
|
||||||
|
) do
|
||||||
{:error, %{kind: :tool_loop_exhausted}}
|
{:error, %{kind: :tool_loop_exhausted}}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp chat_round(conversation, messages, endpoint, model, project_id, tools, runtime, opts, rounds_left) do
|
defp chat_round(
|
||||||
|
conversation,
|
||||||
|
messages,
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
project_id,
|
||||||
|
tools,
|
||||||
|
runtime,
|
||||||
|
opts,
|
||||||
|
rounds_left
|
||||||
|
) do
|
||||||
request = build_chat_request(conversation, messages, model, project_id, tools)
|
request = build_chat_request(conversation, messages, model, project_id, tools)
|
||||||
|
|
||||||
with {:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
|
with {:ok, response} <-
|
||||||
|
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, 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
|
if is_binary(Map.get(response, :content)) and String.trim(Map.get(response, :content)) != "" do
|
||||||
notify_chat_event(opts, {:chat_streaming_content, conversation.id, Map.get(response, :content)})
|
notify_chat_event(
|
||||||
|
opts,
|
||||||
|
{:chat_streaming_content, conversation.id, Map.get(response, :content)}
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
tool_calls = decode_tool_calls(Map.get(response, :tool_calls))
|
tool_calls = decode_tool_calls(Map.get(response, :tool_calls))
|
||||||
@@ -325,7 +585,8 @@ defmodule BDS.AI.Chat do
|
|||||||
|
|
||||||
cond do
|
cond do
|
||||||
tool_calls != [] and tools != [] ->
|
tool_calls != [] and tools != [] ->
|
||||||
with {:ok, tool_messages} <- execute_tool_calls(conversation.id, tool_calls, project_id, opts),
|
with {:ok, tool_messages} <-
|
||||||
|
execute_tool_calls(conversation.id, tool_calls, project_id, opts),
|
||||||
updated_messages <- load_chat_messages(conversation.id),
|
updated_messages <- load_chat_messages(conversation.id),
|
||||||
{:ok, reply} <-
|
{:ok, reply} <-
|
||||||
chat_round(
|
chat_round(
|
||||||
@@ -398,7 +659,7 @@ defmodule BDS.AI.Chat do
|
|||||||
end
|
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)}
|
system_message = %{"role" => "system", "content" => chat_system_prompt(project_id, tools)}
|
||||||
|
|
||||||
%{
|
%{
|
||||||
operation: :chat,
|
operation: :chat,
|
||||||
@@ -415,15 +676,54 @@ defmodule BDS.AI.Chat do
|
|||||||
defp message_for_runtime(%ChatMessage{} = message) do
|
defp message_for_runtime(%ChatMessage{} = message) do
|
||||||
base = %{"role" => Atom.to_string(message.role)}
|
base = %{"role" => Atom.to_string(message.role)}
|
||||||
|
|
||||||
base = if is_binary(message.content), do: Map.put(base, "content", message.content), else: base
|
base =
|
||||||
base = if is_binary(message.tool_call_id), do: Map.put(base, "tool_call_id", message.tool_call_id), else: base
|
if is_binary(message.content), do: Map.put(base, "content", message.content), else: base
|
||||||
|
|
||||||
|
base =
|
||||||
|
if is_binary(message.tool_call_id),
|
||||||
|
do: Map.put(base, "tool_call_id", message.tool_call_id),
|
||||||
|
else: base
|
||||||
|
|
||||||
case Catalog.decode_nullable_json(message.tool_calls) do
|
case Catalog.decode_nullable_json(message.tool_calls) do
|
||||||
nil -> base
|
nil -> base
|
||||||
tool_calls -> Map.put(base, "tool_calls", tool_calls)
|
tool_calls -> Map.put(base, "tool_calls", tool_calls_for_runtime(tool_calls))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp tool_calls_for_runtime(tool_calls) when is_list(tool_calls) do
|
||||||
|
Enum.map(tool_calls, &tool_call_for_runtime/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tool_calls_for_runtime(tool_calls), do: tool_calls
|
||||||
|
|
||||||
|
defp tool_call_for_runtime(%{"type" => "function", "function" => %{} = _function} = tool_call) do
|
||||||
|
tool_call
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tool_call_for_runtime(%{"id" => id, "name" => name} = tool_call) do
|
||||||
|
%{
|
||||||
|
"id" => id,
|
||||||
|
"type" => "function",
|
||||||
|
"function" => %{
|
||||||
|
"name" => name,
|
||||||
|
"arguments" => Jason.encode!(tool_call["arguments"] || %{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tool_call_for_runtime(%{id: id, name: name} = tool_call) do
|
||||||
|
%{
|
||||||
|
"id" => id,
|
||||||
|
"type" => "function",
|
||||||
|
"function" => %{
|
||||||
|
"name" => name,
|
||||||
|
"arguments" => Jason.encode!(Map.get(tool_call, :arguments) || %{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tool_call_for_runtime(tool_call), do: tool_call
|
||||||
|
|
||||||
defp truncate_chat_messages(messages, model, tools) do
|
defp truncate_chat_messages(messages, model, tools) do
|
||||||
context_window = model_context_window(model)
|
context_window = model_context_window(model)
|
||||||
reserve = min(@default_max_output_tokens, max(div(context_window, 4), 512))
|
reserve = min(@default_max_output_tokens, max(div(context_window, 4), 512))
|
||||||
@@ -433,7 +733,9 @@ defmodule BDS.AI.Chat do
|
|||||||
[system | remainder] = messages
|
[system | remainder] = messages
|
||||||
|
|
||||||
{kept, _tokens} =
|
{kept, _tokens} =
|
||||||
Enum.reduce(Enum.reverse(remainder), {[], approximate_message_tokens(system)}, fn message, {acc, used} ->
|
Enum.reduce(Enum.reverse(remainder), {[], approximate_message_tokens(system)}, fn message,
|
||||||
|
{acc,
|
||||||
|
used} ->
|
||||||
message_tokens = approximate_message_tokens(message)
|
message_tokens = approximate_message_tokens(message)
|
||||||
|
|
||||||
if used + message_tokens <= max_budget do
|
if used + message_tokens <= max_budget do
|
||||||
@@ -450,20 +752,55 @@ 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
|
||||||
|
|
||||||
defp chat_system_prompt(project_id) 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
|
||||||
|
|
||||||
case project_stats_summary(project_id) do
|
with true <- tools != [],
|
||||||
nil -> base
|
summary when is_binary(summary) <- project_stats_summary(project_id) do
|
||||||
summary -> base <> "\n\nCurrent blog statistics:\n" <> summary
|
base <> "\n\nCurrent blog statistics:\n" <> summary <> "\n\n" <> blog_tool_guidance()
|
||||||
|
else
|
||||||
|
_other -> base
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp blog_tool_guidance do
|
||||||
|
Enum.join(
|
||||||
|
[
|
||||||
|
"Available blog data tools:",
|
||||||
|
"- Use get_blog_stats for aggregate counts of posts, media, tags, and categories.",
|
||||||
|
"- Use search_posts for full-text blog search and filtered post lookup by category, tag, language, year, month, or status.",
|
||||||
|
"- Use read_post to read a post by ID, or read_post_by_slug to read a post by slug.",
|
||||||
|
"- Use read_post_by_slug to read full post content and metadata when a slug is known.",
|
||||||
|
"- Use list_posts when asked for post titles, slugs, URLs, statuses, backlinks, or recent/top/latest post lists. This is allowed project data access.",
|
||||||
|
"- Use get_media for one media item by ID, list_media for media titles, filenames, MIME types, or recent media lists, and view_image for visual image inspection.",
|
||||||
|
"- Use update_post_metadata and update_media_metadata when asked to change titles, excerpts, tags, categories, alt text, or captions.",
|
||||||
|
"- Use get_post_backlinks, get_post_outlinks, get_post_media, and get_media_posts for relationship 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.",
|
||||||
|
"",
|
||||||
|
"Available UI Render Tools:",
|
||||||
|
"- Use render_chart to show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use it when presenting statistics or comparisons. Prefer heatmap over tables with emoji or color indicators for intensity grids or calendar-style activity.",
|
||||||
|
"- Use render_table for tabular data, comparisons, and structured listings.",
|
||||||
|
"- Use render_form to collect structured user input.",
|
||||||
|
"- Use render_card for summaries, highlights, or actionable items.",
|
||||||
|
"- Use render_metric for a single KPI or important statistic.",
|
||||||
|
"- Use render_list for bullet lists, checklists, or simple enumerations.",
|
||||||
|
"- Use render_tabs to organize multiple views into switchable tabs; tab content can contain text, metrics, lists, charts, and tables.",
|
||||||
|
"When presenting data, statistics, or comparisons, prefer render tools over plain text. When building any visualization, render it as soon as you have enough data."
|
||||||
|
],
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
defp project_stats_summary(nil), do: nil
|
defp project_stats_summary(nil), do: nil
|
||||||
|
|
||||||
defp project_stats_summary(project_id) do
|
defp project_stats_summary(project_id) do
|
||||||
post_count = Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id)
|
post_count =
|
||||||
media_count = Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id)
|
Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id)
|
||||||
|
|
||||||
|
media_count =
|
||||||
|
Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id)
|
||||||
|
|
||||||
tag_count = count_distinct_string_list(Post, :tags, project_id)
|
tag_count = count_distinct_string_list(Post, :tags, project_id)
|
||||||
category_count = count_distinct_string_list(Post, :categories, project_id)
|
category_count = count_distinct_string_list(Post, :categories, project_id)
|
||||||
|
|
||||||
@@ -523,9 +860,14 @@ defmodule BDS.AI.Chat do
|
|||||||
10 -> {:error, :cancelled}
|
10 -> {:error, :cancelled}
|
||||||
end
|
end
|
||||||
|
|
||||||
:shutdown -> {:error, :cancelled}
|
:shutdown ->
|
||||||
{:shutdown, _detail} -> {:error, :cancelled}
|
{:error, :cancelled}
|
||||||
_other -> {:error, :cancelled}
|
|
||||||
|
{:shutdown, _detail} ->
|
||||||
|
{:error, :cancelled}
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
{:error, :cancelled}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -551,14 +893,22 @@ defmodule BDS.AI.Chat do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp approximate_value_tokens(value) when is_binary(value), do: div(String.length(value), 4) + 1
|
defp approximate_value_tokens(value) when is_binary(value), do: div(String.length(value), 4) + 1
|
||||||
defp approximate_value_tokens(value) when is_list(value), do: Enum.map(value, &approximate_value_tokens/1) |> Enum.sum()
|
|
||||||
defp approximate_value_tokens(value) when is_map(value), do: Jason.encode!(value) |> approximate_value_tokens()
|
defp approximate_value_tokens(value) when is_list(value),
|
||||||
|
do: Enum.map(value, &approximate_value_tokens/1) |> Enum.sum()
|
||||||
|
|
||||||
|
defp approximate_value_tokens(value) when is_map(value),
|
||||||
|
do: Jason.encode!(value) |> approximate_value_tokens()
|
||||||
|
|
||||||
defp approximate_value_tokens(_value), do: 1
|
defp approximate_value_tokens(_value), do: 1
|
||||||
|
|
||||||
defp model_context_window(model_id) do
|
defp model_context_window(model_id) do
|
||||||
case Catalog.get_catalog_model(model_id) do
|
case Catalog.get_catalog_model(model_id) do
|
||||||
{:ok, model} when is_integer(model.context_window) and model.context_window > 0 -> model.context_window
|
{:ok, model} when is_integer(model.context_window) and model.context_window > 0 ->
|
||||||
_other -> @default_context_window
|
model.context_window
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
@default_context_window
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -6,17 +6,30 @@ defmodule BDS.AI.ChatConversation do
|
|||||||
|
|
||||||
@primary_key {:id, :string, autogenerate: false}
|
@primary_key {:id, :string, autogenerate: false}
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
id: String.t(),
|
||||||
|
title: String.t() | nil,
|
||||||
|
model: String.t() | nil,
|
||||||
|
copilot_session_id: String.t() | nil,
|
||||||
|
surface_state: map() | nil,
|
||||||
|
created_at: integer() | nil,
|
||||||
|
updated_at: integer() | nil
|
||||||
|
}
|
||||||
|
|
||||||
schema "chat_conversations" do
|
schema "chat_conversations" 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], empty_values: [nil])
|
|> cast(attrs, [:id, :title, :model, :copilot_session_id, :surface_state, :created_at, :updated_at],
|
||||||
|
empty_values: [nil]
|
||||||
|
)
|
||||||
|> validate_required([:id, :title, :created_at, :updated_at])
|
|> validate_required([:id, :title, :created_at, :updated_at])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -23,18 +23,22 @@ defmodule BDS.AI.ChatMessage do
|
|||||||
|
|
||||||
def changeset(message, attrs) do
|
def changeset(message, attrs) do
|
||||||
message
|
message
|
||||||
|> cast(attrs, [
|
|> cast(
|
||||||
:conversation_id,
|
attrs,
|
||||||
:role,
|
[
|
||||||
:content,
|
:conversation_id,
|
||||||
:tool_call_id,
|
:role,
|
||||||
:tool_calls,
|
:content,
|
||||||
:token_usage_input,
|
:tool_call_id,
|
||||||
:token_usage_output,
|
:tool_calls,
|
||||||
:cache_read_tokens,
|
:token_usage_input,
|
||||||
:cache_write_tokens,
|
:token_usage_output,
|
||||||
:created_at
|
:cache_read_tokens,
|
||||||
], empty_values: [nil])
|
:cache_write_tokens,
|
||||||
|
:created_at
|
||||||
|
],
|
||||||
|
empty_values: [nil]
|
||||||
|
)
|
||||||
|> validate_required([:conversation_id, :role, :created_at])
|
|> validate_required([:conversation_id, :role, :created_at])
|
||||||
|> assoc_constraint(:conversation)
|
|> assoc_constraint(:conversation)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,37 +4,119 @@ defmodule BDS.AI.ChatTools do
|
|||||||
import Ecto.Query
|
import Ecto.Query
|
||||||
|
|
||||||
alias BDS.AI.Chat
|
alias BDS.AI.Chat
|
||||||
|
alias BDS.Media, as: MediaContext
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
|
alias BDS.MCP.Queries
|
||||||
|
alias BDS.Posts, as: PostsContext
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
|
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
|
||||||
|
execute("get_blog_stats", %{}, project_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("get_blog_stats", _arguments, project_id) do
|
||||||
project_id = project_id || active_project_id()
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
%{
|
%{
|
||||||
post_count: Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id),
|
post_count:
|
||||||
media_count: Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id),
|
Repo.aggregate(from(post in Post, where: post.project_id == ^project_id), :count, :id),
|
||||||
|
media_count:
|
||||||
|
Repo.aggregate(from(media in Media, where: media.project_id == ^project_id), :count, :id),
|
||||||
tag_count: Chat.count_distinct_string_list(Post, :tags, project_id),
|
tag_count: Chat.count_distinct_string_list(Post, :tags, project_id),
|
||||||
category_count: Chat.count_distinct_string_list(Post, :categories, project_id)
|
category_count: Chat.count_distinct_string_list(Post, :categories, project_id)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def execute("list_posts", arguments, project_id) do
|
def execute("check_term", arguments, project_id) do
|
||||||
limit = normalize_limit(arguments["limit"])
|
project_id = project_id || active_project_id()
|
||||||
|
term = normalize_term(arguments["term"])
|
||||||
|
|
||||||
Repo.all(
|
posts = Repo.all(from post in Post, where: post.project_id == ^project_id)
|
||||||
from(post in Post,
|
|
||||||
where: post.project_id == ^project_id,
|
tag_post_count =
|
||||||
order_by: [desc: post.updated_at],
|
Enum.count(posts, fn post ->
|
||||||
limit: ^limit,
|
Enum.any?(post.tags || [], &(normalize_term(&1) == term))
|
||||||
select: %{id: post.id, title: post.title, slug: post.slug, status: post.status}
|
end)
|
||||||
)
|
|
||||||
)
|
category_post_count =
|
||||||
|
Enum.count(posts, fn post ->
|
||||||
|
Enum.any?(post.categories || [], &(normalize_term(&1) == term))
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{
|
||||||
|
is_category: category_post_count > 0,
|
||||||
|
category_post_count: category_post_count,
|
||||||
|
is_tag: tag_post_count > 0,
|
||||||
|
tag_post_count: tag_post_count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("search_posts", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
filters = search_filters(arguments)
|
||||||
|
|
||||||
|
{:ok, result} = Search.search_posts(project_id, arguments["query"] || "", filters)
|
||||||
|
|
||||||
|
%{
|
||||||
|
posts: Enum.map(result.posts, &Queries.post_summary/1),
|
||||||
|
total: result.total,
|
||||||
|
offset: result.offset,
|
||||||
|
limit: result.limit,
|
||||||
|
has_more: result.offset + result.limit < result.total
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("read_post_by_slug", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
case Repo.get_by(Post, project_id: project_id, slug: arguments["slug"]) do
|
||||||
|
%Post{} = post -> %{post: Queries.post_detail(post)}
|
||||||
|
nil -> %{error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("read_post", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
case Repo.get_by(Post,
|
||||||
|
id: arguments["postId"] || arguments["post_id"],
|
||||||
|
project_id: project_id
|
||||||
|
) do
|
||||||
|
%Post{} = post -> %{post: Queries.post_detail(post)}
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("list_posts", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
limit = normalize_limit(arguments["limit"])
|
||||||
|
offset = normalize_offset(arguments["offset"])
|
||||||
|
filters = search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset})
|
||||||
|
|
||||||
|
{:ok, result} = Search.search_posts(project_id, "", filters)
|
||||||
|
|
||||||
|
%{
|
||||||
|
posts:
|
||||||
|
Enum.map(result.posts, fn post ->
|
||||||
|
post
|
||||||
|
|> 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()
|
||||||
limit = normalize_limit(arguments["limit"])
|
limit = normalize_limit(arguments["limit"])
|
||||||
|
|
||||||
Repo.all(
|
Repo.all(
|
||||||
@@ -46,12 +128,160 @@ defmodule BDS.AI.ChatTools do
|
|||||||
id: media.id,
|
id: media.id,
|
||||||
title: media.title,
|
title: media.title,
|
||||||
mime_type: media.mime_type,
|
mime_type: media.mime_type,
|
||||||
filename: media.filename
|
filename: media.filename,
|
||||||
|
updated_at: media.updated_at
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def execute("get_media", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
case Repo.get_by(Media,
|
||||||
|
id: arguments["mediaId"] || arguments["media_id"],
|
||||||
|
project_id: project_id
|
||||||
|
) do
|
||||||
|
%Media{} = media -> %{media: media_summary(media)}
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("view_image", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
media_id = arguments["mediaId"] || arguments["media_id"]
|
||||||
|
size = arguments["size"] || "medium"
|
||||||
|
|
||||||
|
case Repo.get_by(Media, id: media_id, project_id: project_id) do
|
||||||
|
%Media{mime_type: "image/" <> _rest} = media ->
|
||||||
|
case thumbnail_data_url(project_id, media, size) do
|
||||||
|
nil -> %{success: false, error: "thumbnail_not_available"}
|
||||||
|
data_url -> %{success: true, media: media_summary(media), data_url: data_url}
|
||||||
|
end
|
||||||
|
|
||||||
|
%Media{} = media ->
|
||||||
|
%{success: false, error: "not_image", mime_type: media.mime_type}
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
%{success: false, error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("update_post_metadata", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
post_id = arguments["postId"] || arguments["post_id"]
|
||||||
|
|
||||||
|
with %Post{} <- Repo.get_by(Post, id: post_id, project_id: project_id),
|
||||||
|
attrs <- metadata_attrs(arguments, ["title", "excerpt", "tags", "categories"]),
|
||||||
|
false <- attrs == %{},
|
||||||
|
{:ok, post} <- PostsContext.update_post(post_id, attrs) do
|
||||||
|
%{success: true, post: Queries.post_detail(post)}
|
||||||
|
else
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
true -> %{success: false, error: "no_updates_provided"}
|
||||||
|
{:error, reason} -> %{success: false, error: inspect(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("update_media_metadata", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
media_id = arguments["mediaId"] || arguments["media_id"]
|
||||||
|
|
||||||
|
with %Media{} <- Repo.get_by(Media, id: media_id, project_id: project_id),
|
||||||
|
attrs <- metadata_attrs(arguments, ["title", "alt", "caption", "tags"]),
|
||||||
|
false <- attrs == %{},
|
||||||
|
{:ok, media} <- MediaContext.update_media(media_id, attrs) do
|
||||||
|
%{success: true, media: media_summary(media)}
|
||||||
|
else
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
true -> %{success: false, error: "no_updates_provided"}
|
||||||
|
{:error, reason} -> %{success: false, error: inspect(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("list_tags", _arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
%{
|
||||||
|
tags: counted_terms(project_id, :tags),
|
||||||
|
count: length(counted_terms(project_id, :tags))
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("list_categories", _arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
%{
|
||||||
|
categories: counted_terms(project_id, :categories),
|
||||||
|
count: length(counted_terms(project_id, :categories))
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("count_posts", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
group_by = List.wrap(arguments["groupBy"] || arguments["group_by"]) |> Enum.map(&to_string/1)
|
||||||
|
result = search_all_counted_posts(project_id, arguments)
|
||||||
|
|
||||||
|
groups =
|
||||||
|
result.posts
|
||||||
|
|> Enum.flat_map(&Queries.group_rows(&1, group_by))
|
||||||
|
|> Enum.group_by(& &1, fn _row -> 1 end)
|
||||||
|
|> Enum.map(fn {row, counts} -> Map.put(row, "count", length(counts)) end)
|
||||||
|
|> Enum.sort_by(&Map.to_list/1)
|
||||||
|
|
||||||
|
%{groups: groups, total_posts: result.total}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("get_post_backlinks", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
case Repo.get_by(Post,
|
||||||
|
id: arguments["postId"] || arguments["post_id"],
|
||||||
|
project_id: project_id
|
||||||
|
) 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
|
||||||
|
|
||||||
|
def execute("get_post_outlinks", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
case Repo.get_by(Post,
|
||||||
|
id: arguments["postId"] || arguments["post_id"],
|
||||||
|
project_id: project_id
|
||||||
|
) 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
|
||||||
|
|
||||||
|
def execute("get_post_media", arguments, project_id) do
|
||||||
|
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
|
||||||
|
%Post{} = post -> %{success: true, post_id: post.id, media: post_media(project_id, post.id)}
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("get_media_posts", arguments, project_id) do
|
||||||
|
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
|
||||||
|
%Media{} = media -> %{success: true, media_id: media.id, posts: media_posts(media.id)}
|
||||||
|
nil -> %{success: false, error: "not_found"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def execute("render_table", arguments, _project_id) do
|
def execute("render_table", arguments, _project_id) do
|
||||||
%{
|
%{
|
||||||
type: "table",
|
type: "table",
|
||||||
@@ -65,7 +295,7 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
type: "chart",
|
type: "chart",
|
||||||
title: arguments["title"],
|
title: arguments["title"],
|
||||||
chart_type: arguments["chart_type"] || "bar",
|
chart_type: arguments["chartType"] || arguments["chart_type"] || "bar",
|
||||||
series: arguments["series"] || []
|
series: arguments["series"] || []
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -132,9 +362,193 @@ defmodule BDS.AI.ChatTools do
|
|||||||
project_tools =
|
project_tools =
|
||||||
if is_binary(project_id) do
|
if is_binary(project_id) do
|
||||||
[
|
[
|
||||||
%{name: "blog_stats", spec: tool_spec("blog_stats", "Return aggregate blog statistics", %{"type" => "object", "properties" => %{}})},
|
%{
|
||||||
%{name: "list_posts", spec: tool_spec("list_posts", "List recent posts in the active project", limit_schema())},
|
name: "blog_stats",
|
||||||
%{name: "list_media", spec: tool_spec("list_media", "List recent media items in the active project", limit_schema())}
|
spec:
|
||||||
|
tool_spec("blog_stats", "Return aggregate blog statistics", %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_blog_stats",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"get_blog_stats",
|
||||||
|
"Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.",
|
||||||
|
%{"type" => "object", "properties" => %{}}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "check_term",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"check_term",
|
||||||
|
"Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or list_posts when unsure whether a term is a category or tag.",
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{"term" => %{"type" => "string"}},
|
||||||
|
"required" => ["term"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "search_posts",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"search_posts",
|
||||||
|
"Search blog posts using full-text search. Can filter by category, tags, language, missing translation language, year, month, or status. Returns paginated concrete post data with titles, slugs, tags, categories, backlinks, and links_to.",
|
||||||
|
post_search_schema(true)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "read_post",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"read_post",
|
||||||
|
"Read full content and metadata of a specific blog post by ID. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{"postId" => %{"type" => "string"}},
|
||||||
|
"required" => ["postId"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "read_post_by_slug",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"read_post_by_slug",
|
||||||
|
"Read full content and metadata of a specific blog post by slug. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{"slug" => %{"type" => "string"}},
|
||||||
|
"required" => ["slug"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "list_posts",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"list_posts",
|
||||||
|
"List blog posts with optional filtering by status, category, tags, language, year, or month. Returns paginated concrete post data with titles, slugs, URLs, statuses, tags, categories, backlinks, and links_to. Use for recent, latest, top, or title-list requests.",
|
||||||
|
post_search_schema(false)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_media",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"get_media",
|
||||||
|
"Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.",
|
||||||
|
media_id_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "list_media",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"list_media",
|
||||||
|
"List concrete media data in the active project, including titles, filenames, MIME types, and update times.",
|
||||||
|
limit_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "view_image",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"view_image",
|
||||||
|
"View an image thumbnail as a local data URL for visual inspection. Only works with image media files.",
|
||||||
|
media_id_schema(%{
|
||||||
|
"size" => %{"type" => "string", "enum" => ["small", "medium", "large"]}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "update_post_metadata",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"update_post_metadata",
|
||||||
|
"Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.",
|
||||||
|
update_post_metadata_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "update_media_metadata",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"update_media_metadata",
|
||||||
|
"Update metadata for a media file: title, alt text, caption, or tags.",
|
||||||
|
update_media_metadata_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "list_tags",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"list_tags",
|
||||||
|
"List all tags used across blog posts with post counts.",
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "list_categories",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"list_categories",
|
||||||
|
"List all categories used across blog posts with post counts.",
|
||||||
|
%{"type" => "object", "properties" => %{}}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "count_posts",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"count_posts",
|
||||||
|
"Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.",
|
||||||
|
count_posts_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_post_backlinks",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"get_post_backlinks",
|
||||||
|
"Get all posts that link to a specific post.",
|
||||||
|
post_id_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_post_outlinks",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"get_post_outlinks",
|
||||||
|
"Get all posts that a specific post links to.",
|
||||||
|
post_id_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_post_media",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"get_post_media",
|
||||||
|
"Get media files linked to a specific post.",
|
||||||
|
post_id_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_media_posts",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"get_media_posts",
|
||||||
|
"Get posts that use a specific media file.",
|
||||||
|
media_id_schema()
|
||||||
|
)
|
||||||
|
}
|
||||||
]
|
]
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
@@ -142,14 +556,78 @@ defmodule BDS.AI.ChatTools do
|
|||||||
|
|
||||||
project_tools ++
|
project_tools ++
|
||||||
[
|
[
|
||||||
%{name: "render_card", spec: tool_spec("render_card", "Return a structured card payload", render_card_schema())},
|
%{
|
||||||
%{name: "render_table", spec: tool_spec("render_table", "Return a structured table payload", render_table_schema())},
|
name: "render_card",
|
||||||
%{name: "render_chart", spec: tool_spec("render_chart", "Return a structured chart payload", render_chart_schema())},
|
spec:
|
||||||
%{name: "render_form", spec: tool_spec("render_form", "Return a structured form payload", render_form_schema())},
|
tool_spec(
|
||||||
%{name: "render_metric", spec: tool_spec("render_metric", "Return a structured metric payload", render_metric_schema())},
|
"render_card",
|
||||||
%{name: "render_list", spec: tool_spec("render_list", "Return a structured list payload", render_list_schema())},
|
"Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.",
|
||||||
%{name: "render_tabs", spec: tool_spec("render_tabs", "Return a structured tabs payload", render_tabs_schema())},
|
render_card_schema()
|
||||||
%{name: "render_mindmap", spec: tool_spec("render_mindmap", "Return a structured mindmap payload", render_mindmap_schema())}
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "render_table",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"render_table",
|
||||||
|
"Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.",
|
||||||
|
render_table_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "render_chart",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"render_chart",
|
||||||
|
"Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.",
|
||||||
|
render_chart_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "render_form",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"render_form",
|
||||||
|
"Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.",
|
||||||
|
render_form_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "render_metric",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"render_metric",
|
||||||
|
"Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.",
|
||||||
|
render_metric_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "render_list",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"render_list",
|
||||||
|
"Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.",
|
||||||
|
render_list_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "render_tabs",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"render_tabs",
|
||||||
|
"Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.",
|
||||||
|
render_tabs_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "render_mindmap",
|
||||||
|
spec:
|
||||||
|
tool_spec(
|
||||||
|
"render_mindmap",
|
||||||
|
"Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.",
|
||||||
|
render_mindmap_schema()
|
||||||
|
)
|
||||||
|
}
|
||||||
]
|
]
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
@@ -176,13 +654,106 @@ defmodule BDS.AI.ChatTools do
|
|||||||
}
|
}
|
||||||
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
|
defp render_table_schema do
|
||||||
%{
|
%{
|
||||||
"type" => "object",
|
"type" => "object",
|
||||||
"properties" => %{
|
"properties" => %{
|
||||||
"title" => %{"type" => "string"},
|
"title" => %{"type" => "string", "description" => "Optional table title"},
|
||||||
"columns" => %{"type" => "array"},
|
"columns" => %{
|
||||||
"rows" => %{"type" => "array"}
|
"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
|
end
|
||||||
@@ -191,10 +762,40 @@ defmodule BDS.AI.ChatTools do
|
|||||||
%{
|
%{
|
||||||
"type" => "object",
|
"type" => "object",
|
||||||
"properties" => %{
|
"properties" => %{
|
||||||
"title" => %{"type" => "string"},
|
"chartType" => %{
|
||||||
"chart_type" => %{"type" => "string"},
|
"type" => "string",
|
||||||
"series" => %{"type" => "array"}
|
"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
|
end
|
||||||
|
|
||||||
@@ -265,6 +866,124 @@ defmodule BDS.AI.ChatTools do
|
|||||||
defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
|
defp normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
|
||||||
defp normalize_limit(_value), do: 10
|
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
|
||||||
|
Repo.all(
|
||||||
|
from post in Post, where: post.project_id == ^project_id, select: field(post, ^field)
|
||||||
|
)
|
||||||
|
|> List.flatten()
|
||||||
|
|> Enum.reject(&blank?/1)
|
||||||
|
|> Enum.frequencies()
|
||||||
|
|> Enum.map(fn {term, count} -> %{name: term, count: count} end)
|
||||||
|
|> Enum.sort_by(&String.downcase(to_string(&1.name)))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp metadata_attrs(arguments, keys) do
|
||||||
|
Enum.reduce(keys, %{}, fn key, acc ->
|
||||||
|
maybe_put(acc, BDS.MapUtils.safe_atomize_key(key), arguments[key])
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp media_summary(%Media{} = media) do
|
||||||
|
%{
|
||||||
|
id: media.id,
|
||||||
|
filename: media.filename,
|
||||||
|
original_name: media.original_name,
|
||||||
|
mime_type: media.mime_type,
|
||||||
|
size: media.size,
|
||||||
|
width: media.width,
|
||||||
|
height: media.height,
|
||||||
|
title: media.title,
|
||||||
|
alt: media.alt,
|
||||||
|
caption: media.caption,
|
||||||
|
author: media.author,
|
||||||
|
language: media.language,
|
||||||
|
tags: media.tags || [],
|
||||||
|
created_at: media.created_at,
|
||||||
|
updated_at: media.updated_at
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp post_media(project_id, post_id) do
|
||||||
|
Repo.all(
|
||||||
|
from media in Media,
|
||||||
|
join: post_media in PostMedia,
|
||||||
|
on: post_media.media_id == media.id,
|
||||||
|
where: post_media.project_id == ^project_id and post_media.post_id == ^post_id,
|
||||||
|
order_by: [asc: post_media.sort_order, asc: media.updated_at]
|
||||||
|
)
|
||||||
|
|> Enum.map(&media_summary/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp media_posts(media_id) do
|
||||||
|
MediaContext.list_linked_posts(media_id)
|
||||||
|
|> Enum.map(fn post ->
|
||||||
|
%{"id" => post.post_id, "title" => post.title, "sort_order" => post.sort_order}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp thumbnail_data_url(project_id, media, size) do
|
||||||
|
project = Repo.get!(Project, project_id)
|
||||||
|
size_key = thumbnail_size(size)
|
||||||
|
relative_path = MediaContext.thumbnail_paths(media)[size_key]
|
||||||
|
absolute_path = Path.join(project.data_path, relative_path || "")
|
||||||
|
|
||||||
|
with true <- is_binary(relative_path),
|
||||||
|
true <- File.exists?(absolute_path),
|
||||||
|
{:ok, binary} <- File.read(absolute_path) do
|
||||||
|
"data:#{thumbnail_mime(absolute_path)};base64," <> Base.encode64(binary)
|
||||||
|
else
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp thumbnail_size("small"), do: :small
|
||||||
|
defp thumbnail_size("large"), do: :large
|
||||||
|
defp thumbnail_size(_size), do: :medium
|
||||||
|
|
||||||
|
defp thumbnail_mime(path) do
|
||||||
|
case Path.extname(path) |> String.downcase() do
|
||||||
|
".jpg" -> "image/jpeg"
|
||||||
|
".jpeg" -> "image/jpeg"
|
||||||
|
".png" -> "image/png"
|
||||||
|
".webp" -> "image/webp"
|
||||||
|
_other -> "application/octet-stream"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp blank?(value), do: is_nil(value) or String.trim(to_string(value)) == ""
|
||||||
|
|
||||||
|
defp normalize_term(value), do: value |> to_string() |> String.downcase()
|
||||||
|
|
||||||
defp active_project_id do
|
defp active_project_id 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
|
||||||
|
|||||||
@@ -2,7 +2,11 @@ defmodule BDS.AI.HttpClient do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
def get(url, headers) when is_binary(url) and is_map(headers) do
|
def get(url, headers) when is_binary(url) and is_map(headers) do
|
||||||
request = {String.to_charlist(url), Enum.map(headers, fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end)}
|
request =
|
||||||
|
{String.to_charlist(url),
|
||||||
|
Enum.map(headers, fn {key, value} ->
|
||||||
|
{String.to_charlist(key), String.to_charlist(value)}
|
||||||
|
end)}
|
||||||
|
|
||||||
:inets.start()
|
:inets.start()
|
||||||
:ssl.start()
|
:ssl.start()
|
||||||
@@ -24,7 +28,10 @@ defmodule BDS.AI.HttpClient do
|
|||||||
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 =
|
request =
|
||||||
{String.to_charlist(url), Enum.map(headers, fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end), ~c"application/json", body}
|
{String.to_charlist(url),
|
||||||
|
Enum.map(headers, fn {key, value} ->
|
||||||
|
{String.to_charlist(key), String.to_charlist(value)}
|
||||||
|
end), ~c"application/json", body}
|
||||||
|
|
||||||
:inets.start()
|
:inets.start()
|
||||||
:ssl.start()
|
:ssl.start()
|
||||||
|
|||||||
@@ -34,31 +34,43 @@ defmodule BDS.AI.Model do
|
|||||||
|
|
||||||
def changeset(model, attrs) do
|
def changeset(model, attrs) do
|
||||||
model
|
model
|
||||||
|> cast(attrs, [
|
|> cast(
|
||||||
|
attrs,
|
||||||
|
[
|
||||||
|
:provider,
|
||||||
|
:model_id,
|
||||||
|
:name,
|
||||||
|
:family,
|
||||||
|
:supports_attachment,
|
||||||
|
:supports_reasoning,
|
||||||
|
:supports_tool_calls,
|
||||||
|
:supports_structured_output,
|
||||||
|
:supports_temperature,
|
||||||
|
:knowledge,
|
||||||
|
:release_date,
|
||||||
|
:last_updated_date,
|
||||||
|
:open_weights,
|
||||||
|
:input_price,
|
||||||
|
:output_price,
|
||||||
|
:cache_read_price,
|
||||||
|
:cache_write_price,
|
||||||
|
:context_window,
|
||||||
|
:max_input_tokens,
|
||||||
|
:max_output_tokens,
|
||||||
|
:interleaved,
|
||||||
|
:status,
|
||||||
|
:updated_at
|
||||||
|
],
|
||||||
|
empty_values: [nil]
|
||||||
|
)
|
||||||
|
|> validate_required([
|
||||||
:provider,
|
:provider,
|
||||||
:model_id,
|
:model_id,
|
||||||
:name,
|
:name,
|
||||||
:family,
|
|
||||||
:supports_attachment,
|
|
||||||
:supports_reasoning,
|
|
||||||
:supports_tool_calls,
|
|
||||||
:supports_structured_output,
|
|
||||||
:supports_temperature,
|
|
||||||
:knowledge,
|
|
||||||
:release_date,
|
|
||||||
:last_updated_date,
|
|
||||||
:open_weights,
|
|
||||||
:input_price,
|
|
||||||
:output_price,
|
|
||||||
:cache_read_price,
|
|
||||||
:cache_write_price,
|
|
||||||
:context_window,
|
:context_window,
|
||||||
:max_input_tokens,
|
:max_input_tokens,
|
||||||
:max_output_tokens,
|
:max_output_tokens,
|
||||||
:interleaved,
|
|
||||||
:status,
|
|
||||||
:updated_at
|
:updated_at
|
||||||
], empty_values: [nil])
|
])
|
||||||
|> validate_required([:provider, :model_id, :name, :context_window, :max_input_tokens, :max_output_tokens, :updated_at])
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
defmodule BDS.AI.OneShot do
|
defmodule BDS.AI.OneShot do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias BDS.AI.Chat
|
alias BDS.AI.Chat
|
||||||
alias BDS.AI.OpenAICompatibleRuntime
|
alias BDS.AI.OpenAICompatibleRuntime
|
||||||
alias BDS.AI.Runtime
|
alias BDS.AI.Runtime
|
||||||
alias BDS.Media.Media
|
alias BDS.Media.Media
|
||||||
|
alias BDS.MapUtils
|
||||||
|
alias BDS.Posts
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.Projects
|
||||||
alias BDS.Repo
|
alias BDS.Repo
|
||||||
|
|
||||||
@default_max_output_tokens 16_384
|
@default_max_output_tokens 16_384
|
||||||
@@ -45,10 +50,10 @@ defmodule BDS.AI.OneShot do
|
|||||||
def analyze_import_taxonomy(import_terms, existing_terms, opts \\ [])
|
def analyze_import_taxonomy(import_terms, existing_terms, opts \\ [])
|
||||||
when is_map(import_terms) and is_map(existing_terms) and is_list(opts) do
|
when is_map(import_terms) and is_map(existing_terms) and is_list(opts) do
|
||||||
payload = %{
|
payload = %{
|
||||||
import_categories: normalize_string_list(Map.get(import_terms, :categories) || Map.get(import_terms, "categories")),
|
import_categories: normalize_string_list(MapUtils.attr(import_terms, :categories)),
|
||||||
import_tags: normalize_string_list(Map.get(import_terms, :tags) || Map.get(import_terms, "tags")),
|
import_tags: normalize_string_list(MapUtils.attr(import_terms, :tags)),
|
||||||
existing_categories: normalize_string_list(Map.get(existing_terms, :categories) || Map.get(existing_terms, "categories")),
|
existing_categories: normalize_string_list(MapUtils.attr(existing_terms, :categories)),
|
||||||
existing_tags: normalize_string_list(Map.get(existing_terms, :tags) || Map.get(existing_terms, "tags"))
|
existing_tags: normalize_string_list(MapUtils.attr(existing_terms, :tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
run_one_shot(
|
run_one_shot(
|
||||||
@@ -96,7 +101,8 @@ defmodule BDS.AI.OneShot do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec translate_post(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
@spec translate_post(map() | String.t(), String.t(), keyword()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
def translate_post(post_input, target_language, opts \\ [])
|
def translate_post(post_input, target_language, opts \\ [])
|
||||||
when is_binary(target_language) and is_list(opts) do
|
when is_binary(target_language) and is_list(opts) do
|
||||||
with {:ok, post} <- normalize_post_input(post_input) do
|
with {:ok, post} <- normalize_post_input(post_input) do
|
||||||
@@ -138,7 +144,8 @@ defmodule BDS.AI.OneShot do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec translate_media(map() | String.t(), String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
@spec translate_media(map() | String.t(), String.t(), keyword()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
def translate_media(media_input, target_language, opts \\ [])
|
def translate_media(media_input, target_language, opts \\ [])
|
||||||
when is_binary(target_language) and is_list(opts) do
|
when is_binary(target_language) and is_list(opts) do
|
||||||
with {:ok, media} <- normalize_media_input(media_input) do
|
with {:ok, media} <- normalize_media_input(media_input) do
|
||||||
@@ -159,13 +166,15 @@ defmodule BDS.AI.OneShot do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp run_one_shot(operation, payload, opts, formatter) do
|
defp run_one_shot(:analyze_image = 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),
|
{:ok, payload} <- resolve_image_data_url(payload),
|
||||||
{:ok, response} <- runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts),
|
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),
|
{:ok, json} <- extract_json_response(response),
|
||||||
usage <- Chat.normalize_usage(response.usage),
|
usage <- Chat.normalize_usage(response.usage),
|
||||||
{:ok, result} <- formatter.(json, usage) do
|
{:ok, result} <- formatter.(json, usage) do
|
||||||
@@ -173,55 +182,100 @@ defmodule BDS.AI.OneShot do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_one_shot_request(operation, payload, model) do
|
defp run_one_shot(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),
|
||||||
|
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 build_one_shot_request(operation, payload, model, opts) do
|
||||||
|
language = Keyword.get(opts, :language)
|
||||||
|
|
||||||
|
source_language =
|
||||||
|
case Keyword.get(opts, :source_language) || Map.get(payload, :language) do
|
||||||
|
value when value in [nil, ""] -> nil
|
||||||
|
value -> value
|
||||||
|
end
|
||||||
|
|
||||||
%{
|
%{
|
||||||
operation: operation,
|
operation: operation,
|
||||||
model: model,
|
model: model,
|
||||||
max_output_tokens: @default_max_output_tokens,
|
max_output_tokens: @default_max_output_tokens,
|
||||||
messages: [
|
messages: [
|
||||||
%{"role" => "system", "content" => one_shot_system_prompt(operation)},
|
%{
|
||||||
%{"role" => "user", "content" => one_shot_user_content(operation, payload)}
|
"role" => "system",
|
||||||
|
"content" => one_shot_system_prompt(operation, language, source_language)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"role" => "user",
|
||||||
|
"content" => one_shot_user_content(operation, payload, language, source_language)
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_system_prompt(:detect_language) do
|
defp one_shot_system_prompt(:detect_language, _language, _source_language) do
|
||||||
"Return JSON with exactly one key: language_code."
|
"Return JSON with exactly one key: language_code."
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_system_prompt(:analyze_taxonomy) do
|
defp one_shot_system_prompt(:analyze_taxonomy, _language, _source_language) do
|
||||||
"Return JSON with keys tags and categories, each an array of short strings."
|
"Return JSON with keys tags and categories, each an array of short strings."
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_system_prompt(:import_taxonomy_mapping) do
|
defp one_shot_system_prompt(:import_taxonomy_mapping, _language, _source_language) do
|
||||||
"You are helping import WordPress taxonomy into an existing blog. Return JSON with exactly two keys: categoryMappings and tagMappings. Each value must be an object mapping imported term names to existing project term names. Only map when the imported term should reuse an existing term to avoid duplicates. Do not invent target terms. Leave unmapped items out of the objects."
|
"You are helping import WordPress taxonomy into an existing blog. Return JSON with exactly two keys: categoryMappings and tagMappings. Each value must be an object mapping imported term names to existing project term names. Only map when the imported term should reuse an existing term to avoid duplicates. Do not invent target terms. Leave unmapped items out of the objects."
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_system_prompt(:analyze_post) do
|
defp one_shot_system_prompt(:analyze_post, nil, _source_language) do
|
||||||
"Return JSON with keys title, excerpt, and slug."
|
"Return JSON with keys title, excerpt, and slug. Each value must be a single string (not an array or object)."
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_system_prompt(:translate_post) do
|
defp one_shot_system_prompt(:analyze_post, language, _source_language) do
|
||||||
|
"Return JSON with keys title, excerpt, and slug. Each value must be a single string (not an array or object). Respond in #{language_name(language)}."
|
||||||
|
end
|
||||||
|
|
||||||
|
defp one_shot_system_prompt(:translate_post, _language, nil) do
|
||||||
"Return JSON with keys title, excerpt, and content. Preserve Markdown structure."
|
"Return JSON with keys title, excerpt, and content. Preserve Markdown structure."
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_system_prompt(:analyze_image) do
|
defp one_shot_system_prompt(:translate_post, _language, source_language) do
|
||||||
|
"Return JSON with keys title, excerpt, and content. Preserve Markdown structure. Translate from #{language_name(source_language)} to the requested language."
|
||||||
|
end
|
||||||
|
|
||||||
|
defp one_shot_system_prompt(:analyze_image, nil, _source_language) do
|
||||||
"Return JSON with keys title, alt, and caption for the provided image."
|
"Return JSON with keys title, alt, and caption for the provided image."
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_system_prompt(:translate_media) do
|
defp one_shot_system_prompt(:analyze_image, language, _source_language) do
|
||||||
|
"Return JSON with keys title, alt, and caption for the provided image. Respond in #{language_name(language)}."
|
||||||
|
end
|
||||||
|
|
||||||
|
defp one_shot_system_prompt(:translate_media, _language, nil) do
|
||||||
"Return JSON with keys title, alt, and caption translated to the requested language."
|
"Return JSON with keys title, alt, and caption translated to the requested language."
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_user_content(:detect_language, %{text: text}) do
|
defp one_shot_system_prompt(:translate_media, _language, source_language) do
|
||||||
|
"Return JSON with keys title, alt, and caption. Translate from #{language_name(source_language)} to the requested language."
|
||||||
|
end
|
||||||
|
|
||||||
|
defp one_shot_user_content(:detect_language, %{text: text}, _language, _source_language) do
|
||||||
"Detect the language of this text: #{text}"
|
"Detect the language of this text: #{text}"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_user_content(:analyze_taxonomy, post) do
|
defp one_shot_user_content(:analyze_taxonomy, post, _language, _source_language) do
|
||||||
"Suggest categories and tags for the following post.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
|
"Suggest categories and tags for the following post.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_user_content(:import_taxonomy_mapping, payload) do
|
defp one_shot_user_content(:import_taxonomy_mapping, payload, _language, _source_language) do
|
||||||
[
|
[
|
||||||
"Analyze these imported taxonomy terms and suggest which ones should map to existing project terms.",
|
"Analyze these imported taxonomy terms and suggest which ones should map to existing project terms.",
|
||||||
"",
|
"",
|
||||||
@@ -242,38 +296,90 @@ defmodule BDS.AI.OneShot do
|
|||||||
|> Enum.join("\n")
|
|> Enum.join("\n")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_user_content(:analyze_post, post) do
|
defp one_shot_user_content(:analyze_post, post, nil, _source_language) do
|
||||||
"Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
|
"Suggest an improved title, excerpt, and slug.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_user_content(:translate_post, post) do
|
defp one_shot_user_content(:analyze_post, post, language, _source_language) do
|
||||||
"Translate this post to #{post.target_language}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}"
|
"Suggest an improved title, excerpt, and slug in #{language_name(language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_user_content(:analyze_image, media) do
|
defp one_shot_user_content(:translate_post, post, _language, nil) do
|
||||||
|
"Translate this post to #{language_name(post.target_language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp one_shot_user_content(:translate_post, post, _language, source_language) do
|
||||||
|
"Translate this post from #{language_name(source_language)} to #{language_name(post.target_language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{post.content}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp one_shot_user_content(:analyze_image, media, nil, _source_language) do
|
||||||
[
|
[
|
||||||
%{"type" => "text", "text" => "Analyze this image and return title, alt text, and caption."},
|
%{
|
||||||
|
"type" => "text",
|
||||||
|
"text" => "Analyze this image and return title, alt text, and caption."
|
||||||
|
},
|
||||||
%{"type" => "image_url", "image_url" => %{"url" => media.image_url}}
|
%{"type" => "image_url", "image_url" => %{"url" => media.image_url}}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
defp one_shot_user_content(:translate_media, media) do
|
defp one_shot_user_content(:analyze_image, media, language, _source_language) do
|
||||||
"Translate this media metadata to #{media.target_language}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}"
|
[
|
||||||
|
%{
|
||||||
|
"type" => "text",
|
||||||
|
"text" =>
|
||||||
|
"Analyze this image and return title, alt text, and caption in #{language_name(language)}."
|
||||||
|
},
|
||||||
|
%{"type" => "image_url", "image_url" => %{"url" => media.image_url}}
|
||||||
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp one_shot_user_content(:translate_media, media, _language, nil) do
|
||||||
|
"Translate this media metadata to #{language_name(media.target_language)}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp one_shot_user_content(:translate_media, media, _language, source_language) do
|
||||||
|
"Translate this media metadata from #{language_name(source_language)} to #{language_name(media.target_language)}.\nTitle: #{media.title}\nAlt: #{media.alt}\nCaption: #{media.caption}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp language_name("de"), do: "German"
|
||||||
|
defp language_name("en"), do: "English"
|
||||||
|
defp language_name("fr"), do: "French"
|
||||||
|
defp language_name("it"), do: "Italian"
|
||||||
|
defp language_name("es"), do: "Spanish"
|
||||||
|
defp language_name(language), do: String.capitalize(to_string(language))
|
||||||
|
|
||||||
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 Jason.decode(content) do
|
||||||
{:ok, json} when is_map(json) -> {:ok, json}
|
{:ok, json} when is_map(json) ->
|
||||||
_other -> {:error, %{kind: :invalid_json_response}}
|
{:ok, json}
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
Logger.error(
|
||||||
|
"AI extract_json_response failed to parse content as JSON. Content: #{String.slice(content, 0, 1000)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, %{kind: :invalid_json_response, content: content}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp extract_json_response(_response), do: {:error, %{kind: :invalid_json_response}}
|
defp extract_json_response(response) do
|
||||||
|
Logger.error(
|
||||||
|
"AI extract_json_response received response with no JSON and no content: #{inspect(Map.take(response, [:content, :json, :tool_calls]))}"
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, %{kind: :invalid_json_response}}
|
||||||
|
end
|
||||||
|
|
||||||
defp normalize_post_input(%Post{} = post) do
|
defp normalize_post_input(%Post{} = post) do
|
||||||
{:ok, %{title: post.title || "", excerpt: post.excerpt || "", content: post.content || ""}}
|
{:ok,
|
||||||
|
%{
|
||||||
|
title: post.title || "",
|
||||||
|
excerpt: post.excerpt || "",
|
||||||
|
content: Posts.editor_body(post),
|
||||||
|
language: post.language || ""
|
||||||
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_post_input(post_id) when is_binary(post_id) do
|
defp normalize_post_input(post_id) when is_binary(post_id) do
|
||||||
@@ -286,9 +392,10 @@ defmodule BDS.AI.OneShot do
|
|||||||
defp normalize_post_input(attrs) when is_map(attrs) do
|
defp normalize_post_input(attrs) when is_map(attrs) do
|
||||||
{:ok,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
title: Map.get(attrs, :title) || Map.get(attrs, "title") || "",
|
title: MapUtils.attr(attrs, :title) || "",
|
||||||
excerpt: Map.get(attrs, :excerpt) || Map.get(attrs, "excerpt") || "",
|
excerpt: MapUtils.attr(attrs, :excerpt) || "",
|
||||||
content: Map.get(attrs, :content) || Map.get(attrs, "content") || ""
|
content: MapUtils.attr(attrs, :content) || "",
|
||||||
|
language: MapUtils.attr(attrs, :language) || ""
|
||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -299,7 +406,10 @@ 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) || media_path_to_file_url(media.file_path)
|
image_url: Map.get(media, :image_url),
|
||||||
|
file_path: media.file_path,
|
||||||
|
project_id: media.project_id,
|
||||||
|
language: media.language || ""
|
||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -313,19 +423,84 @@ defmodule BDS.AI.OneShot do
|
|||||||
defp normalize_media_input(attrs) when is_map(attrs) do
|
defp normalize_media_input(attrs) when is_map(attrs) do
|
||||||
{:ok,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
mime_type: Map.get(attrs, :mime_type) || Map.get(attrs, "mime_type"),
|
mime_type: MapUtils.attr(attrs, :mime_type),
|
||||||
title: Map.get(attrs, :title) || Map.get(attrs, "title") || "",
|
title: MapUtils.attr(attrs, :title) || "",
|
||||||
alt: Map.get(attrs, :alt) || Map.get(attrs, "alt") || "",
|
alt: MapUtils.attr(attrs, :alt) || "",
|
||||||
caption: Map.get(attrs, :caption) || Map.get(attrs, "caption") || "",
|
caption: MapUtils.attr(attrs, :caption) || "",
|
||||||
image_url: Map.get(attrs, :image_url) || Map.get(attrs, "image_url")
|
image_url: MapUtils.attr(attrs, :image_url),
|
||||||
|
file_path: MapUtils.attr(attrs, :file_path),
|
||||||
|
project_id: MapUtils.attr(attrs, :project_id),
|
||||||
|
language: MapUtils.attr(attrs, :language) || ""
|
||||||
}}
|
}}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp ensure_image_media(%{mime_type: "image/" <> _rest}), do: :ok
|
defp ensure_image_media(%{mime_type: "image/" <> _rest}), do: :ok
|
||||||
defp ensure_image_media(_media), do: {:error, %{kind: :invalid_media_type}}
|
defp ensure_image_media(_media), do: {:error, %{kind: :invalid_media_type}}
|
||||||
|
|
||||||
defp media_path_to_file_url(nil), do: nil
|
defp resolve_image_data_url(%{image_url: "data:" <> _} = media) do
|
||||||
defp media_path_to_file_url(path), do: "file://" <> path
|
Logger.debug("AI analyze_image using existing data URL")
|
||||||
|
{:ok, media}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_image_data_url(%{image_url: "http" <> _} = media) do
|
||||||
|
Logger.debug("AI analyze_image using HTTP URL: #{media.image_url}")
|
||||||
|
{:ok, media}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_image_data_url(%{image_url: "file://" <> path, mime_type: mime_type} = media) do
|
||||||
|
with {:ok, binary} <- File.read(path) do
|
||||||
|
data_url = "data:#{mime_type};base64," <> Base.encode64(binary)
|
||||||
|
|
||||||
|
Logger.debug(
|
||||||
|
"AI analyze_image converted file://#{path} to data URL (#{byte_size(data_url)} chars)"
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, %{media | image_url: data_url}}
|
||||||
|
else
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("AI analyze_image failed to read file://#{path}: #{inspect(reason)}")
|
||||||
|
{:error, :file_not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_image_data_url(
|
||||||
|
%{file_path: file_path, project_id: project_id, mime_type: mime_type} = media
|
||||||
|
)
|
||||||
|
when is_binary(file_path) and is_binary(project_id) do
|
||||||
|
case Projects.get_project(project_id) do
|
||||||
|
nil ->
|
||||||
|
Logger.error("AI analyze_image project not found: #{project_id}")
|
||||||
|
{:error, :file_not_found}
|
||||||
|
|
||||||
|
project ->
|
||||||
|
absolute_path = Path.join(Projects.project_data_dir(project), file_path)
|
||||||
|
|
||||||
|
case File.read(absolute_path) do
|
||||||
|
{:ok, binary} ->
|
||||||
|
data_url = "data:#{mime_type};base64," <> Base.encode64(binary)
|
||||||
|
|
||||||
|
Logger.debug(
|
||||||
|
"AI analyze_image converted #{absolute_path} to data URL (#{byte_size(data_url)} chars)"
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, %{media | image_url: data_url}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("AI analyze_image failed to read #{absolute_path}: #{inspect(reason)}")
|
||||||
|
{:error, :file_not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_image_data_url(%{image_url: url} = media) when is_binary(url) and url != "" do
|
||||||
|
Logger.debug("AI analyze_image using URL: #{url}")
|
||||||
|
{:ok, media}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_image_data_url(_media) do
|
||||||
|
Logger.error("AI analyze_image missing image source (no file_path, project_id, or image_url)")
|
||||||
|
{:error, :missing_image_source}
|
||||||
|
end
|
||||||
|
|
||||||
defp normalize_string_list(values) do
|
defp normalize_string_list(values) do
|
||||||
values
|
values
|
||||||
@@ -336,7 +511,8 @@ defmodule BDS.AI.OneShot do
|
|||||||
|> Enum.uniq()
|
|> Enum.uniq()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp filter_taxonomy_mapping_response(mappings, import_terms, existing_terms) when is_map(mappings) do
|
defp filter_taxonomy_mapping_response(mappings, import_terms, existing_terms)
|
||||||
|
when is_map(mappings) do
|
||||||
import_lookup = canonical_term_lookup(import_terms)
|
import_lookup = canonical_term_lookup(import_terms)
|
||||||
existing_lookup = canonical_term_lookup(existing_terms)
|
existing_lookup = canonical_term_lookup(existing_terms)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
defmodule BDS.AI.OpenAICompatibleRuntime do
|
defmodule BDS.AI.OpenAICompatibleRuntime do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias BDS.AI.HttpClient
|
alias BDS.AI.HttpClient
|
||||||
|
|
||||||
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
|
||||||
@@ -30,40 +32,76 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
|||||||
}
|
}
|
||||||
|> maybe_put_auth(endpoint.api_key)
|
|> maybe_put_auth(endpoint.api_key)
|
||||||
|
|
||||||
payload = %{
|
payload =
|
||||||
"model" => request.model,
|
%{
|
||||||
"messages" => request.messages,
|
"model" => request.model,
|
||||||
"max_tokens" => request.max_output_tokens
|
"messages" => request.messages,
|
||||||
}
|
"max_tokens" => request.max_output_tokens
|
||||||
|> maybe_put_tools(request.tools)
|
}
|
||||||
|
|> maybe_disable_thinking(request.model)
|
||||||
|
|> maybe_put_tools(Map.get(request, :tools, []))
|
||||||
|
|
||||||
with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)),
|
payload_json = Jason.encode!(payload)
|
||||||
200 <- response.status do
|
|
||||||
normalize_response(response.body)
|
Logger.debug(
|
||||||
else
|
"AI OpenAI-compatible request operation=#{inspect(Map.get(request, :operation))} model=#{inspect(request.model)} url=#{url} tools=#{payload |> Map.get("tools", []) |> length()} payload_size=#{byte_size(payload_json)}"
|
||||||
status when is_integer(status) -> {:error, %{kind: :http_error, status: status}}
|
)
|
||||||
{:error, reason} -> {:error, %{kind: :http_error, reason: reason}}
|
|
||||||
|
case HttpClient.post(url, headers, payload_json) do
|
||||||
|
{:ok, %{status: 200, body: body}} ->
|
||||||
|
result = normalize_response(body)
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:ok, %{json: nil, content: content}} when is_binary(content) ->
|
||||||
|
Logger.debug(
|
||||||
|
"AI OpenAI-compatible response parsed but content is not valid JSON. Content: #{String.slice(content, 0, 500)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, _} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error(
|
||||||
|
"AI OpenAI-compatible response normalization failed: #{inspect(reason)} body=#{String.slice(body, 0, 1000)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
result
|
||||||
|
|
||||||
|
{:ok, %{status: status, body: body}} ->
|
||||||
|
Logger.error(
|
||||||
|
"AI OpenAI-compatible HTTP error status=#{status} body=#{String.slice(body, 0, 2000)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
{:error, %{kind: :http_error, status: status, body: body}}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("AI OpenAI-compatible HTTP request failed: #{inspect(reason)}")
|
||||||
|
{:error, %{kind: :http_error, reason: reason}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_response(body) do
|
defp normalize_response(body) do
|
||||||
payload = Jason.decode!(body)
|
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"]) || %{}
|
||||||
content = normalize_content(message["content"])
|
content = normalize_content(message["content"])
|
||||||
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 =
|
json =
|
||||||
case content do
|
case content do
|
||||||
nil -> nil
|
nil ->
|
||||||
value when is_binary(value) ->
|
nil
|
||||||
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}}
|
value when is_binary(value) ->
|
||||||
|
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
|
||||||
|
|
||||||
defp completions_url(url) do
|
defp completions_url(url) do
|
||||||
@@ -76,37 +114,53 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
|||||||
|
|
||||||
defp models_url(url) do
|
defp models_url(url) do
|
||||||
cond do
|
cond do
|
||||||
String.ends_with?(url, "/chat/completions") -> String.replace_suffix(url, "/chat/completions", "/models")
|
String.ends_with?(url, "/chat/completions") ->
|
||||||
String.ends_with?(url, "/models") -> url
|
String.replace_suffix(url, "/chat/completions", "/models")
|
||||||
String.ends_with?(url, "/") -> url <> "models"
|
|
||||||
true -> url <> "/models"
|
String.ends_with?(url, "/models") ->
|
||||||
|
url
|
||||||
|
|
||||||
|
String.ends_with?(url, "/") ->
|
||||||
|
url <> "models"
|
||||||
|
|
||||||
|
true ->
|
||||||
|
url <> "/models"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_models_response(body) do
|
defp normalize_models_response(body) do
|
||||||
payload = Jason.decode!(body)
|
with {:ok, payload} <- decode_json_body(body) do
|
||||||
|
models =
|
||||||
|
payload
|
||||||
|
|> Map.get("data", [])
|
||||||
|
|> Enum.map(fn entry ->
|
||||||
|
id = entry["id"] || entry[:id]
|
||||||
|
|
||||||
models =
|
%{
|
||||||
payload
|
id: id,
|
||||||
|> Map.get("data", [])
|
label: id
|
||||||
|> Enum.map(fn entry ->
|
}
|
||||||
id = entry["id"] || entry[:id]
|
end)
|
||||||
|
|> Enum.reject(&is_nil(&1.id))
|
||||||
|
|> Enum.uniq_by(& &1.id)
|
||||||
|
|> Enum.sort_by(&String.downcase(&1.id))
|
||||||
|
|
||||||
%{
|
{:ok, models}
|
||||||
id: id,
|
end
|
||||||
label: id
|
end
|
||||||
}
|
|
||||||
end)
|
|
||||||
|> Enum.reject(&is_nil(&1.id))
|
|
||||||
|> Enum.uniq_by(& &1.id)
|
|
||||||
|> Enum.sort_by(&String.downcase(&1.id))
|
|
||||||
|
|
||||||
{:ok, models}
|
defp decode_json_body(body) do
|
||||||
|
case Jason.decode(body) do
|
||||||
|
{:ok, payload} -> {:ok, payload}
|
||||||
|
{:error, reason} -> {:error, %{kind: :invalid_json_response, reason: reason}}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
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
|
||||||
defp maybe_put_auth(headers, api_key), do: Map.put(headers, "authorization", "Bearer #{api_key}")
|
|
||||||
|
defp maybe_put_auth(headers, api_key),
|
||||||
|
do: Map.put(headers, "authorization", "Bearer #{api_key}")
|
||||||
|
|
||||||
defp maybe_put_tools(payload, []), do: payload
|
defp maybe_put_tools(payload, []), do: payload
|
||||||
defp maybe_put_tools(payload, nil), do: payload
|
defp maybe_put_tools(payload, nil), do: payload
|
||||||
@@ -116,6 +170,18 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
|||||||
|> Map.put("tool_choice", "auto")
|
|> Map.put("tool_choice", "auto")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp maybe_disable_thinking(payload, model) when is_binary(model) do
|
||||||
|
if BDS.AI.Catalog.model_capabilities(model).disables_reasoning do
|
||||||
|
Map.update(payload, "chat_template_kwargs", %{"enable_thinking" => false}, fn kwargs ->
|
||||||
|
Map.put(kwargs || %{}, "enable_thinking", false)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
payload
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_disable_thinking(payload, _model), do: payload
|
||||||
|
|
||||||
defp normalize_tool_calls(tool_calls) do
|
defp normalize_tool_calls(tool_calls) do
|
||||||
Enum.map(tool_calls, fn tool_call ->
|
Enum.map(tool_calls, fn tool_call ->
|
||||||
%{
|
%{
|
||||||
|
|||||||
@@ -32,10 +32,18 @@ defmodule BDS.AI.Runtime do
|
|||||||
|
|
||||||
@spec validate_target(atom(), String.t(), :airplane | :online) :: :ok | {:error, term()}
|
@spec validate_target(atom(), String.t(), :airplane | :online) :: :ok | {:error, term()}
|
||||||
def validate_target(:analyze_image, model, _mode) do
|
def validate_target(:analyze_image, model, _mode) do
|
||||||
if Catalog.model_capabilities(model).supports_attachment do
|
capabilities = Catalog.model_capabilities(model)
|
||||||
:ok
|
|
||||||
else
|
cond do
|
||||||
{:error, %{kind: :model_capability_missing, capability: :supports_attachment, model: model}}
|
capabilities.supports_attachment ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
capabilities.supports_attachment == false ->
|
||||||
|
{:error,
|
||||||
|
%{kind: :model_capability_missing, capability: :supports_attachment, model: model}}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -65,7 +73,9 @@ defmodule BDS.AI.Runtime do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, extra) do
|
defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, extra) do
|
||||||
{:ok, Keyword.get(extra, :model) || model_preference_value(:airplane_image_analysis) || endpoint.model}
|
{:ok,
|
||||||
|
Keyword.get(extra, :model) || model_preference_value(:airplane_image_analysis) ||
|
||||||
|
endpoint.model}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp resolve_model_for_operation(:analyze_image, :online, endpoint, extra) do
|
defp resolve_model_for_operation(:analyze_image, :online, endpoint, extra) do
|
||||||
@@ -83,7 +93,8 @@ defmodule BDS.AI.Runtime do
|
|||||||
defp fetch_endpoint_for_mode(mode, secret_backend) do
|
defp fetch_endpoint_for_mode(mode, secret_backend) do
|
||||||
with {:ok, endpoint} <- AI.get_endpoint(mode, secret_backend: secret_backend) do
|
with {:ok, endpoint} <- AI.get_endpoint(mode, secret_backend: secret_backend) do
|
||||||
case endpoint do
|
case endpoint do
|
||||||
%{url: url, model: model} = loaded when is_binary(url) and url != "" and is_binary(model) and model != "" ->
|
%{url: url, model: model} = loaded
|
||||||
|
when is_binary(url) and url != "" and is_binary(model) and model != "" ->
|
||||||
if mode == :online and blank?(loaded.api_key) do
|
if mode == :online and blank?(loaded.api_key) do
|
||||||
{:error, %{kind: :endpoint_not_configured, endpoint: mode}}
|
{:error, %{kind: :endpoint_not_configured, endpoint: mode}}
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -17,7 +17,15 @@ defmodule BDS.AI.SecretBackend 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(:aes_256_gcm, secret_key(), iv, ciphertext, @aad, tag, false) do
|
:crypto.crypto_one_time_aead(
|
||||||
|
:aes_256_gcm,
|
||||||
|
secret_key(),
|
||||||
|
iv,
|
||||||
|
ciphertext,
|
||||||
|
@aad,
|
||||||
|
tag,
|
||||||
|
false
|
||||||
|
) do
|
||||||
{:ok, plaintext}
|
{:ok, plaintext}
|
||||||
else
|
else
|
||||||
_other -> {:error, :invalid_ciphertext}
|
_other -> {:error, :invalid_ciphertext}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ defmodule BDS.AI.SettingsStore do
|
|||||||
def put_setting(key, value) when is_binary(key) and is_binary(value) do
|
def put_setting(key, value) when is_binary(key) and is_binary(value) do
|
||||||
now = Persistence.now_ms()
|
now = Persistence.now_ms()
|
||||||
|
|
||||||
(%Setting{}
|
%Setting{}
|
||||||
|> Setting.changeset(%{key: key, value: value, updated_at: now}))
|
|> Setting.changeset(%{key: key, value: value, updated_at: now})
|
||||||
|> Repo.insert(
|
|> Repo.insert(
|
||||||
on_conflict: [set: [value: value, updated_at: now]],
|
on_conflict: [set: [value: value, updated_at: now]],
|
||||||
conflict_target: [:key]
|
conflict_target: [:key]
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ defmodule BDS.Application do
|
|||||||
BDS.Preview,
|
BDS.Preview,
|
||||||
BDS.Publishing,
|
BDS.Publishing,
|
||||||
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
|
{Task.Supervisor, name: BDS.Tasks.TaskSupervisor},
|
||||||
|
{Task.Supervisor, name: BDS.TCP.TaskSupervisor},
|
||||||
BDS.Scripting.JobStore,
|
BDS.Scripting.JobStore,
|
||||||
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
{Task.Supervisor, name: BDS.Scripting.TaskSupervisor},
|
||||||
BDS.Scripting.JobSupervisor
|
BDS.Scripting.JobSupervisor
|
||||||
|
|||||||
114
lib/bds/bounded_atoms.ex
Normal file
114
lib/bds/bounded_atoms.ex
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
defmodule BDS.BoundedAtoms do
|
||||||
|
@moduledoc """
|
||||||
|
Safe conversion of dynamic values to atoms from pre-defined allow-lists,
|
||||||
|
preventing atom table exhaustion from untrusted input.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias BDS.UI.Registry
|
||||||
|
alias BDS.UI.MenuBar
|
||||||
|
|
||||||
|
@panel_tabs [:tasks, :output, :post_links, :git_log]
|
||||||
|
@post_statuses [:draft, :published, :archived]
|
||||||
|
@translation_statuses [:draft, :published]
|
||||||
|
@script_kinds [:macro, :utility, :transform]
|
||||||
|
@template_kinds [:post, :list, :not_found, :partial]
|
||||||
|
@menu_kinds [:page, :submenu, :category_archive, :home]
|
||||||
|
@import_sections [
|
||||||
|
:post_conflicts,
|
||||||
|
:page_conflicts,
|
||||||
|
:posts,
|
||||||
|
:other,
|
||||||
|
:pages,
|
||||||
|
:media,
|
||||||
|
:taxonomy,
|
||||||
|
:macros
|
||||||
|
]
|
||||||
|
@taxonomy_types [:categories, :tags]
|
||||||
|
@ai_endpoints [:online, :airplane]
|
||||||
|
@mcp_agents [
|
||||||
|
:claude_code,
|
||||||
|
:claude_desktop,
|
||||||
|
:github_copilot,
|
||||||
|
:gemini_cli,
|
||||||
|
:opencode,
|
||||||
|
:mistral_vibe,
|
||||||
|
:openai_codex
|
||||||
|
]
|
||||||
|
@shell_commands [
|
||||||
|
:toggle_sidebar,
|
||||||
|
:toggle_panel,
|
||||||
|
:toggle_assistant_sidebar,
|
||||||
|
:view_posts,
|
||||||
|
:view_media,
|
||||||
|
:edit_preferences,
|
||||||
|
:open_in_browser,
|
||||||
|
:open_data_folder,
|
||||||
|
:preview_post,
|
||||||
|
:edit_menu,
|
||||||
|
:rebuild_database,
|
||||||
|
:reindex_text,
|
||||||
|
:rebuild_embedding_index,
|
||||||
|
:metadata_diff,
|
||||||
|
:regenerate_calendar,
|
||||||
|
:validate_translations,
|
||||||
|
:fill_missing_translations,
|
||||||
|
:find_duplicates,
|
||||||
|
:generate_sitemap,
|
||||||
|
:validate_site,
|
||||||
|
:upload_site,
|
||||||
|
:documentation,
|
||||||
|
:api_documentation,
|
||||||
|
:close_tab
|
||||||
|
]
|
||||||
|
@menu_actions MenuBar.default_groups(dev_mode?: true)
|
||||||
|
|> Enum.flat_map(fn group ->
|
||||||
|
Enum.flat_map(group.items, fn
|
||||||
|
%{separator: true} -> []
|
||||||
|
%{id: id} -> [id]
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
def atom(value, allowed, fallback \\ nil)
|
||||||
|
|
||||||
|
def atom(value, allowed, fallback) when is_atom(value) do
|
||||||
|
if value in allowed, do: value, else: fallback
|
||||||
|
end
|
||||||
|
|
||||||
|
def atom(value, allowed, fallback) when is_binary(value),
|
||||||
|
do: string_atom(value, allowed, fallback)
|
||||||
|
|
||||||
|
def atom(_value, _allowed, fallback), do: fallback
|
||||||
|
|
||||||
|
def sidebar_view(value, fallback \\ nil), do: atom(value, sidebar_views(), fallback)
|
||||||
|
def editor_route(value, fallback \\ nil), do: atom(value, editor_routes(), fallback)
|
||||||
|
def panel_tab(value, fallback \\ nil), do: atom(value, @panel_tabs, fallback)
|
||||||
|
def post_status(value, fallback \\ nil), do: atom(value, @post_statuses, fallback)
|
||||||
|
def translation_status(value, fallback \\ nil), do: atom(value, @translation_statuses, fallback)
|
||||||
|
def script_kind(value, fallback \\ nil), do: atom(value, @script_kinds, fallback)
|
||||||
|
def template_kind(value, fallback \\ nil), do: atom(value, @template_kinds, fallback)
|
||||||
|
|
||||||
|
def menu_kind(value, fallback \\ nil),
|
||||||
|
do: atom(normalize_menu_kind(value), @menu_kinds, fallback)
|
||||||
|
|
||||||
|
def import_section(value, fallback \\ nil), do: atom(value, @import_sections, fallback)
|
||||||
|
def taxonomy_type(value, fallback \\ nil), do: atom(value, @taxonomy_types, fallback)
|
||||||
|
def ai_endpoint(value, fallback \\ nil), do: atom(value, @ai_endpoints, fallback)
|
||||||
|
def mcp_agent(value, fallback \\ nil), do: atom(value, @mcp_agents, fallback)
|
||||||
|
def shell_command(value, fallback \\ nil), do: atom(value, @shell_commands, fallback)
|
||||||
|
def menu_action(value, fallback \\ nil), do: atom(value, @menu_actions, fallback)
|
||||||
|
|
||||||
|
defp string_atom(value, allowed, fallback) do
|
||||||
|
Enum.find(allowed, fallback, &(Atom.to_string(&1) == value))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sidebar_views do
|
||||||
|
Enum.map(Registry.sidebar_views(), & &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp editor_routes do
|
||||||
|
Enum.map(Registry.editor_routes(), & &1.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_menu_kind("category-archive"), do: "category_archive"
|
||||||
|
defp normalize_menu_kind(value), do: value
|
||||||
|
end
|
||||||
@@ -39,12 +39,18 @@ defmodule BDS.CliSync do
|
|||||||
ids = Enum.map(notifications, & &1.id)
|
ids = Enum.map(notifications, & &1.id)
|
||||||
|
|
||||||
if ids != [] do
|
if ids != [] do
|
||||||
Repo.update_all(from(notification in Notification, where: notification.id in ^ids), set: [seen_at: now])
|
Repo.update_all(from(notification in Notification, where: notification.id in ^ids),
|
||||||
|
set: [seen_at: now]
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
Enum.map(notifications, fn notification ->
|
Enum.map(notifications, fn notification ->
|
||||||
%{entity_type: notification.entity_type, entity_id: notification.entity_id, action: notification.action}
|
%{
|
||||||
|
entity_type: notification.entity_type,
|
||||||
|
entity_id: notification.entity_id,
|
||||||
|
action: notification.action
|
||||||
|
}
|
||||||
end)}
|
end)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -52,13 +58,17 @@ defmodule BDS.CliSync do
|
|||||||
{processed_count, _} =
|
{processed_count, _} =
|
||||||
Repo.delete_all(
|
Repo.delete_all(
|
||||||
from notification in Notification,
|
from notification in Notification,
|
||||||
where: not is_nil(notification.seen_at) and notification.created_at <= ^(now - @processed_ttl_ms)
|
where:
|
||||||
|
not is_nil(notification.seen_at) and
|
||||||
|
notification.created_at <= ^(now - @processed_ttl_ms)
|
||||||
)
|
)
|
||||||
|
|
||||||
{unprocessed_count, _} =
|
{unprocessed_count, _} =
|
||||||
Repo.delete_all(
|
Repo.delete_all(
|
||||||
from notification in Notification,
|
from notification in Notification,
|
||||||
where: is_nil(notification.seen_at) and notification.created_at <= ^(now - @unprocessed_ttl_ms)
|
where:
|
||||||
|
is_nil(notification.seen_at) and
|
||||||
|
notification.created_at <= ^(now - @unprocessed_ttl_ms)
|
||||||
)
|
)
|
||||||
|
|
||||||
{:ok, %{processed: processed_count, unprocessed: unprocessed_count}}
|
{:ok, %{processed: processed_count, unprocessed: unprocessed_count}}
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ defmodule BDS.CliSync.Notification do
|
|||||||
|
|
||||||
def changeset(notification, attrs) do
|
def changeset(notification, attrs) do
|
||||||
notification
|
notification
|
||||||
|> cast(attrs, [:entity_type, :entity_id, :action, :from_cli, :seen_at, :created_at], empty_values: [nil])
|
|> cast(attrs, [:entity_type, :entity_id, :action, :from_cli, :seen_at, :created_at],
|
||||||
|
empty_values: [nil]
|
||||||
|
)
|
||||||
|> validate_required([:entity_type, :entity_id, :action, :from_cli, :created_at])
|
|> validate_required([:entity_type, :entity_id, :action, :from_cli, :created_at])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -24,7 +24,11 @@ defmodule BDS.CliSync.Watcher do
|
|||||||
@impl true
|
@impl true
|
||||||
def init(opts) do
|
def init(opts) do
|
||||||
state = %{
|
state = %{
|
||||||
poll_interval_ms: normalize_positive_integer(Keyword.get(opts, :poll_interval_ms), @default_poll_interval_ms),
|
poll_interval_ms:
|
||||||
|
normalize_positive_integer(
|
||||||
|
Keyword.get(opts, :poll_interval_ms),
|
||||||
|
@default_poll_interval_ms
|
||||||
|
),
|
||||||
pubsub: Keyword.get(opts, :pubsub, BDS.PubSub)
|
pubsub: Keyword.get(opts, :pubsub, BDS.PubSub)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +53,11 @@ defmodule BDS.CliSync.Watcher do
|
|||||||
{:ok, _pruned} = CliSync.prune_notifications()
|
{:ok, _pruned} = CliSync.prune_notifications()
|
||||||
|
|
||||||
Enum.each(notifications, fn notification ->
|
Enum.each(notifications, fn notification ->
|
||||||
Phoenix.PubSub.broadcast(state.pubsub, topic(), {:entity_changed, notification_payload(notification)})
|
Phoenix.PubSub.broadcast(
|
||||||
|
state.pubsub,
|
||||||
|
topic(),
|
||||||
|
{:entity_changed, notification_payload(notification)}
|
||||||
|
)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
state
|
state
|
||||||
|
|||||||
@@ -107,7 +107,9 @@ defmodule BDS.Desktop.Automation do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_call({:native_menu_action, action}, _from, state) do
|
def handle_call({:native_menu_action, action}, _from, state) do
|
||||||
{reply, state} = driver_request(state, %{"command" => "native_menu_action", "action" => action})
|
{reply, state} =
|
||||||
|
driver_request(state, %{"command" => "native_menu_action", "action" => action})
|
||||||
|
|
||||||
{:reply, normalize_simple_reply(reply), state}
|
{:reply, normalize_simple_reply(reply), state}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -204,7 +206,9 @@ defmodule BDS.Desktop.Automation do
|
|||||||
|
|
||||||
receive_driver_message(state, @request_timeout, fn message ->
|
receive_driver_message(state, @request_timeout, fn message ->
|
||||||
case message do
|
case message do
|
||||||
%{"ref" => ^ref, "status" => "ok", "result" => result} -> {:ok, result}
|
%{"ref" => ^ref, "status" => "ok", "result" => result} ->
|
||||||
|
{:ok, result}
|
||||||
|
|
||||||
%{"ref" => ^ref, "status" => "error", "message" => reason} ->
|
%{"ref" => ^ref, "status" => "error", "message" => reason} ->
|
||||||
raise "desktop automation request failed: #{reason}"
|
raise "desktop automation request failed: #{reason}"
|
||||||
|
|
||||||
@@ -242,7 +246,8 @@ defmodule BDS.Desktop.Automation do
|
|||||||
defp process_driver_messages(state, deadline, matcher) do
|
defp process_driver_messages(state, deadline, matcher) do
|
||||||
{messages, buffer} = split_driver_buffer(state.driver_buffer)
|
{messages, buffer} = split_driver_buffer(state.driver_buffer)
|
||||||
|
|
||||||
case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message, {acc, _} ->
|
case Enum.reduce_while(messages, {%{state | driver_buffer: buffer}, nil}, fn message,
|
||||||
|
{acc, _} ->
|
||||||
case decode_driver_message(message) do
|
case decode_driver_message(message) do
|
||||||
:skip ->
|
:skip ->
|
||||||
{:cont, {acc, nil}}
|
{:cont, {acc, nil}}
|
||||||
@@ -259,7 +264,11 @@ defmodule BDS.Desktop.Automation do
|
|||||||
|
|
||||||
receive do
|
receive do
|
||||||
{port, {:data, data}} when port == state.driver_port ->
|
{port, {:data, data}} when port == state.driver_port ->
|
||||||
process_driver_messages(%{state | driver_buffer: state.driver_buffer <> data}, deadline, matcher)
|
process_driver_messages(
|
||||||
|
%{state | driver_buffer: state.driver_buffer <> data},
|
||||||
|
deadline,
|
||||||
|
matcher
|
||||||
|
)
|
||||||
|
|
||||||
{port, {:exit_status, status}} when port == state.driver_port ->
|
{port, {:exit_status, status}} when port == state.driver_port ->
|
||||||
raise "desktop automation driver exited with status #{status}"
|
raise "desktop automation driver exited with status #{status}"
|
||||||
@@ -311,7 +320,9 @@ defmodule BDS.Desktop.Automation do
|
|||||||
|
|
||||||
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 :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do
|
||||||
{:ok, {{_, 200, _}, _headers, _body}} -> :ok
|
{:ok, {{_, 200, _}, _headers, _body}} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
_other ->
|
_other ->
|
||||||
if System.monotonic_time(:millisecond) >= deadline do
|
if System.monotonic_time(:millisecond) >= deadline do
|
||||||
raise "desktop app process did not become healthy in time"
|
raise "desktop app process did not become healthy in time"
|
||||||
@@ -364,13 +375,7 @@ defmodule BDS.Desktop.Automation do
|
|||||||
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__)
|
||||||
|
|||||||
@@ -9,28 +9,24 @@ defmodule BDS.Desktop.Endpoint do
|
|||||||
signing_salt: "desktop-shell"
|
signing_salt: "desktop-shell"
|
||||||
]
|
]
|
||||||
|
|
||||||
socket "/live", Phoenix.LiveView.Socket,
|
socket("/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]])
|
||||||
websocket: [connect_info: [session: @session_options]]
|
|
||||||
|
|
||||||
plug Plug.Session, @session_options
|
plug(Plug.Session, @session_options)
|
||||||
plug :maybe_require_desktop_auth
|
plug(:maybe_require_desktop_auth)
|
||||||
|
|
||||||
plug Plug.Static,
|
plug(Plug.Static,
|
||||||
at: "/assets",
|
at: "/assets",
|
||||||
from: {:bds, "priv/ui"},
|
from: {:bds, "priv/static/assets"},
|
||||||
only: ["app.css", "live.js", "monaco"]
|
only: ["app.css", "app.js"]
|
||||||
|
)
|
||||||
|
|
||||||
plug Plug.Static,
|
plug(Plug.Static,
|
||||||
at: "/vendor/phoenix",
|
at: "/monaco",
|
||||||
from: {:phoenix, "priv/static"},
|
from: {:bds, "priv/ui/monaco"},
|
||||||
only: ["phoenix.min.js"]
|
only: ["vs"]
|
||||||
|
)
|
||||||
|
|
||||||
plug Plug.Static,
|
plug(BDS.Desktop.Router)
|
||||||
at: "/vendor/live_view",
|
|
||||||
from: {:phoenix_live_view, "priv/static"},
|
|
||||||
only: ["phoenix_live_view.min.js"]
|
|
||||||
|
|
||||||
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"] do
|
||||||
|
|||||||
12
lib/bds/desktop/external_links.ex
Normal file
12
lib/bds/desktop/external_links.ex
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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
|
||||||
@@ -2,9 +2,13 @@ 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -16,9 +16,7 @@ defmodule BDS.Desktop.Layouts do
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<%= @inner_content %>
|
<%= @inner_content %>
|
||||||
<script defer phx-track-static src="/vendor/phoenix/phoenix.min.js"></script>
|
<script defer phx-track-static src="/assets/app.js"></script>
|
||||||
<script defer phx-track-static src="/vendor/live_view/phoenix_live_view.min.js"></script>
|
|
||||||
<script defer phx-track-static src="/assets/live.js"></script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -6,23 +6,33 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
alias Desktop.Window
|
alias Desktop.Window
|
||||||
|
|
||||||
@window_id __MODULE__
|
@window_id __MODULE__
|
||||||
|
@server_name BDS.Desktop.MainWindow.Watcher
|
||||||
@persist_interval_ms 1_000
|
@persist_interval_ms 1_000
|
||||||
@default_size {1280, 780}
|
@default_size {1280, 780}
|
||||||
@default_min_size {800, 600}
|
@default_min_size {800, 600}
|
||||||
@state_file "window-state.json"
|
@state_file "window-state.json"
|
||||||
|
|
||||||
def start_link(_opts) do
|
def start_link(_opts) do
|
||||||
GenServer.start_link(__MODULE__, :ok)
|
GenServer.start_link(__MODULE__, :ok, name: @server_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def window_id, do: @window_id
|
def window_id, do: @window_id
|
||||||
|
def server_name, do: @server_name
|
||||||
|
|
||||||
|
def persist_now(timeout \\ 100) do
|
||||||
|
GenServer.call(@server_name, :persist_bounds_now, timeout)
|
||||||
|
catch
|
||||||
|
:exit, _reason -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
def window_options(extra_opts \\ []) do
|
def window_options(extra_opts \\ []) do
|
||||||
desktop_config = Application.get_env(:bds, :desktop, [])
|
desktop_config = Application.get_env(:bds, :desktop, [])
|
||||||
restored = restore_bounds()
|
restored = restore_bounds()
|
||||||
{default_width, default_height} = Keyword.get(desktop_config, :window_size, @default_size)
|
{default_width, default_height} = Keyword.get(desktop_config, :window_size, @default_size)
|
||||||
{min_width, min_height} = Keyword.get(desktop_config, :window_min_size, @default_min_size)
|
{min_width, min_height} = Keyword.get(desktop_config, :window_min_size, @default_min_size)
|
||||||
startup_bounds = clamp_startup_bounds(restored || %{width: default_width, height: default_height})
|
|
||||||
|
startup_bounds =
|
||||||
|
clamp_startup_bounds(restored || %{width: default_width, height: default_height})
|
||||||
|
|
||||||
base_opts = [
|
base_opts = [
|
||||||
app: :bds,
|
app: :bds,
|
||||||
@@ -69,8 +79,11 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
|
|
||||||
frame ->
|
frame ->
|
||||||
apply_restored_bounds(frame)
|
apply_restored_bounds(frame)
|
||||||
|
BDS.Desktop.Shutdown.install_handlers(frame)
|
||||||
schedule_persist()
|
schedule_persist()
|
||||||
{:noreply, %{state | frame: frame, last_bounds: current_bounds(frame) || state.last_bounds}}
|
|
||||||
|
{:noreply,
|
||||||
|
%{state | frame: frame, last_bounds: current_bounds(frame) || state.last_bounds}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -86,8 +99,13 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def terminate(_reason, %{frame: frame, last_bounds: last_bounds}) do
|
def handle_call(:persist_bounds_now, _from, state) do
|
||||||
if bounds = current_bounds(frame) || last_bounds do
|
{:reply, :ok, persist_current_bounds(state)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def terminate(_reason, %{last_bounds: last_bounds}) do
|
||||||
|
if bounds = last_bounds do
|
||||||
_ = persist_bounds(bounds)
|
_ = persist_bounds(bounds)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -98,6 +116,16 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
Process.send_after(self(), :persist_bounds, @persist_interval_ms)
|
Process.send_after(self(), :persist_bounds, @persist_interval_ms)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp persist_current_bounds(%{frame: frame} = state) do
|
||||||
|
next_bounds = current_bounds(frame) || state.last_bounds
|
||||||
|
|
||||||
|
if next_bounds do
|
||||||
|
_ = persist_bounds(next_bounds)
|
||||||
|
end
|
||||||
|
|
||||||
|
%{state | last_bounds: next_bounds}
|
||||||
|
end
|
||||||
|
|
||||||
defp apply_restored_bounds(frame) do
|
defp apply_restored_bounds(frame) do
|
||||||
case restore_bounds() do
|
case restore_bounds() do
|
||||||
%{x: x, y: y, width: width, height: height} ->
|
%{x: x, y: y, width: width, height: height} ->
|
||||||
@@ -122,17 +150,30 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
defp current_bounds(nil), do: nil
|
defp current_bounds(nil), do: nil
|
||||||
|
|
||||||
defp current_bounds(frame) do
|
defp current_bounds(frame) do
|
||||||
with_wx_env(fn ->
|
try do
|
||||||
cond do
|
with_wx_env(fn ->
|
||||||
not :wxWindow.isShown(frame) -> nil
|
cond do
|
||||||
:wxTopLevelWindow.isFullScreen(frame) -> nil
|
not :wxWindow.isShown(frame) ->
|
||||||
:wxTopLevelWindow.isMaximized(frame) -> nil
|
nil
|
||||||
true ->
|
|
||||||
{x, y} = :wxWindow.getPosition(frame)
|
:wxTopLevelWindow.isFullScreen(frame) ->
|
||||||
{width, height} = :wxWindow.getSize(frame)
|
nil
|
||||||
%{x: x, y: y, width: width, height: height}
|
|
||||||
end
|
:wxTopLevelWindow.isMaximized(frame) ->
|
||||||
end)
|
nil
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{x, y} = :wxWindow.getPosition(frame)
|
||||||
|
{width, height} = :wxWindow.getSize(frame)
|
||||||
|
%{x: x, y: y, width: width, height: height}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
rescue
|
||||||
|
ErlangError -> nil
|
||||||
|
FunctionClauseError -> nil
|
||||||
|
catch
|
||||||
|
:exit, _reason -> nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp with_wx_env(fun) do
|
defp with_wx_env(fun) do
|
||||||
@@ -160,7 +201,8 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_bounds(%{x: x, y: y, width: width, height: height})
|
defp normalize_bounds(%{x: x, y: y, width: width, height: height})
|
||||||
when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) and width > 0 and height > 0 do
|
when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) and
|
||||||
|
width > 0 and height > 0 do
|
||||||
{:ok, %{x: x, y: y, width: width, height: height}}
|
{:ok, %{x: x, y: y, width: width, height: height}}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -180,7 +222,8 @@ defmodule BDS.Desktop.MainWindow do
|
|||||||
desktop_config = Application.get_env(:bds, :desktop, [])
|
desktop_config = Application.get_env(:bds, :desktop, [])
|
||||||
|
|
||||||
case Keyword.get(desktop_config, :window_client_area_override) do
|
case Keyword.get(desktop_config, :window_client_area_override) do
|
||||||
{x, y, width, height} when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) ->
|
{x, y, width, height}
|
||||||
|
when is_integer(x) and is_integer(y) and is_integer(width) and is_integer(height) ->
|
||||||
%{x: x, y: y, width: width, height: height}
|
%{x: x, y: y, width: width, height: height}
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ defmodule BDS.Desktop.MediaController do
|
|||||||
with %{} = project <- Projects.get_active_project(),
|
with %{} = project <- Projects.get_active_project(),
|
||||||
%MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
|
%MediaRecord{} = media <- Repo.get(MediaRecord, media_id),
|
||||||
true <- media.project_id == project.id,
|
true <- media.project_id == project.id,
|
||||||
relative_path when is_binary(relative_path) <- Media.thumbnail_paths(media)[thumbnail_size(size)],
|
relative_path when is_binary(relative_path) <-
|
||||||
|
Media.thumbnail_paths(media)[thumbnail_size(size)],
|
||||||
absolute_path = Path.join(Projects.project_data_dir(project), relative_path),
|
absolute_path = Path.join(Projects.project_data_dir(project), relative_path),
|
||||||
true <- File.exists?(absolute_path) do
|
true <- File.exists?(absolute_path) do
|
||||||
{:ok, thumbnail_content_type(relative_path), absolute_path}
|
{:ok, thumbnail_content_type(relative_path), absolute_path}
|
||||||
@@ -33,7 +34,8 @@ defmodule BDS.Desktop.MediaController do
|
|||||||
end
|
end
|
||||||
rescue
|
rescue
|
||||||
error in [Exqlite.Error, DBConnection.OwnershipError] ->
|
error in [Exqlite.Error, DBConnection.OwnershipError] ->
|
||||||
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
|
if match?(%Exqlite.Error{}, error) and
|
||||||
|
not String.contains?(Exception.message(error), "no such table") do
|
||||||
reraise error, __STACKTRACE__
|
reraise error, __STACKTRACE__
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ defmodule BDS.Desktop.Menu do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use BDS.Desktop.MenuCompat
|
use BDS.Desktop.MenuCompat
|
||||||
|
alias BDS.Desktop.Shutdown
|
||||||
alias Desktop.Window
|
alias Desktop.Window
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
@@ -27,7 +28,7 @@ defmodule BDS.Desktop.Menu do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("quit", menu) do
|
def handle_event("quit", menu) do
|
||||||
Window.quit()
|
Shutdown.request_quit()
|
||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ 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.UI.Commands
|
alias BDS.UI.Commands
|
||||||
alias BDS.UI.MenuBar, as: ShellMenuBar
|
alias BDS.UI.MenuBar, as: ShellMenuBar
|
||||||
alias Desktop.OS
|
alias Desktop.OS
|
||||||
alias Desktop.Window
|
alias Desktop.Window
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
def groups(opts \\ []) do
|
def groups(opts \\ []) do
|
||||||
opts
|
opts
|
||||||
@@ -21,6 +23,8 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(menu) do
|
def mount(menu) do
|
||||||
|
UILocale.put(ShellData.ui_language())
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
Desktop.Menu.assign(
|
Desktop.Menu.assign(
|
||||||
menu,
|
menu,
|
||||||
@@ -50,12 +54,12 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("quit", menu) do
|
def handle_event("quit", menu) do
|
||||||
Window.quit()
|
Shutdown.request_quit()
|
||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("view_on_github", menu) do
|
def handle_event("view_on_github", menu) do
|
||||||
OS.launch_default_browser("https://github.com/rfc1437/bDS")
|
OS.launch_default_browser(ExternalLinks.github_url())
|
||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -74,7 +78,7 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("report_issue", menu) do
|
def handle_event("report_issue", menu) do
|
||||||
OS.launch_default_browser("https://github.com/rfc1437/bDS/issues")
|
OS.launch_default_browser(ExternalLinks.github_issues_url())
|
||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -84,6 +88,17 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
def handle_info({:set_ui_locale, locale}, menu) do
|
||||||
|
UILocale.put(locale)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
Desktop.Menu.assign(
|
||||||
|
menu,
|
||||||
|
:groups,
|
||||||
|
groups(dev_mode?: Application.get_env(:bds, :dev_routes, false))
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
def handle_info(_, menu) do
|
def handle_info(_, menu) do
|
||||||
{:noreply, menu}
|
{:noreply, menu}
|
||||||
end
|
end
|
||||||
@@ -126,58 +141,58 @@ defmodule BDS.Desktop.MenuBar do
|
|||||||
defp native_label(label, nil), do: label
|
defp native_label(label, nil), do: label
|
||||||
defp native_label(label, shortcut), do: label <> "\t" <> shortcut
|
defp native_label(label, shortcut), do: label <> "\t" <> shortcut
|
||||||
|
|
||||||
defp group_label(:file), do: "File"
|
defp group_label(:file), do: dgettext("ui", "File")
|
||||||
defp group_label(:edit), do: "Edit"
|
defp group_label(:edit), do: dgettext("ui", "Edit")
|
||||||
defp group_label(:view), do: "View"
|
defp group_label(:view), do: dgettext("ui", "View")
|
||||||
defp group_label(:blog), do: "Blog"
|
defp group_label(:blog), do: dgettext("ui", "Blog")
|
||||||
defp group_label(:help), do: "Help"
|
defp group_label(:help), do: dgettext("ui", "Help")
|
||||||
|
|
||||||
defp item_label(:new_post), do: "New Post"
|
defp item_label(:new_post), do: dgettext("ui", "New Post")
|
||||||
defp item_label(:import_media), do: "Import Media"
|
defp item_label(:import_media), do: dgettext("ui", "Import Media")
|
||||||
defp item_label(:save), do: "Save"
|
defp item_label(:save), do: dgettext("ui", "Save")
|
||||||
defp item_label(:open_in_browser), do: "Open in Browser"
|
defp item_label(:open_in_browser), do: dgettext("ui", "Open in Browser")
|
||||||
defp item_label(:open_data_folder), do: "Open Data Folder"
|
defp item_label(:open_data_folder), do: dgettext("ui", "Open Data Folder")
|
||||||
defp item_label(:close_tab), do: "Close Tab"
|
defp item_label(:close_tab), do: dgettext("ui", "Close Tab")
|
||||||
defp item_label(:quit), do: "Quit"
|
defp item_label(:quit), do: dgettext("ui", "Quit")
|
||||||
defp item_label(:undo), do: "Undo"
|
defp item_label(:undo), do: dgettext("ui", "Undo")
|
||||||
defp item_label(:redo), do: "Redo"
|
defp item_label(:redo), do: dgettext("ui", "Redo")
|
||||||
defp item_label(:cut), do: "Cut"
|
defp item_label(:cut), do: dgettext("ui", "Cut")
|
||||||
defp item_label(:copy), do: "Copy"
|
defp item_label(:copy), do: dgettext("ui", "Copy")
|
||||||
defp item_label(:paste), do: "Paste"
|
defp item_label(:paste), do: dgettext("ui", "Paste")
|
||||||
defp item_label(:delete), do: "Delete"
|
defp item_label(:delete), do: dgettext("ui", "Delete")
|
||||||
defp item_label(:select_all), do: "Select All"
|
defp item_label(:select_all), do: dgettext("ui", "Select All")
|
||||||
defp item_label(:find), do: "Find"
|
defp item_label(:find), do: dgettext("ui", "Find")
|
||||||
defp item_label(:replace), do: "Replace"
|
defp item_label(:replace), do: dgettext("ui", "Replace")
|
||||||
defp item_label(:edit_preferences), do: "Preferences"
|
defp item_label(:edit_preferences), do: dgettext("ui", "Preferences")
|
||||||
defp item_label(:view_posts), do: "Posts"
|
defp item_label(:view_posts), do: dgettext("ui", "Posts")
|
||||||
defp item_label(:view_media), do: "Media"
|
defp item_label(:view_media), do: dgettext("ui", "Media")
|
||||||
defp item_label(:toggle_sidebar), do: "Toggle Sidebar"
|
defp item_label(:toggle_sidebar), do: dgettext("ui", "Toggle Sidebar")
|
||||||
defp item_label(:toggle_panel), do: "Toggle Panel"
|
defp item_label(:toggle_panel), do: dgettext("ui", "Toggle Panel")
|
||||||
defp item_label(:toggle_assistant_sidebar), do: "Toggle Assistant Sidebar"
|
defp item_label(:toggle_assistant_sidebar), do: dgettext("ui", "Toggle Assistant Sidebar")
|
||||||
defp item_label(:toggle_dev_tools), do: "Toggle Dev Tools"
|
defp item_label(:toggle_dev_tools), do: dgettext("ui", "Toggle Dev Tools")
|
||||||
defp item_label(:reload), do: "Reload"
|
defp item_label(:reload), do: dgettext("ui", "Reload")
|
||||||
defp item_label(:force_reload), do: "Force Reload"
|
defp item_label(:force_reload), do: dgettext("ui", "Force Reload")
|
||||||
defp item_label(:reset_zoom), do: "Reset Zoom"
|
defp item_label(:reset_zoom), do: dgettext("ui", "Reset Zoom")
|
||||||
defp item_label(:zoom_in), do: "Zoom In"
|
defp item_label(:zoom_in), do: dgettext("ui", "Zoom In")
|
||||||
defp item_label(:zoom_out), do: "Zoom Out"
|
defp item_label(:zoom_out), do: dgettext("ui", "Zoom Out")
|
||||||
defp item_label(:toggle_full_screen), do: "Toggle Full Screen"
|
defp item_label(:toggle_full_screen), do: dgettext("ui", "Toggle Full Screen")
|
||||||
defp item_label(:publish_selected), do: "Publish Selected"
|
defp item_label(:publish_selected), do: dgettext("ui", "Publish Selected")
|
||||||
defp item_label(:preview_post), do: "Preview Post"
|
defp item_label(:preview_post), do: dgettext("ui", "Preview Post")
|
||||||
defp item_label(:edit_menu), do: "Edit Menu"
|
defp item_label(:edit_menu), do: dgettext("ui", "Edit Menu")
|
||||||
defp item_label(:rebuild_database), do: "Rebuild Database"
|
defp item_label(:rebuild_database), do: dgettext("ui", "Rebuild Database")
|
||||||
defp item_label(:reindex_text), do: "Reindex Text"
|
defp item_label(:reindex_text), do: dgettext("ui", "Reindex Text")
|
||||||
defp item_label(:rebuild_embedding_index), do: "Rebuild Embedding Index"
|
defp item_label(:rebuild_embedding_index), do: dgettext("ui", "Rebuild Embedding Index")
|
||||||
defp item_label(:metadata_diff), do: "Metadata Diff"
|
defp item_label(:metadata_diff), do: dgettext("ui", "Metadata Diff")
|
||||||
defp item_label(:regenerate_calendar), do: "Regenerate Calendar"
|
defp item_label(:regenerate_calendar), do: dgettext("ui", "Regenerate Calendar")
|
||||||
defp item_label(:validate_translations), do: "Validate Translations"
|
defp item_label(:validate_translations), do: dgettext("ui", "Validate Translations")
|
||||||
defp item_label(:fill_missing_translations), do: "Fill Missing Translations"
|
defp item_label(:fill_missing_translations), do: dgettext("ui", "Fill Missing Translations")
|
||||||
defp item_label(:find_duplicates), do: "Find Duplicate Posts"
|
defp item_label(:find_duplicates), do: dgettext("ui", "Find Duplicate Posts")
|
||||||
defp item_label(:generate_sitemap), do: "Generate Site"
|
defp item_label(:generate_sitemap), do: dgettext("ui", "Generate Site")
|
||||||
defp item_label(:validate_site), do: "Validate Site"
|
defp item_label(:validate_site), do: dgettext("ui", "Validate Site")
|
||||||
defp item_label(:upload_site), do: "Upload Site"
|
defp item_label(:upload_site), do: dgettext("ui", "Upload Site")
|
||||||
defp item_label(:about), do: "About"
|
defp item_label(:about), do: dgettext("ui", "About")
|
||||||
defp item_label(:documentation), do: "Documentation"
|
defp item_label(:documentation), do: dgettext("ui", "Documentation")
|
||||||
defp item_label(:api_documentation), do: "API Documentation"
|
defp item_label(:api_documentation), do: dgettext("ui", "API Documentation")
|
||||||
defp item_label(:view_on_github), do: "View on GitHub"
|
defp item_label(:view_on_github), do: dgettext("ui", "View on GitHub")
|
||||||
defp item_label(:report_issue), do: "Report Issue"
|
defp item_label(:report_issue), do: dgettext("ui", "Report Issue")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -48,29 +48,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 +118,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)
|
||||||
|
|
||||||
@@ -168,14 +171,16 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
def close_lightbox(%{kind: :gallery} = overlay), do: %{overlay | lightbox: nil}
|
def close_lightbox(%{kind: :gallery} = overlay), do: %{overlay | lightbox: nil}
|
||||||
def close_lightbox(overlay), do: overlay
|
def close_lightbox(overlay), do: overlay
|
||||||
|
|
||||||
def lightbox_next(%{kind: :gallery, lightbox: lightbox, images: images} = overlay) when is_map(lightbox) and images != [] do
|
def lightbox_next(%{kind: :gallery, lightbox: lightbox, images: images} = overlay)
|
||||||
|
when is_map(lightbox) and images != [] do
|
||||||
next_index = rem(lightbox.current_index + 1, length(images))
|
next_index = rem(lightbox.current_index + 1, length(images))
|
||||||
%{overlay | lightbox: lightbox_from_index(images, next_index)}
|
%{overlay | lightbox: lightbox_from_index(images, next_index)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def lightbox_next(overlay), do: overlay
|
def lightbox_next(overlay), do: overlay
|
||||||
|
|
||||||
def lightbox_previous(%{kind: :gallery, lightbox: lightbox, images: images} = overlay) when is_map(lightbox) and images != [] do
|
def lightbox_previous(%{kind: :gallery, lightbox: lightbox, images: images} = overlay)
|
||||||
|
when is_map(lightbox) and images != [] do
|
||||||
next_index = rem(lightbox.current_index - 1 + length(images), length(images))
|
next_index = rem(lightbox.current_index - 1 + length(images), length(images))
|
||||||
%{overlay | lightbox: lightbox_from_index(images, next_index)}
|
%{overlay | lightbox: lightbox_from_index(images, next_index)}
|
||||||
end
|
end
|
||||||
@@ -201,18 +206,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
|
||||||
}
|
}
|
||||||
@@ -226,15 +235,43 @@ defmodule BDS.Desktop.Overlay do
|
|||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def set_ai_suggestions(%{kind: :ai_suggestions} = overlay, suggestions) do
|
||||||
|
fields =
|
||||||
|
Enum.map(overlay.fields, fn field ->
|
||||||
|
case Map.get(suggestions, field.key) do
|
||||||
|
nil ->
|
||||||
|
field
|
||||||
|
|
||||||
|
value when is_binary(value) and value != "" ->
|
||||||
|
%{field | suggested_value: value, loading: false}
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
field
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{overlay | fields: fields}
|
||||||
|
end
|
||||||
|
|
||||||
|
def set_ai_suggestions(overlay, _suggestions), do: overlay
|
||||||
|
|
||||||
|
def set_ai_suggestions_error(%{kind: :ai_suggestions} = overlay, error_message) do
|
||||||
|
Map.put(overlay, :error, error_message)
|
||||||
|
end
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -247,7 +284,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
|
||||||
@@ -260,29 +297,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
|
||||||
|
|
||||||
|
|||||||
@@ -6,23 +6,23 @@ defmodule BDS.Desktop.Router do
|
|||||||
import Phoenix.LiveView.Router
|
import Phoenix.LiveView.Router
|
||||||
|
|
||||||
pipeline :browser do
|
pipeline :browser do
|
||||||
plug :accepts, ["html"]
|
plug(:accepts, ["html"])
|
||||||
plug :fetch_session
|
plug(:fetch_session)
|
||||||
plug :fetch_live_flash
|
plug(:fetch_live_flash)
|
||||||
plug :put_root_layout, html: {BDS.Desktop.Layouts, :root}
|
plug(:put_root_layout, html: {BDS.Desktop.Layouts, :root})
|
||||||
plug :protect_from_forgery
|
plug(:protect_from_forgery)
|
||||||
plug :put_secure_browser_headers
|
plug(:put_secure_browser_headers)
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/", BDS.Desktop do
|
scope "/", BDS.Desktop do
|
||||||
pipe_through :browser
|
pipe_through(:browser)
|
||||||
|
|
||||||
get "/health", HealthController, :show
|
get("/health", HealthController, :show)
|
||||||
get "/media-thumbnail/:media_id", MediaController, :thumbnail
|
get("/media-thumbnail/:media_id", MediaController, :thumbnail)
|
||||||
|
|
||||||
live_session :desktop_shell,
|
live_session :desktop_shell,
|
||||||
root_layout: {BDS.Desktop.Layouts, :root} do
|
root_layout: {BDS.Desktop.Layouts, :root} do
|
||||||
live "/", ShellLive, :index
|
live("/", ShellLive, :index)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -80,18 +80,26 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
attrs = %{group_id: group_id, group_name: "Search"}
|
attrs = %{group_id: group_id, group_name: "Search"}
|
||||||
|
|
||||||
{:ok, posts_task} =
|
{:ok, posts_task} =
|
||||||
Tasks.submit_task("Reindex Search Text", fn report ->
|
Tasks.submit_task(
|
||||||
:ok = Search.reindex_posts(project.id, on_progress: report)
|
"Reindex Search Text",
|
||||||
report.(1.0, "Post search text reindexed")
|
fn report ->
|
||||||
%{project_id: project.id, entity: "posts"}
|
:ok = Search.reindex_posts(project.id, on_progress: report)
|
||||||
end, attrs)
|
report.(1.0, "Post search text reindexed")
|
||||||
|
%{project_id: project.id, entity: "posts"}
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, _media_task} =
|
{:ok, _media_task} =
|
||||||
Tasks.submit_task("Reindex Media Search Text", fn report ->
|
Tasks.submit_task(
|
||||||
:ok = Search.reindex_media(project.id, on_progress: report)
|
"Reindex Media Search Text",
|
||||||
report.(1.0, "Media search text reindexed")
|
fn report ->
|
||||||
%{project_id: project.id, entity: "media"}
|
:ok = Search.reindex_media(project.id, on_progress: report)
|
||||||
end, attrs)
|
report.(1.0, "Media search text reindexed")
|
||||||
|
%{project_id: project.id, entity: "media"}
|
||||||
|
end,
|
||||||
|
attrs
|
||||||
|
)
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
%{
|
%{
|
||||||
@@ -107,43 +115,86 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("rebuild_embedding_index", project, _params) do
|
defp dispatch("rebuild_embedding_index", project, _params) do
|
||||||
queue_task(project, "rebuild_embedding_index", "Rebuild Embedding Index", "Embeddings", fn report ->
|
queue_task(
|
||||||
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report)
|
project,
|
||||||
report.(1.0, "Embedding index rebuilt")
|
"rebuild_embedding_index",
|
||||||
%{project_id: project.id, rebuilt_post_ids: rebuilt_post_ids, rebuilt_count: length(rebuilt_post_ids)}
|
"Rebuild Embedding Index",
|
||||||
end)
|
"Embeddings",
|
||||||
|
fn report ->
|
||||||
|
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report)
|
||||||
|
report.(1.0, "Embedding index rebuilt")
|
||||||
|
|
||||||
|
%{
|
||||||
|
project_id: project.id,
|
||||||
|
rebuilt_post_ids: rebuilt_post_ids,
|
||||||
|
rebuilt_count: length(rebuilt_post_ids)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("rebuild_posts_from_files", project, _params) do
|
defp dispatch("rebuild_posts_from_files", project, _params) do
|
||||||
queue_task(project, "rebuild_posts_from_files", "Rebuild Posts From Files", "Maintenance", fn report ->
|
queue_task(
|
||||||
{:ok, posts} = Maintenance.rebuild_from_filesystem(project.id, "post", on_progress: report)
|
project,
|
||||||
report.(1.0, "Post rebuild complete")
|
"rebuild_posts_from_files",
|
||||||
%{project_id: project.id, counts: %{posts: length(posts)}}
|
"Rebuild Posts From Files",
|
||||||
end)
|
"Maintenance",
|
||||||
|
fn report ->
|
||||||
|
{:ok, posts} =
|
||||||
|
Maintenance.rebuild_from_filesystem(project.id, "post", on_progress: report)
|
||||||
|
|
||||||
|
report.(1.0, "Post rebuild complete")
|
||||||
|
%{project_id: project.id, counts: %{posts: length(posts)}}
|
||||||
|
end
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("rebuild_media_from_files", project, _params) do
|
defp dispatch("rebuild_media_from_files", project, _params) do
|
||||||
queue_task(project, "rebuild_media_from_files", "Rebuild Media From Files", "Maintenance", fn report ->
|
queue_task(
|
||||||
{:ok, media} = Maintenance.rebuild_from_filesystem(project.id, "media", on_progress: report)
|
project,
|
||||||
report.(1.0, "Media rebuild complete")
|
"rebuild_media_from_files",
|
||||||
%{project_id: project.id, counts: %{media: length(media)}}
|
"Rebuild Media From Files",
|
||||||
end)
|
"Maintenance",
|
||||||
|
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
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("rebuild_scripts_from_files", project, _params) do
|
defp dispatch("rebuild_scripts_from_files", project, _params) do
|
||||||
queue_task(project, "rebuild_scripts_from_files", "Rebuild Scripts From Files", "Maintenance", fn report ->
|
queue_task(
|
||||||
{:ok, scripts} = Maintenance.rebuild_from_filesystem(project.id, "script", on_progress: report)
|
project,
|
||||||
report.(1.0, "Script rebuild complete")
|
"rebuild_scripts_from_files",
|
||||||
%{project_id: project.id, counts: %{scripts: length(scripts)}}
|
"Rebuild Scripts From Files",
|
||||||
end)
|
"Maintenance",
|
||||||
|
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
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("rebuild_templates_from_files", project, _params) do
|
defp dispatch("rebuild_templates_from_files", project, _params) do
|
||||||
queue_task(project, "rebuild_templates_from_files", "Rebuild Templates From Files", "Maintenance", fn report ->
|
queue_task(
|
||||||
{:ok, templates} = Maintenance.rebuild_from_filesystem(project.id, "template", on_progress: report)
|
project,
|
||||||
report.(1.0, "Template rebuild complete")
|
"rebuild_templates_from_files",
|
||||||
%{project_id: project.id, counts: %{templates: length(templates)}}
|
"Rebuild Templates From Files",
|
||||||
end)
|
"Maintenance",
|
||||||
|
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
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("rebuild_post_links", project, _params) do
|
defp dispatch("rebuild_post_links", project, _params) do
|
||||||
@@ -155,11 +206,17 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("regenerate_missing_thumbnails", project, _params) do
|
defp dispatch("regenerate_missing_thumbnails", project, _params) do
|
||||||
queue_task(project, "regenerate_missing_thumbnails", "Regenerate Missing Thumbnails", "Maintenance", fn report ->
|
queue_task(
|
||||||
result = BDS.Media.regenerate_missing_thumbnails(project.id, on_progress: report)
|
project,
|
||||||
report.(1.0, "Missing thumbnails regenerated")
|
"regenerate_missing_thumbnails",
|
||||||
Map.put(result, :project_id, project.id)
|
"Regenerate Missing Thumbnails",
|
||||||
end)
|
"Maintenance",
|
||||||
|
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
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("rebuild_database", project, _params) do
|
defp dispatch("rebuild_database", project, _params) do
|
||||||
@@ -192,15 +249,24 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
|
|
||||||
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_task(project, "generate_sitemap", "Generate Site", "Generation", fn report ->
|
||||||
{:ok, generation} = Generation.generate_site(project.id, @site_sections, on_progress: report)
|
{:ok, generation} =
|
||||||
|
Generation.generate_site(project.id, @site_sections, on_progress: report)
|
||||||
|
|
||||||
report.(1.0, "Generated site output")
|
report.(1.0, "Generated site output")
|
||||||
%{project_id: project.id, sections: generation.sections, generated_count: length(generation.generated_files)}
|
|
||||||
|
%{
|
||||||
|
project_id: project.id,
|
||||||
|
sections: generation.sections,
|
||||||
|
generated_count: length(generation.generated_files)
|
||||||
|
}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("validate_site", project, _params) do
|
defp dispatch("validate_site", project, _params) do
|
||||||
queue_task(project, "validate_site", "Validate Site", "Validation", fn report ->
|
queue_task(project, "validate_site", "Validate Site", "Validation", fn report ->
|
||||||
{:ok, validation} = Generation.validate_site(project.id, @site_sections, on_progress: report)
|
{:ok, validation} =
|
||||||
|
Generation.validate_site(project.id, @site_sections, on_progress: report)
|
||||||
|
|
||||||
site_validation_result(project.id, validation)
|
site_validation_result(project.id, validation)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -213,59 +279,117 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp dispatch("regenerate_calendar", project, _params) do
|
||||||
|
queue_task(project, "regenerate_calendar", "Regenerate Calendar", "Generation", fn report ->
|
||||||
|
{:ok, generation} = Generation.generate_site(project.id, [:core], on_progress: report)
|
||||||
|
report.(1.0, "Calendar regenerated")
|
||||||
|
|
||||||
|
%{
|
||||||
|
project_id: project.id,
|
||||||
|
sections: generation.sections,
|
||||||
|
generated_count: length(generation.generated_files)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
defp dispatch("repair_metadata_diff", project, params) do
|
defp dispatch("repair_metadata_diff", project, params) do
|
||||||
items = normalize_metadata_diff_items(Map.get(params, "items", Map.get(params, :items, [])))
|
items = normalize_metadata_diff_items(BDS.MapUtils.attr(params, :items, []))
|
||||||
direction = Map.get(params, "direction", Map.get(params, :direction))
|
direction = BDS.MapUtils.attr(params, :direction)
|
||||||
|
|
||||||
if items == [] do
|
if items == [] do
|
||||||
{:error, %{action: "repair_metadata_diff", message: "No metadata diff items selected"}}
|
{:error, %{action: "repair_metadata_diff", message: "No metadata diff items selected"}}
|
||||||
else
|
else
|
||||||
queue_task(project, "repair_metadata_diff", "Repair Metadata Diff", "Maintenance", fn report ->
|
queue_task(
|
||||||
{:ok, _repair} =
|
project,
|
||||||
Maintenance.repair_metadata_diff(project.id, direction, items,
|
"repair_metadata_diff",
|
||||||
on_progress: scaled_progress_reporter(report, 0.0, 0.8)
|
"Repair Metadata Diff",
|
||||||
)
|
"Maintenance",
|
||||||
|
fn report ->
|
||||||
|
{:ok, _repair} =
|
||||||
|
Maintenance.repair_metadata_diff(project.id, direction, items,
|
||||||
|
on_progress: scaled_progress_reporter(report, 0.0, 0.8)
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, metadata_diff} =
|
{:ok, metadata_diff} =
|
||||||
Maintenance.metadata_diff(project.id,
|
Maintenance.metadata_diff(project.id,
|
||||||
on_progress: scaled_progress_reporter(report, 0.8, 0.98)
|
on_progress: scaled_progress_reporter(report, 0.8, 0.98)
|
||||||
)
|
)
|
||||||
|
|
||||||
report.(1.0, "Metadata diff repair complete")
|
report.(1.0, "Metadata diff repair complete")
|
||||||
metadata_diff_result(project.id, metadata_diff)
|
metadata_diff_result(project.id, metadata_diff)
|
||||||
end)
|
end
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("import_metadata_diff_orphans", project, params) do
|
defp dispatch("import_metadata_diff_orphans", project, params) do
|
||||||
orphans = normalize_metadata_diff_orphans(Map.get(params, "orphans", Map.get(params, :orphans, [])))
|
orphans = normalize_metadata_diff_orphans(BDS.MapUtils.attr(params, :orphans, []))
|
||||||
|
|
||||||
if orphans == [] do
|
if orphans == [] do
|
||||||
{:error, %{action: "import_metadata_diff_orphans", message: "No orphan files selected"}}
|
{:error, %{action: "import_metadata_diff_orphans", message: "No orphan files selected"}}
|
||||||
else
|
else
|
||||||
queue_task(project, "import_metadata_diff_orphans", "Import Metadata Diff Orphans", "Maintenance", fn report ->
|
queue_task(
|
||||||
{:ok, _import} =
|
project,
|
||||||
Maintenance.import_metadata_diff_orphans(project.id, orphans,
|
"import_metadata_diff_orphans",
|
||||||
on_progress: scaled_progress_reporter(report, 0.0, 0.8)
|
"Import Metadata Diff Orphans",
|
||||||
)
|
"Maintenance",
|
||||||
|
fn report ->
|
||||||
|
{:ok, _import} =
|
||||||
|
Maintenance.import_metadata_diff_orphans(project.id, orphans,
|
||||||
|
on_progress: scaled_progress_reporter(report, 0.0, 0.8)
|
||||||
|
)
|
||||||
|
|
||||||
{:ok, metadata_diff} =
|
{:ok, metadata_diff} =
|
||||||
Maintenance.metadata_diff(project.id,
|
Maintenance.metadata_diff(project.id,
|
||||||
on_progress: scaled_progress_reporter(report, 0.8, 0.98)
|
on_progress: scaled_progress_reporter(report, 0.8, 0.98)
|
||||||
)
|
)
|
||||||
|
|
||||||
report.(1.0, "Metadata diff import complete")
|
report.(1.0, "Metadata diff import complete")
|
||||||
metadata_diff_result(project.id, metadata_diff)
|
metadata_diff_result(project.id, metadata_diff)
|
||||||
end)
|
end
|
||||||
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("validate_translations", project, _params) do
|
defp dispatch("validate_translations", project, _params) do
|
||||||
queue_task(project, "validate_translations", "Validate Translations", "Validation", fn report ->
|
queue_task(
|
||||||
{:ok, translation_report} = Posts.validate_translations(project.id, on_progress: report)
|
project,
|
||||||
report.(1.0, "Translation validation complete")
|
"validate_translations",
|
||||||
translation_validation_result(project.id, translation_report)
|
"Validate Translations",
|
||||||
end)
|
"Validation",
|
||||||
|
fn report ->
|
||||||
|
{:ok, translation_report} = Posts.validate_translations(project.id, on_progress: report)
|
||||||
|
report.(1.0, "Translation validation complete")
|
||||||
|
translation_validation_result(project.id, translation_report)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp dispatch("fill_missing_translations", project, _params) do
|
||||||
|
with {:ok, metadata} <- Metadata.get_project_metadata(project.id) do
|
||||||
|
if translation_fill_enabled?(metadata) do
|
||||||
|
queue_task(
|
||||||
|
project,
|
||||||
|
"fill_missing_translations",
|
||||||
|
"Fill Missing Translations",
|
||||||
|
"AI",
|
||||||
|
fn report ->
|
||||||
|
{:ok, result} = Posts.fill_missing_translations(project.id, on_progress: report)
|
||||||
|
Map.put(result, :project_id, project.id)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
else
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
kind: "output",
|
||||||
|
action: "fill_missing_translations",
|
||||||
|
title: "Fill Missing Translations",
|
||||||
|
message: "All translations are up to date",
|
||||||
|
project_id: project.id,
|
||||||
|
level: "info"
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp dispatch("find_duplicates", project, _params) do
|
defp dispatch("find_duplicates", project, _params) do
|
||||||
@@ -324,6 +448,19 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp translation_fill_enabled?(metadata) do
|
||||||
|
([metadata.main_language] ++ metadata.blog_languages)
|
||||||
|
|> Enum.map(fn language ->
|
||||||
|
language
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.downcase()
|
||||||
|
end)
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
|> Enum.uniq()
|
||||||
|
|> length() > 1
|
||||||
|
end
|
||||||
|
|
||||||
defp rebuild_database_steps(project) do
|
defp rebuild_database_steps(project) do
|
||||||
[
|
[
|
||||||
%{
|
%{
|
||||||
@@ -342,7 +479,9 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
%{
|
%{
|
||||||
name: "Rebuild Media From Files",
|
name: "Rebuild Media From Files",
|
||||||
work: fn report ->
|
work: fn report ->
|
||||||
{:ok, media} = Maintenance.rebuild_from_filesystem(project.id, "media", on_progress: report)
|
{:ok, media} =
|
||||||
|
Maintenance.rebuild_from_filesystem(project.id, "media", on_progress: report)
|
||||||
|
|
||||||
report.(1.0, "Media rebuild complete")
|
report.(1.0, "Media rebuild complete")
|
||||||
%{project_id: project.id, counts: %{media: length(media)}}
|
%{project_id: project.id, counts: %{media: length(media)}}
|
||||||
end
|
end
|
||||||
@@ -350,7 +489,9 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
%{
|
%{
|
||||||
name: "Rebuild Scripts From Files",
|
name: "Rebuild Scripts From Files",
|
||||||
work: fn report ->
|
work: fn report ->
|
||||||
{:ok, scripts} = Maintenance.rebuild_from_filesystem(project.id, "script", on_progress: report)
|
{:ok, scripts} =
|
||||||
|
Maintenance.rebuild_from_filesystem(project.id, "script", on_progress: report)
|
||||||
|
|
||||||
report.(1.0, "Script rebuild complete")
|
report.(1.0, "Script rebuild complete")
|
||||||
%{project_id: project.id, counts: %{scripts: length(scripts)}}
|
%{project_id: project.id, counts: %{scripts: length(scripts)}}
|
||||||
end
|
end
|
||||||
@@ -358,7 +499,9 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
%{
|
%{
|
||||||
name: "Rebuild Templates From Files",
|
name: "Rebuild Templates From Files",
|
||||||
work: fn report ->
|
work: fn report ->
|
||||||
{:ok, templates} = Maintenance.rebuild_from_filesystem(project.id, "template", on_progress: report)
|
{:ok, templates} =
|
||||||
|
Maintenance.rebuild_from_filesystem(project.id, "template", on_progress: report)
|
||||||
|
|
||||||
report.(1.0, "Template rebuild complete")
|
report.(1.0, "Template rebuild complete")
|
||||||
%{project_id: project.id, counts: %{templates: length(templates)}}
|
%{project_id: project.id, counts: %{templates: length(templates)}}
|
||||||
end
|
end
|
||||||
@@ -384,7 +527,12 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
work: fn report ->
|
work: fn report ->
|
||||||
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report)
|
{:ok, rebuilt_post_ids} = Embeddings.rebuild_project(project.id, on_progress: report)
|
||||||
report.(1.0, "Embedding index rebuilt")
|
report.(1.0, "Embedding index rebuilt")
|
||||||
%{project_id: project.id, rebuilt_post_ids: rebuilt_post_ids, rebuilt_count: length(rebuilt_post_ids)}
|
|
||||||
|
%{
|
||||||
|
project_id: project.id,
|
||||||
|
rebuilt_post_ids: rebuilt_post_ids,
|
||||||
|
rebuilt_count: length(rebuilt_post_ids)
|
||||||
|
}
|
||||||
end
|
end
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -531,7 +679,10 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
subtitle: "Database rows and translation files checked for invalid state",
|
subtitle: "Database rows and translation files checked for invalid state",
|
||||||
editorMeta: [
|
editorMeta: [
|
||||||
%{label: "Invalid DB", value: Integer.to_string(length(report.invalid_database_rows))},
|
%{label: "Invalid DB", value: Integer.to_string(length(report.invalid_database_rows))},
|
||||||
%{label: "Invalid Files", value: Integer.to_string(length(report.invalid_filesystem_files))}
|
%{
|
||||||
|
label: "Invalid Files",
|
||||||
|
value: Integer.to_string(length(report.invalid_filesystem_files))
|
||||||
|
}
|
||||||
],
|
],
|
||||||
payload: normalize_translation_validation(report)
|
payload: normalize_translation_validation(report)
|
||||||
}
|
}
|
||||||
@@ -564,8 +715,8 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
defp normalize_metadata_diff_items(items) when is_list(items) do
|
defp normalize_metadata_diff_items(items) when is_list(items) do
|
||||||
Enum.map(items, fn item ->
|
Enum.map(items, fn item ->
|
||||||
%{
|
%{
|
||||||
entity_type: Map.get(item, :entity_type) || Map.get(item, "entity_type"),
|
entity_type: BDS.MapUtils.attr(item, :entity_type),
|
||||||
entity_id: Map.get(item, :entity_id) || Map.get(item, "entity_id")
|
entity_id: BDS.MapUtils.attr(item, :entity_id)
|
||||||
}
|
}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -574,7 +725,7 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
|
|
||||||
defp normalize_metadata_diff_orphans(orphans) when is_list(orphans) do
|
defp normalize_metadata_diff_orphans(orphans) when is_list(orphans) do
|
||||||
Enum.map(orphans, fn orphan ->
|
Enum.map(orphans, fn orphan ->
|
||||||
%{file_path: Map.get(orphan, :file_path) || Map.get(orphan, "file_path")}
|
%{file_path: BDS.MapUtils.attr(orphan, :file_path)}
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -593,7 +744,10 @@ defmodule BDS.Desktop.ShellCommands do
|
|||||||
ssh_mode: Map.get(prefs, "ssh_mode")
|
ssh_mode: Map.get(prefs, "ssh_mode")
|
||||||
}
|
}
|
||||||
|
|
||||||
if Enum.all?([credentials.ssh_host, credentials.ssh_user, credentials.ssh_remote_path], &is_binary/1) do
|
if Enum.all?(
|
||||||
|
[credentials.ssh_host, credentials.ssh_user, credentials.ssh_remote_path],
|
||||||
|
&is_binary/1
|
||||||
|
) do
|
||||||
{:ok, credentials}
|
{:ok, credentials}
|
||||||
else
|
else
|
||||||
{:error, %{action: "upload_site", message: "Publishing preferences are incomplete"}}
|
{:error, %{action: "upload_site", message: "Publishing preferences are incomplete"}}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
defmodule BDS.Desktop.ShellData do
|
defmodule BDS.Desktop.ShellData do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
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
|
||||||
@@ -12,12 +15,45 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server"
|
Application.get_env(:bds, :desktop)[:title] || "Blogging Desktop Server"
|
||||||
end
|
end
|
||||||
|
|
||||||
def ui_language do
|
def activity_icon(id) do
|
||||||
I18n.current_ui_locale()
|
case to_string(id) do
|
||||||
|
"posts" ->
|
||||||
|
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"></path><path d="M8 12h8v2H8zm0 4h8v2H8z"></path></svg>)
|
||||||
|
|
||||||
|
"pages" ->
|
||||||
|
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h10v4h6v12H4V4zm10 1.5V9h4.5L14 5.5zM7 12h10v1.5H7V12zm0 3h10v1.5H7V15z"></path></svg>)
|
||||||
|
|
||||||
|
"media" ->
|
||||||
|
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"></path></svg>)
|
||||||
|
|
||||||
|
"scripts" ->
|
||||||
|
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"></path></svg>)
|
||||||
|
|
||||||
|
"templates" ->
|
||||||
|
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7zM5.5 5.5v4h4v-4h-4zm9 0v4h4v-4h-4zm-9 9v4h4v-4h-4zm9 0v4h4v-4h-4z"></path></svg>)
|
||||||
|
|
||||||
|
"tags" ->
|
||||||
|
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"></path></svg>)
|
||||||
|
|
||||||
|
"chat" ->
|
||||||
|
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"></path><circle cx="8" cy="10" r="1.5"></circle><circle cx="12" cy="10" r="1.5"></circle><circle cx="16" cy="10" r="1.5"></circle></svg>)
|
||||||
|
|
||||||
|
"import" ->
|
||||||
|
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path></svg>)
|
||||||
|
|
||||||
|
"git" ->
|
||||||
|
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M22 11.73L12.27 2a1 1 0 0 0-1.41 0L8.84 4.02l2.56 2.56a1.2 1.2 0 0 1 1.52 1.53l2.47 2.47a1.2 1.2 0 1 1-.72.67l-2.3-2.3v6.06a1.2 1.2 0 1 1-.85 0V8.9a1.2 1.2 0 0 1-.66-1.59L8.35 4.8 2 11.16a1 1 0 0 0 0 1.41L11.73 22a1 1 0 0 0 1.41 0L22 13.14a1 1 0 0 0 0-1.41z"></path></svg>)
|
||||||
|
|
||||||
|
"settings" ->
|
||||||
|
~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"></path></svg>)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
activity_icon("posts")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def translations(locale \\ nil) do
|
def ui_language do
|
||||||
I18n.get_ui_translations(effective_ui_language(locale))
|
I18n.current_ui_locale()
|
||||||
end
|
end
|
||||||
|
|
||||||
def supported_ui_languages do
|
def supported_ui_languages do
|
||||||
@@ -26,23 +62,12 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
def translate(key, bindings \\ %{}, locale \\ nil) do
|
|
||||||
text = Map.get(translations(locale), to_string(key), to_string(key))
|
|
||||||
|
|
||||||
Enum.reduce(bindings, text, fn {binding, value}, acc ->
|
|
||||||
String.replace(acc, "%{#{binding}}", to_string(value))
|
|
||||||
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 not String.contains?(Exception.message(error), "no such table: projects") do
|
{:error, :not_ready}
|
||||||
reraise error, __STACKTRACE__
|
end
|
||||||
end
|
|
||||||
|
|
||||||
default_project_snapshot()
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def current_project(projects_snapshot) do
|
def current_project(projects_snapshot) do
|
||||||
@@ -51,40 +76,47 @@ 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 not String.contains?(Exception.message(error), "no such table") do
|
{:error, :not_ready}
|
||||||
reraise error, __STACKTRACE__
|
end
|
||||||
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 not String.contains?(Exception.message(error), "no such table") do
|
{:error, :not_ready}
|
||||||
reraise error, __STACKTRACE__
|
end
|
||||||
end
|
|
||||||
|
|
||||||
Sidebar.view(nil, view_id, params)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def assistant_cards do
|
def assistant_cards do
|
||||||
[
|
[
|
||||||
%{label: "Offline Gate", text: "Automatic AI actions stay gated by airplane mode."},
|
%{
|
||||||
%{label: "Filesystem Sync", text: "Metadata flush, diffing, and rebuild hooks still need editor wiring."},
|
label: dgettext("ui", "Offline Gate"),
|
||||||
%{label: "Desktop Runtime", text: "The app window is now served from LiveView state."}
|
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
|
end
|
||||||
|
|
||||||
def editor_meta(task_status) do
|
def editor_meta(task_status) do
|
||||||
[
|
[
|
||||||
%{label: "Status", value: task_status.running_task_message || "Idle"},
|
%{
|
||||||
%{label: "Mode", value: "Offline"},
|
label: dgettext("ui", "Status"),
|
||||||
%{label: "Main Language", value: ui_language()}
|
value: task_status.running_task_message || dgettext("ui", "Idle")
|
||||||
|
},
|
||||||
|
%{label: dgettext("ui", "Mode"), value: dgettext("ui", "Offline")},
|
||||||
|
%{label: dgettext("ui", "Main Language"), value: ui_language()}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -103,25 +135,35 @@ 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
|
||||||
|
{: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 =
|
||||||
case provider.(project_id, []) do
|
custom_provider? ||
|
||||||
{:ok, %{behind: behind}} when is_integer(behind) and behind > 0 -> behind
|
case BDS.Projects.get_project(project_id) do
|
||||||
{:ok, %{behind: behind}} when is_binary(behind) -> parse_positive_count(behind)
|
nil -> false
|
||||||
_other -> 0
|
project -> File.dir?(Path.join(BDS.Projects.project_data_dir(project), ".git"))
|
||||||
end
|
end
|
||||||
rescue
|
|
||||||
error in [DBConnection.OwnershipError, Exqlite.Error] ->
|
count =
|
||||||
if match?(%Exqlite.Error{}, error) and not String.contains?(Exception.message(error), "no such table") do
|
if has_git do
|
||||||
reraise error, __STACKTRACE__
|
case provider.(project_id, []) do
|
||||||
|
{:ok, %{behind: behind}} when is_integer(behind) and behind > 0 -> behind
|
||||||
|
{:ok, %{behind: behind}} when is_binary(behind) -> parse_positive_count(behind)
|
||||||
|
_other -> 0
|
||||||
|
end
|
||||||
|
else
|
||||||
|
0
|
||||||
end
|
end
|
||||||
|
|
||||||
0
|
{:ok, count}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -133,46 +175,18 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
|> Enum.uniq()
|
|> Enum.uniq()
|
||||||
end
|
end
|
||||||
|
|
||||||
defp git_remote_state_provider do
|
|
||||||
Application.get_env(:bds, :git_remote_state_provider, &Git.remote_state/2)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp parse_positive_count(value) do
|
|
||||||
case Integer.parse(value) do
|
|
||||||
{count, _rest} when count > 0 -> count
|
|
||||||
_other -> 0
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def activity_icon(id) do
|
|
||||||
case to_string(id) do
|
|
||||||
"posts" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6zM6 20V4h7v5h5v11H6z"></path><path d="M8 12h8v2H8zm0 4h8v2H8z"></path></svg>)
|
|
||||||
"pages" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h10v4h6v12H4V4zm10 1.5V9h4.5L14 5.5zM7 12h10v1.5H7V12zm0 3h10v1.5H7V15z"></path></svg>)
|
|
||||||
"media" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"></path></svg>)
|
|
||||||
"scripts" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 3H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h7v2H8v2h8v-2h-3v-2h7a1 1 0 0 0 1-1V4a1 1 0 0 0-1-1zM5 14V5h14v9H5zm2-7.5L9.5 9 7 11.5l1.4 1.4L12.3 9 8.4 5.1 7 6.5zm6.5 5.5h4v-2h-4v2z"></path></svg>)
|
|
||||||
"templates" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M4 4h7v7H4V4zm9 0h7v7h-7V4zM4 13h7v7H4v-7zm9 0h7v7h-7v-7zM5.5 5.5v4h4v-4h-4zm9 0v4h4v-4h-4zm-9 9v4h4v-4h-4zm9 0v4h4v-4h-4z"></path></svg>)
|
|
||||||
"tags" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58s1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41s-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"></path></svg>)
|
|
||||||
"chat" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"></path><circle cx="8" cy="10" r="1.5"></circle><circle cx="12" cy="10" r="1.5"></circle><circle cx="16" cy="10" r="1.5"></circle></svg>)
|
|
||||||
"import" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"></path></svg>)
|
|
||||||
"git" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M22 11.73L12.27 2a1 1 0 0 0-1.41 0L8.84 4.02l2.56 2.56a1.2 1.2 0 0 1 1.52 1.53l2.47 2.47a1.2 1.2 0 1 1-.72.67l-2.3-2.3v6.06a1.2 1.2 0 1 1-.85 0V8.9a1.2 1.2 0 0 1-.66-1.59L8.35 4.8 2 11.16a1 1 0 0 0 0 1.41L11.73 22a1 1 0 0 0 1.41 0L22 13.14a1 1 0 0 0 0-1.41z"></path></svg>)
|
|
||||||
"settings" -> ~s(<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"></path></svg>)
|
|
||||||
_other -> activity_icon("posts")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def dashboard_status_label(status) do
|
def dashboard_status_label(status) do
|
||||||
case to_string(status) do
|
case to_string(status) do
|
||||||
"draft" -> translate("dashboard.status.draft")
|
"draft" -> dgettext("ui", "Draft")
|
||||||
"published" -> translate("dashboard.status.published")
|
"published" -> dgettext("ui", "Published")
|
||||||
"archived" -> translate("dashboard.status.archived")
|
"archived" -> dgettext("ui", "Archived")
|
||||||
other -> other |> String.replace("_", " ") |> String.capitalize()
|
other -> other |> String.replace("_", " ") |> String.capitalize()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def dashboard_post_count_label(count) do
|
def dashboard_post_count_label(count) do
|
||||||
normalized_count = count || 0
|
normalized_count = count || 0
|
||||||
key = if normalized_count == 1, do: "dashboard.postCount.one", else: "dashboard.postCount.other"
|
dngettext("ui", "%{count} post", "%{count} posts", normalized_count, count: normalized_count)
|
||||||
translate(key, %{count: normalized_count})
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def dashboard_tag_cloud_items(items) when is_list(items) do
|
def dashboard_tag_cloud_items(items) when is_list(items) do
|
||||||
@@ -188,7 +202,7 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
|
|
||||||
top_items
|
top_items
|
||||||
|> Enum.map(fn item ->
|
|> Enum.map(fn item ->
|
||||||
font_size = 11 + (((item.count || 0) - min_count) / range) * 11
|
font_size = 11 + ((item.count || 0) - min_count) / range * 11
|
||||||
Map.merge(item, %{font_size: font_size, color: normalize_dashboard_tag_color(item.color)})
|
Map.merge(item, %{font_size: font_size, color: normalize_dashboard_tag_color(item.color)})
|
||||||
end)
|
end)
|
||||||
|> Enum.sort_by(&String.downcase(to_string(&1.tag || "")))
|
|> Enum.sort_by(&String.downcase(to_string(&1.tag || "")))
|
||||||
@@ -199,10 +213,11 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
|
|
||||||
declarations =
|
declarations =
|
||||||
if item.color do
|
if item.color do
|
||||||
declarations ++ [
|
declarations ++
|
||||||
"background-color: #{item.color};",
|
[
|
||||||
"color: #{dashboard_contrast_color(item.color)};"
|
"background-color: #{item.color};",
|
||||||
]
|
"color: #{dashboard_contrast_color(item.color)};"
|
||||||
|
]
|
||||||
else
|
else
|
||||||
declarations
|
declarations
|
||||||
end
|
end
|
||||||
@@ -225,9 +240,17 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
|
|
||||||
def route_label(route) do
|
def route_label(route) do
|
||||||
case to_string(route) do
|
case to_string(route) do
|
||||||
"git_log" -> "Git Log"
|
"git_log" ->
|
||||||
"post_links" -> "Post Links"
|
dgettext("ui", "Git Log")
|
||||||
other -> other |> String.replace("_", " ") |> String.split() |> Enum.map_join(" ", &String.capitalize/1)
|
|
||||||
|
"post_links" ->
|
||||||
|
dgettext("ui", "Post Links")
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
|> String.replace("_", " ")
|
||||||
|
|> String.split()
|
||||||
|
|> Enum.map_join(" ", &String.capitalize/1)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -248,17 +271,25 @@ defmodule BDS.Desktop.ShellData do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp effective_ui_language(nil) do
|
defp git_remote_state_provider do
|
||||||
Process.get(:bds_ui_locale) || ui_language()
|
Application.get_env(:bds, :git_remote_state_provider, &Git.remote_state/2)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp effective_ui_language(locale), do: locale
|
defp parse_positive_count(value) do
|
||||||
|
case Integer.parse(value) do
|
||||||
|
{count, _rest} when count > 0 -> count
|
||||||
|
_other -> 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp maybe_add_panel_tab(tabs, :post, :post_links), do: tabs ++ [:post_links]
|
defp maybe_add_panel_tab(tabs, :post, :post_links), do: tabs ++ [:post_links]
|
||||||
defp maybe_add_panel_tab(tabs, route, :git_log) when route in [:post, :media], do: tabs ++ [:git_log]
|
|
||||||
|
defp maybe_add_panel_tab(tabs, route, :git_log) when route in [:post, :media],
|
||||||
|
do: tabs ++ [:git_log]
|
||||||
|
|
||||||
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
224
lib/bds/desktop/shell_live/bridges.ex
Normal file
224
lib/bds/desktop/shell_live/bridges.ex
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.Bridges do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
import Phoenix.LiveView, only: [connected?: 1, send_update: 2]
|
||||||
|
|
||||||
|
alias BDS.Desktop.ShellData
|
||||||
|
alias BDS.Desktop.ShellLive.{ChatEditor, PostEditor}
|
||||||
|
alias BDS.Desktop.ShellLive.{CliSync, SessionUtil}
|
||||||
|
alias BDS.UI.Workbench
|
||||||
|
|
||||||
|
@refreshable_tab_meta_types [:import, :chat]
|
||||||
|
|
||||||
|
@spec handle_info(tuple() | atom(), Phoenix.LiveView.Socket.t(), map()) ::
|
||||||
|
{:noreply, Phoenix.LiveView.Socket.t()}
|
||||||
|
|
||||||
|
# ── Generic editor notifications (sent via Notify module) ────────────────
|
||||||
|
|
||||||
|
def handle_info({:editor_output, title, message, detail, level}, socket, callbacks) do
|
||||||
|
{:noreply, callbacks.append_output.(socket, title, message, detail, level)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:editor_tab_meta, type, id, updates}, socket, callbacks)
|
||||||
|
when is_atom(type) and is_map(updates) do
|
||||||
|
key = {type, id}
|
||||||
|
current_meta = Map.get(socket.assigns.tab_meta, key, %{})
|
||||||
|
next_meta = Map.merge(current_meta, updates)
|
||||||
|
tab_meta = Map.put(socket.assigns.tab_meta, key, next_meta)
|
||||||
|
|
||||||
|
socket = assign(socket, :tab_meta, tab_meta)
|
||||||
|
|
||||||
|
if type in @refreshable_tab_meta_types do
|
||||||
|
{:noreply, callbacks.refresh_sidebar.(socket, socket.assigns.workbench)}
|
||||||
|
else
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:editor_dirty, type, id, dirty?}, socket, _callbacks) do
|
||||||
|
workbench =
|
||||||
|
if dirty? do
|
||||||
|
Workbench.mark_dirty(socket.assigns.workbench, type, id)
|
||||||
|
else
|
||||||
|
Workbench.clear_dirty(socket.assigns.workbench, type, id)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, assign(socket, :workbench, workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
|
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,
|
||||||
|
callbacks.refresh_layout.(socket, Workbench.close_tab(socket.assigns.workbench, type, id))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(:tags_changed, socket, callbacks) do
|
||||||
|
{:noreply, callbacks.refresh_content.(socket, socket.assigns.workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(:settings_changed, socket, callbacks) do
|
||||||
|
{:noreply, callbacks.reload.(socket, socket.assigns.workbench)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Chat editor messages (sent from AI streaming, not from Notify) ──────
|
||||||
|
|
||||||
|
def handle_info({:chat_tool_call, conversation_id, tool_call}, socket, _callbacks) do
|
||||||
|
send_update(ChatEditor,
|
||||||
|
id: "chat-editor-#{conversation_id}",
|
||||||
|
action: :note_tool_call,
|
||||||
|
tool_call: tool_call
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_tool_result, conversation_id, name}, socket, _callbacks) do
|
||||||
|
send_update(ChatEditor,
|
||||||
|
id: "chat-editor-#{conversation_id}",
|
||||||
|
action: :note_tool_result,
|
||||||
|
name: name
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_streaming_content, conversation_id, content}, socket, _callbacks) do
|
||||||
|
send_update(ChatEditor,
|
||||||
|
id: "chat-editor-#{conversation_id}",
|
||||||
|
action: :note_streaming_content,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_editor_task_started, conversation_id, ref}, socket, _callbacks) do
|
||||||
|
refs = Map.put(socket.assigns.chat_editor_request_refs, ref, conversation_id)
|
||||||
|
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_editor_task_cancelled, _conversation_id, ref}, socket, _callbacks) do
|
||||||
|
refs = Map.delete(socket.assigns.chat_editor_request_refs, ref)
|
||||||
|
{:noreply, assign(socket, :chat_editor_request_refs, refs)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:persist_surface_state, conversation_id}, socket, _callbacks) do
|
||||||
|
send_update(ChatEditor,
|
||||||
|
id: "chat-editor-#{conversation_id}",
|
||||||
|
action: :persist_surface_state
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_editor_toggle_sidebar}, socket, callbacks) do
|
||||||
|
{:noreply,
|
||||||
|
callbacks.refresh_layout.(socket, Workbench.toggle_sidebar(socket.assigns.workbench))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_editor_toggle_panel}, socket, callbacks) do
|
||||||
|
{:noreply,
|
||||||
|
callbacks.refresh_layout.(socket, Workbench.toggle_panel(socket.assigns.workbench))}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_editor_toggle_assistant_sidebar}, socket, callbacks) do
|
||||||
|
{:noreply,
|
||||||
|
callbacks.refresh_layout.(
|
||||||
|
socket,
|
||||||
|
Workbench.toggle_assistant_sidebar(socket.assigns.workbench)
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:chat_editor_switch_view, view}, socket, callbacks) do
|
||||||
|
{:noreply,
|
||||||
|
callbacks.refresh_sidebar.(socket, Workbench.click_activity(socket.assigns.workbench, view))}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── Post editor cross-component messages (sent from OverlayManager) ─────
|
||||||
|
|
||||||
|
def handle_info({:post_editor_insert_content, post_id, content}, socket, _callbacks) do
|
||||||
|
send_update(PostEditor,
|
||||||
|
id: "post-editor-#{post_id}",
|
||||||
|
action: :insert_content,
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:post_editor_translate, post_id, language}, socket, _callbacks) do
|
||||||
|
send_update(PostEditor, id: "post-editor-#{post_id}", action: :translate, language: language)
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info({:post_editor_apply_ai_suggestions, post_id, fields}, socket, _callbacks) do
|
||||||
|
send_update(PostEditor,
|
||||||
|
id: "post-editor-#{post_id}",
|
||||||
|
action: :apply_ai_suggestions,
|
||||||
|
fields: fields
|
||||||
|
)
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── External system messages ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def handle_info({:entity_changed, payload}, socket, callbacks) when is_map(payload) do
|
||||||
|
{:noreply, CliSync.apply_entity_change(socket, payload, callbacks.refresh_content)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(:refresh_task_status, socket, callbacks) do
|
||||||
|
raw_task_status = BDS.Tasks.status_snapshot()
|
||||||
|
|
||||||
|
socket =
|
||||||
|
case SessionUtil.next_completed_task_result(socket, raw_task_status) do
|
||||||
|
nil ->
|
||||||
|
task_status =
|
||||||
|
BDS.Desktop.ShellLive.TaskLocalization.localize_task_status(
|
||||||
|
raw_task_status,
|
||||||
|
socket.assigns.page_language
|
||||||
|
)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:task_status, task_status)
|
||||||
|
|> assign(:editor_meta, ShellData.editor_meta(task_status))
|
||||||
|
|> assign(
|
||||||
|
:status,
|
||||||
|
ShellData.status_bar(
|
||||||
|
socket.assigns.workbench,
|
||||||
|
task_status,
|
||||||
|
socket.assigns.dashboard,
|
||||||
|
ui_language: socket.assigns.page_language,
|
||||||
|
offline_mode: socket.assigns.offline_mode
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
task ->
|
||||||
|
socket
|
||||||
|
|> SessionUtil.mark_task_result_handled(task.id)
|
||||||
|
|> callbacks.apply_shell_command_result.(task.result)
|
||||||
|
end
|
||||||
|
|
||||||
|
if connected?(socket) do
|
||||||
|
Process.send_after(self(), :refresh_task_status, BDS.Desktop.ShellLive.refresh_interval())
|
||||||
|
end
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_info(_message, socket, _callbacks), do: {:noreply, socket}
|
||||||
|
end
|
||||||
File diff suppressed because it is too large
Load Diff
272
lib/bds/desktop/shell_live/chat_editor/message_build.ex
Normal file
272
lib/bds/desktop/shell_live/chat_editor/message_build.ex
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.ChatEditor.MessageBuild do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias BDS.AI
|
||||||
|
alias BDS.AI.ChatConversation
|
||||||
|
alias BDS.Desktop.ShellLive.ChatEditor.{ModelSelection, ToolSurfaces, ToolTracking}
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
|
@spec build(term()) :: term()
|
||||||
|
def build(%{current_tab: %{type: :chat, id: conversation_id}} = assigns) do
|
||||||
|
case AI.get_chat_conversation(conversation_id) do
|
||||||
|
nil ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
%ChatConversation{} = conversation ->
|
||||||
|
messages = AI.list_chat_messages(conversation.id)
|
||||||
|
request = Map.get(assigns.chat_editor_requests, conversation.id)
|
||||||
|
effective_model = AI.effective_chat_model(conversation)
|
||||||
|
available_models = AI.available_chat_models(effective_model)
|
||||||
|
streaming_tool_markers = streaming_tool_markers(messages, request)
|
||||||
|
streaming_content = streaming_content(messages, request)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: conversation.id,
|
||||||
|
title: conversation.title || dgettext("ui", "New Chat"),
|
||||||
|
model: conversation.model,
|
||||||
|
effective_model: effective_model,
|
||||||
|
available_models: available_models,
|
||||||
|
available_model_groups: ModelSelection.group_available_models(available_models),
|
||||||
|
model_selector_open?:
|
||||||
|
Map.get(assigns.chat_model_selectors_open, conversation.id, false),
|
||||||
|
input: Map.get(assigns.chat_editor_inputs, conversation.id, ""),
|
||||||
|
messages: build_entries(messages, assigns),
|
||||||
|
pending_user_message: pending_user_message(messages, request),
|
||||||
|
is_streaming: not is_nil(request),
|
||||||
|
streaming_content: streaming_content,
|
||||||
|
streaming_tool_markers: streaming_tool_markers,
|
||||||
|
streaming_inline_surfaces:
|
||||||
|
streaming_inline_surfaces(conversation.id, streaming_tool_markers, assigns),
|
||||||
|
offline?: Map.get(assigns, :offline_mode, true),
|
||||||
|
needs_api_key?: ModelSelection.needs_api_key?(Map.get(assigns, :offline_mode, true)),
|
||||||
|
action_error: Map.get(assigns.chat_editor_action_errors, conversation.id),
|
||||||
|
send_disabled?:
|
||||||
|
String.trim(Map.get(assigns.chat_editor_inputs, conversation.id, "")) == "" or
|
||||||
|
not is_nil(request)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build(_assigns), do: nil
|
||||||
|
|
||||||
|
defp build_entries(messages, assigns) do
|
||||||
|
{entries, current_entry, _turn_index} =
|
||||||
|
Enum.reduce(messages, {[], nil, -1}, fn message, {entries, current_entry, turn_index} ->
|
||||||
|
case message.role do
|
||||||
|
:tool ->
|
||||||
|
if current_entry && current_entry.role == :assistant do
|
||||||
|
{entries, append_tool_result(current_entry, message), turn_index}
|
||||||
|
else
|
||||||
|
{entries, current_entry, turn_index}
|
||||||
|
end
|
||||||
|
|
||||||
|
:system ->
|
||||||
|
{entries, current_entry, turn_index}
|
||||||
|
|
||||||
|
:user ->
|
||||||
|
entries = finalize_entry(entries, current_entry)
|
||||||
|
next_turn_index = turn_index + 1
|
||||||
|
{entries, start_entry(message, next_turn_index, assigns), next_turn_index}
|
||||||
|
|
||||||
|
:assistant ->
|
||||||
|
next_entry = start_entry(message, turn_index, assigns)
|
||||||
|
|
||||||
|
if tool_only_assistant_entry?(current_entry) do
|
||||||
|
{entries, merge_tool_only_entry(current_entry, next_entry), turn_index}
|
||||||
|
else
|
||||||
|
entries = finalize_entry(entries, current_entry)
|
||||||
|
{entries, next_entry, turn_index}
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
entries = finalize_entry(entries, current_entry)
|
||||||
|
{entries, start_entry(message, turn_index, assigns), turn_index}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
entries
|
||||||
|
|> finalize_entry(current_entry)
|
||||||
|
|> Enum.reverse()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp finalize_entry(entries, nil), do: entries
|
||||||
|
defp finalize_entry(entries, entry), do: [entry | entries]
|
||||||
|
|
||||||
|
defp start_entry(message, turn_index, assigns) do
|
||||||
|
tool_markers = ToolTracking.normalize_tool_calls(message.tool_calls)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: message.id,
|
||||||
|
role: message.role,
|
||||||
|
content: message.content || "",
|
||||||
|
turn_index: turn_index,
|
||||||
|
tool_markers: tool_markers,
|
||||||
|
inline_surfaces:
|
||||||
|
ToolSurfaces.build_render_surfaces(tool_markers, message.id, assigns)
|
||||||
|
|> mark_surfaces_expanded(assigns),
|
||||||
|
tool_surfaces: []
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp append_tool_result(entry, message) do
|
||||||
|
ToolTracking.mark_tool_call_completed(entry, message.tool_call_id, message.content)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tool_only_assistant_entry?(%{role: :assistant, content: content} = entry) do
|
||||||
|
String.trim(content || "") == "" and
|
||||||
|
(entry.tool_markers != [] or entry.inline_surfaces != [] or entry.tool_surfaces != [])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp tool_only_assistant_entry?(_entry), do: false
|
||||||
|
|
||||||
|
defp merge_tool_only_entry(tool_entry, assistant_entry) do
|
||||||
|
%{
|
||||||
|
assistant_entry
|
||||||
|
| tool_markers: tool_entry.tool_markers ++ assistant_entry.tool_markers,
|
||||||
|
inline_surfaces: tool_entry.inline_surfaces ++ assistant_entry.inline_surfaces,
|
||||||
|
tool_surfaces: tool_entry.tool_surfaces ++ assistant_entry.tool_surfaces
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp mark_surfaces_expanded([], _assigns), do: []
|
||||||
|
|
||||||
|
defp mark_surfaces_expanded(surfaces, assigns) do
|
||||||
|
dismissed = Map.get(assigns, :chat_editor_dismissed_surfaces, MapSet.new())
|
||||||
|
|
||||||
|
surfaces
|
||||||
|
|> Enum.reject(&MapSet.member?(dismissed, &1.id))
|
||||||
|
|> Enum.map(&Map.put(&1, :expanded?, true))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pending_user_message(_messages, nil), do: nil
|
||||||
|
|
||||||
|
defp pending_user_message(messages, %{message: message} = request) when is_binary(message) do
|
||||||
|
cond do
|
||||||
|
persisted_user_message_for_request?(messages, request) ->
|
||||||
|
nil
|
||||||
|
|
||||||
|
true ->
|
||||||
|
legacy_pending_user_message(messages, message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pending_user_message(_messages, _request), do: nil
|
||||||
|
|
||||||
|
defp legacy_pending_user_message(messages, message) do
|
||||||
|
case messages |> Enum.reverse() |> Enum.find(&(&1.role not in [:system, :tool])) do
|
||||||
|
%{role: :user, content: ^message} -> nil
|
||||||
|
_other -> message
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp streaming_content(nil), do: ""
|
||||||
|
defp streaming_content(%{content: content}) when is_binary(content), do: content
|
||||||
|
defp streaming_content(_request), do: ""
|
||||||
|
|
||||||
|
defp streaming_content(messages, request) do
|
||||||
|
content = streaming_content(request)
|
||||||
|
|
||||||
|
if content != "" and persisted_assistant_content_for_request?(messages, request, content) do
|
||||||
|
""
|
||||||
|
else
|
||||||
|
content
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp streaming_tool_markers(_messages, nil), do: []
|
||||||
|
|
||||||
|
defp streaming_tool_markers(messages, request) do
|
||||||
|
request
|
||||||
|
|> ToolTracking.tool_markers_from_events()
|
||||||
|
|> drop_persisted_tool_markers(messages, request)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp streaming_inline_surfaces(_conversation_id, [], _assigns), do: []
|
||||||
|
|
||||||
|
defp streaming_inline_surfaces(conversation_id, tool_markers, assigns) do
|
||||||
|
tool_markers
|
||||||
|
|> ToolSurfaces.build_render_surfaces("streaming-#{conversation_id}", assigns)
|
||||||
|
|> mark_surfaces_expanded(assigns)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp persisted_user_message_for_request?(messages, %{message: message} = request)
|
||||||
|
when is_binary(message) do
|
||||||
|
messages
|
||||||
|
|> persisted_messages_for_request(request)
|
||||||
|
|> Enum.any?(fn persisted_message ->
|
||||||
|
persisted_message.role == :user and persisted_message.content == message
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp persisted_user_message_for_request?(_messages, _request), do: false
|
||||||
|
|
||||||
|
defp persisted_assistant_content_for_request?(messages, request, content)
|
||||||
|
when is_binary(content) and content != "" do
|
||||||
|
messages
|
||||||
|
|> persisted_messages_for_request(request)
|
||||||
|
|> Enum.any?(fn persisted_message ->
|
||||||
|
persisted_message.role == :assistant and (persisted_message.content || "") == content
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp persisted_assistant_content_for_request?(_messages, _request, _content), do: false
|
||||||
|
|
||||||
|
defp drop_persisted_tool_markers(tool_markers, messages, request) do
|
||||||
|
persisted_markers = persisted_tool_markers_for_request(messages, request)
|
||||||
|
|
||||||
|
{remaining, _persisted_markers} =
|
||||||
|
Enum.reduce(tool_markers, {[], persisted_markers}, fn marker,
|
||||||
|
{remaining, persisted_markers} ->
|
||||||
|
case pop_matching_tool_marker(persisted_markers, marker) do
|
||||||
|
{nil, persisted_markers} -> {remaining ++ [marker], persisted_markers}
|
||||||
|
{_matched, persisted_markers} -> {remaining, persisted_markers}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
remaining
|
||||||
|
end
|
||||||
|
|
||||||
|
defp persisted_tool_markers_for_request(messages, request) do
|
||||||
|
messages
|
||||||
|
|> persisted_messages_for_request(request)
|
||||||
|
|> Enum.flat_map(fn message ->
|
||||||
|
if message.role == :assistant do
|
||||||
|
ToolTracking.normalize_tool_calls(message.tool_calls)
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp pop_matching_tool_marker(tool_markers, marker) do
|
||||||
|
case Enum.find_index(tool_markers, &same_tool_marker?(&1, marker)) do
|
||||||
|
nil -> {nil, tool_markers}
|
||||||
|
index -> {Enum.at(tool_markers, index), List.delete_at(tool_markers, index)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp same_tool_marker?(left, right) do
|
||||||
|
cond do
|
||||||
|
is_binary(left.id) and is_binary(right.id) ->
|
||||||
|
left.id == right.id
|
||||||
|
|
||||||
|
true ->
|
||||||
|
left.name == right.name and (left.arguments || %{}) == (right.arguments || %{})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp persisted_messages_for_request(messages, request) do
|
||||||
|
case request_started_at(request) do
|
||||||
|
started_at when is_integer(started_at) ->
|
||||||
|
Enum.filter(messages, fn message ->
|
||||||
|
is_integer(message.created_at) and message.created_at >= started_at
|
||||||
|
end)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp request_started_at(%{started_at: started_at}) when is_integer(started_at), do: started_at
|
||||||
|
defp request_started_at(_request), do: nil
|
||||||
|
end
|
||||||
81
lib/bds/desktop/shell_live/chat_editor/model_selection.ex
Normal file
81
lib/bds/desktop/shell_live/chat_editor/model_selection.ex
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.ChatEditor.ModelSelection do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias BDS.AI
|
||||||
|
|
||||||
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
|
@spec toggle_model_selector(term(), term()) :: term()
|
||||||
|
def toggle_model_selector(socket, reload) do
|
||||||
|
%{id: conversation_id} = socket.assigns.current_tab
|
||||||
|
current = Map.get(socket.assigns.chat_model_selectors_open, conversation_id, false)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(
|
||||||
|
:chat_model_selectors_open,
|
||||||
|
Map.put(socket.assigns.chat_model_selectors_open, conversation_id, not current)
|
||||||
|
)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec set_model(term(), term(), term(), term()) :: term()
|
||||||
|
def set_model(socket, model_id, reload, append_output) do
|
||||||
|
%{id: conversation_id} = socket.assigns.current_tab
|
||||||
|
|
||||||
|
case AI.set_conversation_model(conversation_id, model_id) do
|
||||||
|
{:ok, _conversation} ->
|
||||||
|
socket
|
||||||
|
|> assign(
|
||||||
|
:chat_model_selectors_open,
|
||||||
|
Map.put(socket.assigns.chat_model_selectors_open, conversation_id, false)
|
||||||
|
)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(dgettext("ui", "Chat"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec group_available_models(term()) :: term()
|
||||||
|
def group_available_models(models) when is_list(models) do
|
||||||
|
models
|
||||||
|
|> Enum.group_by(&Map.get(&1, :provider, "other"))
|
||||||
|
|> Enum.map(fn {provider, entries} ->
|
||||||
|
%{
|
||||||
|
provider: provider,
|
||||||
|
label: provider_group_label(entries, provider),
|
||||||
|
models:
|
||||||
|
Enum.sort_by(
|
||||||
|
entries,
|
||||||
|
&String.downcase(to_string(Map.get(&1, :name) || Map.get(&1, :id)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|> Enum.sort_by(&String.downcase(to_string(&1.label)))
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec needs_api_key?(term()) :: term()
|
||||||
|
def needs_api_key?(true), do: false
|
||||||
|
|
||||||
|
def needs_api_key?(false) do
|
||||||
|
case AI.get_endpoint(:online) do
|
||||||
|
{:ok, %{url: url, model: model, api_key: api_key}} ->
|
||||||
|
blank?(url) or blank?(model) or blank?(api_key)
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp provider_group_label([%{provider_name: name} | _entries], _provider)
|
||||||
|
when is_binary(name) and name != "",
|
||||||
|
do: name
|
||||||
|
|
||||||
|
defp provider_group_label(_entries, provider) when is_binary(provider), do: provider
|
||||||
|
|
||||||
|
defp blank?(value) when is_binary(value), do: String.trim(value) == ""
|
||||||
|
defp blank?(nil), do: true
|
||||||
|
end
|
||||||
299
lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex
Normal file
299
lib/bds/desktop/shell_live/chat_editor/tool_surfaces.ex
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.ChatEditor.ToolSurfaces do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
|
@render_tool_names MapSet.new([
|
||||||
|
"render_card",
|
||||||
|
"render_chart",
|
||||||
|
"render_form",
|
||||||
|
"render_list",
|
||||||
|
"render_metric",
|
||||||
|
"render_mindmap",
|
||||||
|
"render_table",
|
||||||
|
"render_tabs"
|
||||||
|
])
|
||||||
|
|
||||||
|
@spec render_tool?(term()) :: term()
|
||||||
|
def render_tool?(name) when is_binary(name), do: MapSet.member?(@render_tool_names, name)
|
||||||
|
@spec render_tool?(term()) :: term()
|
||||||
|
def render_tool?(_name), do: false
|
||||||
|
|
||||||
|
@spec build_render_surfaces(term(), term(), term()) :: term()
|
||||||
|
def build_render_surfaces(tool_calls, message_id, assigns) do
|
||||||
|
tool_calls
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.flat_map(fn {tool_call, index} ->
|
||||||
|
case build_render_surface(tool_call, "#{message_id}-surface-#{index}", assigns) do
|
||||||
|
nil -> []
|
||||||
|
surface -> [surface]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec build_render_surface(term(), term(), term()) :: term()
|
||||||
|
def build_render_surface(%{name: name, arguments: arguments}, surface_id, assigns) do
|
||||||
|
if MapSet.member?(@render_tool_names, name) do
|
||||||
|
do_build_render_surface(name, arguments || %{}, surface_id, assigns)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_tool_surface(content) when is_binary(content) do
|
||||||
|
case Jason.decode(content) do
|
||||||
|
{:ok, %{"type" => type} = decoded} ->
|
||||||
|
%{
|
||||||
|
type: type,
|
||||||
|
title: decoded["title"],
|
||||||
|
columns: List.wrap(decoded["columns"]),
|
||||||
|
rows: Enum.map(List.wrap(decoded["rows"]), &List.wrap/1),
|
||||||
|
fields: List.wrap(decoded["fields"]),
|
||||||
|
data: decoded
|
||||||
|
}
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec normalize_tool_surface(term()) :: term()
|
||||||
|
def normalize_tool_surface(_content), do: nil
|
||||||
|
|
||||||
|
defp do_build_render_surface("render_card", arguments, surface_id, _assigns) do
|
||||||
|
%{
|
||||||
|
id: surface_id,
|
||||||
|
type: "card",
|
||||||
|
title: map_value(arguments, "title"),
|
||||||
|
subtitle: map_value(arguments, "subtitle"),
|
||||||
|
body: map_value(arguments, "body", ""),
|
||||||
|
actions: decode_surface_actions(map_value(arguments, "actions", []))
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_build_render_surface("render_table", arguments, surface_id, _assigns) do
|
||||||
|
%{
|
||||||
|
id: surface_id,
|
||||||
|
type: "table",
|
||||||
|
title: map_value(arguments, "title"),
|
||||||
|
columns: stringify_list(map_value(arguments, "columns", [])),
|
||||||
|
rows: Enum.map(List.wrap(map_value(arguments, "rows", [])), &stringify_list/1)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_build_render_surface("render_chart", arguments, surface_id, _assigns) do
|
||||||
|
series =
|
||||||
|
map_value(arguments, "series", [])
|
||||||
|
|> List.wrap()
|
||||||
|
|> Enum.map(fn entry ->
|
||||||
|
%{
|
||||||
|
label: map_value(entry, "label", dgettext("ui", "Assistant")),
|
||||||
|
value: numeric_value(map_value(entry, "value", 0)),
|
||||||
|
segments: List.wrap(map_value(entry, "segments", []))
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: surface_id,
|
||||||
|
type: "chart",
|
||||||
|
title: map_value(arguments, "title"),
|
||||||
|
chart_type: map_value(arguments, "chart_type", "bar"),
|
||||||
|
series: series,
|
||||||
|
max_value: Enum.max([0 | Enum.map(series, & &1.value)])
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_build_render_surface("render_metric", arguments, surface_id, _assigns) do
|
||||||
|
%{
|
||||||
|
id: surface_id,
|
||||||
|
type: "metric",
|
||||||
|
label: map_value(arguments, "label", "Metric"),
|
||||||
|
value: map_value(arguments, "value", "")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_build_render_surface("render_list", arguments, surface_id, _assigns) do
|
||||||
|
%{
|
||||||
|
id: surface_id,
|
||||||
|
type: "list",
|
||||||
|
title: map_value(arguments, "title"),
|
||||||
|
items: stringify_list(map_value(arguments, "items", []))
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_build_render_surface("render_mindmap", arguments, surface_id, _assigns) do
|
||||||
|
nodes =
|
||||||
|
arguments
|
||||||
|
|> map_value("nodes", [])
|
||||||
|
|> List.wrap()
|
||||||
|
|> Enum.map(fn node ->
|
||||||
|
%{
|
||||||
|
id: map_value(node, "id"),
|
||||||
|
label: map_value(node, "label", "Node"),
|
||||||
|
children: stringify_list(map_value(node, "children", []))
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: surface_id,
|
||||||
|
type: "mindmap",
|
||||||
|
title: map_value(arguments, "title"),
|
||||||
|
nodes: nodes
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_build_render_surface("render_form", arguments, surface_id, assigns) do
|
||||||
|
stored_fields = Map.get(assigns.chat_editor_surface_data, surface_id, %{})
|
||||||
|
|
||||||
|
fields =
|
||||||
|
arguments
|
||||||
|
|> map_value("fields", [])
|
||||||
|
|> List.wrap()
|
||||||
|
|> Enum.map(fn field ->
|
||||||
|
key = map_value(field, "key", "field")
|
||||||
|
|
||||||
|
%{
|
||||||
|
key: key,
|
||||||
|
label: map_value(field, "label", key),
|
||||||
|
input_type: map_value(field, "inputType") || map_value(field, "input_type", "text"),
|
||||||
|
placeholder: map_value(field, "placeholder"),
|
||||||
|
value:
|
||||||
|
Map.get(
|
||||||
|
stored_fields,
|
||||||
|
key,
|
||||||
|
map_value(field, "defaultValue") || map_value(field, "default_value")
|
||||||
|
),
|
||||||
|
options: decode_surface_options(map_value(field, "options", [])),
|
||||||
|
required?: truthy?(map_value(field, "required", false))
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: surface_id,
|
||||||
|
type: "form",
|
||||||
|
title: map_value(arguments, "title"),
|
||||||
|
fields: fields,
|
||||||
|
submit_label:
|
||||||
|
map_value(arguments, "submitLabel") ||
|
||||||
|
map_value(arguments, "submit_label", dgettext("ui", "Stop")),
|
||||||
|
submit_action:
|
||||||
|
map_value(arguments, "submitAction") ||
|
||||||
|
map_value(arguments, "submit_action", "submitForm")
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_build_render_surface("render_tabs", arguments, surface_id, assigns) do
|
||||||
|
tabs =
|
||||||
|
arguments
|
||||||
|
|> map_value("tabs", [])
|
||||||
|
|> List.wrap()
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.map(fn {tab, tab_index} ->
|
||||||
|
%{
|
||||||
|
label: map_value(tab, "label", "Tab #{tab_index + 1}"),
|
||||||
|
content:
|
||||||
|
tab
|
||||||
|
|> map_value("content", [])
|
||||||
|
|> List.wrap()
|
||||||
|
|> Enum.with_index()
|
||||||
|
|> Enum.map(fn {content, content_index} ->
|
||||||
|
build_tab_surface(
|
||||||
|
content,
|
||||||
|
"#{surface_id}-tab-#{tab_index}-#{content_index}",
|
||||||
|
assigns
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: surface_id,
|
||||||
|
type: "tabs",
|
||||||
|
title: map_value(arguments, "title"),
|
||||||
|
tabs: tabs,
|
||||||
|
selected_index: Map.get(assigns.chat_editor_surface_tabs, surface_id, 0)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_build_render_surface(_name, arguments, surface_id, _assigns) do
|
||||||
|
%{id: surface_id, type: "json", raw: arguments}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_tab_surface(%{} = content, surface_id, assigns) do
|
||||||
|
type = map_value(content, "type", "text")
|
||||||
|
|
||||||
|
case type do
|
||||||
|
render_type
|
||||||
|
when render_type in ["card", "chart", "form", "list", "metric", "mindmap", "table", "tabs"] ->
|
||||||
|
do_build_render_surface(
|
||||||
|
"render_#{render_type}",
|
||||||
|
Map.delete(content, "type"),
|
||||||
|
surface_id,
|
||||||
|
assigns
|
||||||
|
)
|
||||||
|
|
||||||
|
"text" ->
|
||||||
|
%{
|
||||||
|
id: surface_id,
|
||||||
|
type: "text",
|
||||||
|
body: map_value(content, "body") || map_value(content, "text", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
%{id: surface_id, type: "json", raw: content}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_tab_surface(content, surface_id, _assigns) do
|
||||||
|
%{id: surface_id, type: "text", body: to_string(content || "")}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_surface_actions(actions) when is_list(actions) do
|
||||||
|
Enum.map(actions, fn action ->
|
||||||
|
%{
|
||||||
|
label: map_value(action, "label", dgettext("ui", "Open Settings")),
|
||||||
|
action: map_value(action, "action", "openSettings"),
|
||||||
|
payload: map_value(action, "payload", %{})
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_surface_actions(_actions), do: []
|
||||||
|
|
||||||
|
defp decode_surface_options(options) when is_list(options) do
|
||||||
|
Enum.map(options, fn option ->
|
||||||
|
%{
|
||||||
|
label: map_value(option, "label", ""),
|
||||||
|
value: map_value(option, "value", "")
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_surface_options(_options), do: []
|
||||||
|
|
||||||
|
defp stringify_list(values) when is_list(values), do: Enum.map(values, &to_string/1)
|
||||||
|
defp stringify_list(value), do: List.wrap(value) |> Enum.map(&to_string/1)
|
||||||
|
|
||||||
|
defp numeric_value(value) when is_integer(value), do: value
|
||||||
|
defp numeric_value(value) when is_float(value), do: value
|
||||||
|
|
||||||
|
defp numeric_value(value) when is_binary(value) do
|
||||||
|
case Float.parse(value) do
|
||||||
|
{parsed, ""} -> parsed
|
||||||
|
_other -> 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp numeric_value(_value), do: 0
|
||||||
|
|
||||||
|
defp map_value(map, key, default \\ nil)
|
||||||
|
|
||||||
|
defp map_value(map, key, default) when is_map(map) and is_binary(key) do
|
||||||
|
Map.get(map, key, Map.get(map, String.to_existing_atom(key), default))
|
||||||
|
rescue
|
||||||
|
ArgumentError -> Map.get(map, key, default)
|
||||||
|
end
|
||||||
|
|
||||||
|
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: false
|
||||||
|
end
|
||||||
123
lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex
Normal file
123
lib/bds/desktop/shell_live/chat_editor/tool_tracking.ex
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.ChatEditor.ToolTracking do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
@tool_args_max_length 30
|
||||||
|
|
||||||
|
@spec tool_call_name(term()) :: term()
|
||||||
|
def tool_call_name(tool_call) when is_map(tool_call) do
|
||||||
|
BDS.MapUtils.attr(tool_call, :name) || "tool"
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec tool_call_id(term()) :: term()
|
||||||
|
def tool_call_id(tool_call) when is_map(tool_call) do
|
||||||
|
BDS.MapUtils.attr(tool_call, :id)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec tool_call_id(term()) :: term()
|
||||||
|
def tool_call_id(_tool_call), do: nil
|
||||||
|
|
||||||
|
@spec tool_call_arguments(term()) :: term()
|
||||||
|
def tool_call_arguments(tool_call) when is_map(tool_call) do
|
||||||
|
BDS.MapUtils.attr(tool_call, :arguments) || BDS.MapUtils.attr(tool_call, :args) || %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_tool_calls(tool_calls) when is_list(tool_calls) do
|
||||||
|
Enum.map(tool_calls, fn tool_call ->
|
||||||
|
arguments = tool_call_arguments(tool_call)
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: BDS.MapUtils.attr(tool_call, :id),
|
||||||
|
name: tool_call_name(tool_call),
|
||||||
|
arguments: arguments,
|
||||||
|
args_preview: tool_arguments_preview(arguments),
|
||||||
|
result: nil,
|
||||||
|
complete?: false
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec normalize_tool_calls(term()) :: term()
|
||||||
|
def normalize_tool_calls(_tool_calls), do: []
|
||||||
|
|
||||||
|
def tool_arguments_preview(arguments) when is_map(arguments) do
|
||||||
|
arguments
|
||||||
|
|> Enum.map(fn {key, value} -> "#{key}: #{preview_value(value)}" end)
|
||||||
|
|> Enum.join(", ")
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec tool_arguments_preview(term()) :: term()
|
||||||
|
def tool_arguments_preview(_arguments), do: ""
|
||||||
|
|
||||||
|
@spec mark_tool_call_completed(term(), term()) :: term()
|
||||||
|
def mark_tool_call_completed(entry, tool_call_id) when is_binary(tool_call_id) do
|
||||||
|
mark_tool_call_completed(entry, tool_call_id, nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_tool_call_completed(entry, _tool_call_id), do: entry
|
||||||
|
|
||||||
|
@spec mark_tool_call_completed(term(), term(), term()) :: term()
|
||||||
|
def mark_tool_call_completed(entry, tool_call_id, result) when is_binary(tool_call_id) do
|
||||||
|
update_in(entry.tool_markers, fn markers ->
|
||||||
|
Enum.map(markers, fn marker ->
|
||||||
|
if marker.id == tool_call_id do
|
||||||
|
%{marker | complete?: true, result: result}
|
||||||
|
else
|
||||||
|
marker
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_tool_call_completed(entry, _tool_call_id, _result), do: entry
|
||||||
|
|
||||||
|
@spec tool_markers_from_events(term()) :: term()
|
||||||
|
def tool_markers_from_events(nil), do: []
|
||||||
|
|
||||||
|
def tool_markers_from_events(%{tool_events: tool_events}) do
|
||||||
|
Enum.reduce(tool_events || [], [], fn event, markers ->
|
||||||
|
case event.type do
|
||||||
|
:call ->
|
||||||
|
markers ++
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
id: Map.get(event, :id),
|
||||||
|
name: event.name,
|
||||||
|
arguments: event.arguments,
|
||||||
|
args_preview: tool_arguments_preview(event.arguments || %{}),
|
||||||
|
result: nil,
|
||||||
|
complete?: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
:result ->
|
||||||
|
Enum.reverse(markers)
|
||||||
|
|> mark_last_matching_complete(event.name)
|
||||||
|
|> Enum.reverse()
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp mark_last_matching_complete(markers, name) do
|
||||||
|
{updated, found?} =
|
||||||
|
Enum.map_reduce(markers, false, fn marker, found? ->
|
||||||
|
cond do
|
||||||
|
found? -> {marker, true}
|
||||||
|
marker.name == name and not marker.complete? -> {%{marker | complete?: true}, true}
|
||||||
|
true -> {marker, false}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
if found?, do: updated, else: updated
|
||||||
|
end
|
||||||
|
|
||||||
|
defp preview_value(value) when is_binary(value) do
|
||||||
|
quoted =
|
||||||
|
if String.length(value) > @tool_args_max_length,
|
||||||
|
do: String.slice(value, 0, @tool_args_max_length) <> "...",
|
||||||
|
else: value
|
||||||
|
|
||||||
|
inspect(quoted)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp preview_value(value), do: inspect(value)
|
||||||
|
end
|
||||||
@@ -1,154 +1,121 @@
|
|||||||
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel" data-testid="chat-editor" phx-hook="ChatSurface">
|
<div id={"chat-editor-#{@chat_editor.id}"} class="chat-panel ui-editor-shell flex h-full min-h-0 flex-col" data-testid="chat-editor" phx-hook="ChatSurface">
|
||||||
<div class="chat-panel-header">
|
<div class="chat-panel-header flex shrink-0 items-center justify-between gap-3 px-4 py-3">
|
||||||
<div class="chat-panel-title">
|
<div class="chat-panel-title flex min-w-0 flex-1 items-center justify-between gap-3">
|
||||||
<%= if @chat_editor.needs_api_key? do %>
|
<span class="chat-panel-title-main">
|
||||||
<%= translated("chat.setupTitle") %>
|
<%= if @chat_editor.needs_api_key? do %>
|
||||||
<% else %>
|
<%= dgettext("ui", "AI Chat Setup") %>
|
||||||
<%= @chat_editor.title %>
|
<% else %>
|
||||||
|
<%= @chat_editor.title %>
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<%= unless @chat_editor.needs_api_key? do %>
|
||||||
|
<span class="chat-model-selector-wrap relative shrink-0">
|
||||||
|
<button
|
||||||
|
class="chat-model-selector-button chat-model-selector-inline ui-button ui-button-secondary inline-flex items-center gap-2"
|
||||||
|
type="button"
|
||||||
|
phx-click="toggle_chat_model_selector"
|
||||||
|
phx-target={@myself}
|
||||||
|
data-testid="chat-model-selector-button"
|
||||||
|
>
|
||||||
|
<span><%= @chat_editor.effective_model || dgettext("ui", "No model") %></span>
|
||||||
|
<span class="chat-model-selector-caret">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
|
||||||
|
<div class="chat-model-selector-menu ui-dropdown-menu absolute right-0 top-full z-10 mt-2 flex min-w-56 flex-col">
|
||||||
|
<%= for group <- @chat_editor.available_model_groups do %>
|
||||||
|
<section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}>
|
||||||
|
<%= if length(@chat_editor.available_model_groups) > 1 do %>
|
||||||
|
<div class="chat-model-provider-header"><%= group.label %></div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= for model <- group.models do %>
|
||||||
|
<button
|
||||||
|
class={[
|
||||||
|
"chat-model-selector-option ui-dropdown-item flex items-center justify-between gap-2 text-left",
|
||||||
|
if(model.id == @chat_editor.effective_model, do: "active")
|
||||||
|
]}
|
||||||
|
type="button"
|
||||||
|
phx-click="select_chat_model"
|
||||||
|
phx-target={@myself}
|
||||||
|
phx-value-model={model.id}
|
||||||
|
data-testid="chat-model-selector-option"
|
||||||
|
data-provider={group.provider}
|
||||||
|
>
|
||||||
|
<span class="chat-model-selector-option-name"><%= model.name || model.id %></span>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= unless @chat_editor.needs_api_key? do %>
|
|
||||||
<div class="chat-panel-header-actions">
|
|
||||||
<button
|
|
||||||
class="chat-model-selector-button"
|
|
||||||
type="button"
|
|
||||||
phx-click="toggle_chat_model_selector"
|
|
||||||
data-testid="chat-model-selector-button"
|
|
||||||
>
|
|
||||||
<span><%= @chat_editor.model || translated("chat.newChat") %></span>
|
|
||||||
<span class="chat-model-selector-caret">▾</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<%= if @chat_editor.model_selector_open? and @chat_editor.available_models != [] do %>
|
|
||||||
<div class="chat-model-selector-menu">
|
|
||||||
<%= for group <- @chat_editor.available_model_groups do %>
|
|
||||||
<section class="chat-model-provider-group" data-testid="chat-model-provider-group" data-provider={group.provider}>
|
|
||||||
<%= if length(@chat_editor.available_model_groups) > 1 do %>
|
|
||||||
<div class="chat-model-provider-header"><%= group.label %></div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= for model <- group.models do %>
|
|
||||||
<button
|
|
||||||
class={[
|
|
||||||
"chat-model-selector-option",
|
|
||||||
if(model.id == @chat_editor.model, do: "active")
|
|
||||||
]}
|
|
||||||
type="button"
|
|
||||||
phx-click="select_chat_model"
|
|
||||||
phx-value-model={model.id}
|
|
||||||
data-testid="chat-model-selector-option"
|
|
||||||
data-provider={group.provider}
|
|
||||||
>
|
|
||||||
<span class="chat-model-selector-option-name"><%= model.name || model.id %></span>
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</section>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="chat-messages chat-surface-scroll">
|
<div class="chat-messages chat-surface-scroll min-h-0 flex-1 overflow-auto">
|
||||||
<%= if @chat_editor.needs_api_key? do %>
|
<%= if @chat_editor.needs_api_key? do %>
|
||||||
<div class="chat-welcome chat-api-key-state" data-testid="chat-api-key-required">
|
<div class="chat-welcome chat-api-key-state ui-section-card flex flex-col items-start gap-3 p-4" data-testid="chat-api-key-required">
|
||||||
<div class="chat-welcome-icon">🔑</div>
|
<div class="chat-welcome-icon">🔑</div>
|
||||||
<h2><%= translated("chat.apiKeyRequiredTitle") %></h2>
|
<h2><%= dgettext("ui", "API Key Required") %></h2>
|
||||||
<p><%= translated("chat.apiKeyRequiredDescription") %></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" type="button" phx-click="open_chat_settings"><%= translated("chat.openSettings") %></button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= if Enum.empty?(@chat_editor.messages) and not @chat_editor.is_streaming do %>
|
<%= if Enum.empty?(@chat_editor.messages) and not @chat_editor.is_streaming do %>
|
||||||
<div class="chat-welcome">
|
<div class="chat-welcome ui-section-card flex flex-col items-start gap-3 p-4">
|
||||||
<div class="chat-welcome-icon">🤖</div>
|
<div class="chat-welcome-icon">🤖</div>
|
||||||
<h2><%= translated("chat.welcomeTitle") %></h2>
|
<h2><%= dgettext("ui", "Welcome to the AI Assistant") %></h2>
|
||||||
<p><%= translated("chat.welcomeDescription") %></p>
|
<p><%= dgettext("ui", "I can help you manage your blog with rich visualizations. Try asking me to:") %></p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><%= translated("chat.welcomeTipSearch") %></li>
|
<li><%= dgettext("ui", "Search for posts about a specific topic") %></li>
|
||||||
<li><%= translated("chat.welcomeTipChart") %></li>
|
<li><%= dgettext("ui", "Show a chart of posts published per month") %></li>
|
||||||
<li><%= translated("chat.welcomeTipTable") %></li>
|
<li><%= dgettext("ui", "Compare my recent posts in a table") %></li>
|
||||||
<li><%= translated("chat.welcomeTipMetadata") %></li>
|
<li><%= dgettext("ui", "Update metadata for posts or media") %></li>
|
||||||
<li><%= translated("chat.welcomeTipTabs") %></li>
|
<li><%= dgettext("ui", "Show post statistics by year in tabs with charts") %></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= if @chat_editor.pending_user_message do %>
|
|
||||||
<div class="chat-message user pending" data-testid="chat-pending-user-message">
|
|
||||||
<div class="chat-message-avatar">👤</div>
|
|
||||||
<div class="chat-message-content">
|
|
||||||
<div class="chat-message-header">
|
|
||||||
<span class="chat-message-role"><%= message_role_label(:user) %></span>
|
|
||||||
</div>
|
|
||||||
<div class="chat-message-text"><%= @chat_editor.pending_user_message %></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= for message <- @chat_editor.messages do %>
|
<%= for message <- @chat_editor.messages do %>
|
||||||
<div class={["chat-message", to_string(message.role || "assistant")]}>
|
<div class={["chat-message flex items-start gap-3", to_string(message.role || "assistant")]}>
|
||||||
<div class="chat-message-avatar"><%= if message.role == :user, do: "👤", else: "🤖" %></div>
|
<div class="chat-message-avatar"><%= if message.role == :user, do: "👤", else: "🤖" %></div>
|
||||||
<div class="chat-message-content">
|
<div class="chat-message-content ui-section-card">
|
||||||
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
|
<div class="chat-message-header"><span class="chat-message-role"><%= message_role_label(message.role) %></span></div>
|
||||||
<.chat_tool_markers markers={message.tool_markers} />
|
<.chat_tool_markers markers={message.tool_markers} />
|
||||||
|
|
||||||
<div class="chat-message-text">
|
<%= if message.role == :assistant do %>
|
||||||
<%= if message.role == :assistant do %>
|
<div class="chat-message-text"><%= markdown_html(message.content || "") %></div>
|
||||||
<%= markdown_html(message.content || "") %>
|
<%= for surface <- message.inline_surfaces do %>
|
||||||
<% else %>
|
<.chat_surface surface={surface} myself={@myself} />
|
||||||
<%= message.content || "" %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
<% else %>
|
||||||
|
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= message.content || "" %></div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= for surface <- message.inline_surfaces do %>
|
<% end %>
|
||||||
<.chat_surface surface={surface} />
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= for surface <- message.tool_surfaces do %>
|
<%= if @chat_editor.pending_user_message do %>
|
||||||
<article class="chat-tool-surface" data-testid="chat-tool-surface">
|
<div class="chat-message user pending flex items-start gap-3" data-testid="chat-pending-user-message">
|
||||||
<%= if surface.title do %>
|
<div class="chat-message-avatar">👤</div>
|
||||||
<h3><%= surface.title %></h3>
|
<div class="chat-message-content ui-section-card">
|
||||||
<% end %>
|
<div class="chat-message-header">
|
||||||
|
<span class="chat-message-role"><%= message_role_label(:user) %></span>
|
||||||
<%= case tool_surface_type(surface) do %>
|
</div>
|
||||||
<% "table" -> %>
|
<div class="chat-message-text chat-user-message-text" data-testid="chat-user-message-text"><%= @chat_editor.pending_user_message %></div>
|
||||||
<div class="chat-tool-surface-table-wrap">
|
</div>
|
||||||
<table class="chat-tool-surface-table">
|
</div>
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<%= for column <- surface.columns do %>
|
|
||||||
<th><%= column %></th>
|
|
||||||
<% end %>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<%= for row <- surface.rows do %>
|
|
||||||
<tr>
|
|
||||||
<%= for value <- row do %>
|
|
||||||
<td><%= value %></td>
|
|
||||||
<% end %>
|
|
||||||
</tr>
|
|
||||||
<% end %>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% _other -> %>
|
|
||||||
<pre class="chat-tool-surface-json"><%= Jason.encode!(surface.data, pretty: true) %></pre>
|
|
||||||
<% end %>
|
|
||||||
</article>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
|
<%= if @chat_editor.is_streaming and (@chat_editor.streaming_content != "" or @chat_editor.streaming_tool_markers != []) do %>
|
||||||
<div class="chat-message assistant streaming" data-testid="chat-streaming-message">
|
<div class="chat-message assistant streaming flex items-start gap-3" data-testid="chat-streaming-message">
|
||||||
<div class="chat-message-avatar">🤖</div>
|
<div class="chat-message-avatar">🤖</div>
|
||||||
<div class="chat-message-content">
|
<div class="chat-message-content ui-section-card">
|
||||||
<div class="chat-message-header">
|
<div class="chat-message-header">
|
||||||
<span class="chat-message-role"><%= message_role_label(:assistant) %></span>
|
<span class="chat-message-role"><%= message_role_label(:assistant) %></span>
|
||||||
<span class="streaming-indicator">●</span>
|
<span class="streaming-indicator">●</span>
|
||||||
@@ -158,12 +125,15 @@
|
|||||||
<%= if @chat_editor.streaming_content != "" do %>
|
<%= if @chat_editor.streaming_content != "" do %>
|
||||||
<div class="chat-message-text"><%= markdown_html(@chat_editor.streaming_content) %></div>
|
<div class="chat-message-text"><%= markdown_html(@chat_editor.streaming_content) %></div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<%= for surface <- @chat_editor.streaming_inline_surfaces do %>
|
||||||
|
<.chat_surface surface={surface} myself={@myself} />
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>
|
<%= if @chat_editor.is_streaming and @chat_editor.streaming_content == "" and @chat_editor.streaming_tool_markers == [] do %>
|
||||||
<div class="chat-message assistant thinking" data-testid="chat-streaming-thinking">
|
<div class="chat-message assistant thinking flex items-start gap-3" data-testid="chat-streaming-thinking">
|
||||||
<div class="chat-message-avatar">🤖</div>
|
<div class="chat-message-avatar">🤖</div>
|
||||||
<div class="chat-message-content">
|
<div class="chat-message-content">
|
||||||
<div class="chat-thinking-indicator">
|
<div class="chat-thinking-indicator">
|
||||||
@@ -177,14 +147,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= unless @chat_editor.needs_api_key? do %>
|
<%= unless @chat_editor.needs_api_key? do %>
|
||||||
<div class="chat-input-container" data-testid="chat-input-container">
|
<div class="chat-input-container ui-field-stack flex shrink-0 flex-col gap-3" data-testid="chat-input-container">
|
||||||
<%= if @chat_editor.is_streaming do %>
|
<%= if @chat_editor.is_streaming do %>
|
||||||
<button class="chat-abort-button" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message">◼ <%= translated("chat.stop") %></button>
|
<button class="chat-abort-button ui-button ui-button-secondary" data-testid="chat-abort-button" type="button" phx-click="abort_chat_editor_message" phx-target={@myself}>◼ <%= dgettext("ui", "Stop") %></button>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<form class="chat-input-wrapper" phx-change="change_chat_editor_input" phx-submit="send_chat_editor_message">
|
<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" name="message" rows="1" placeholder={translated("chat.inputPlaceholder")} 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" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" disabled={@chat_editor.send_disabled?}>↑</button>
|
<button class="chat-send-button ui-button ui-button-primary" data-testid="chat-send-button" type="button" phx-click="send_chat_editor_message" phx-target={@myself} disabled={@chat_editor.send_disabled?}>↑</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<%= if @chat_editor.action_error do %>
|
<%= if @chat_editor.action_error do %>
|
||||||
|
|||||||
@@ -3,138 +3,7 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
|
|||||||
|
|
||||||
import Phoenix.Component, only: [assign: 3]
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
|
||||||
alias BDS.Desktop.ShellData
|
use Gettext, backend: BDS.Gettext
|
||||||
alias BDS.Desktop.ShellLive.{ChatEditor, TabHelpers}
|
|
||||||
alias BDS.UI.Workbench
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Handle a chat-surface action from a chat message. Receives callbacks for
|
|
||||||
`reload_shell/2` and `open_sidebar_item/3` to remain decoupled from
|
|
||||||
`BDS.Desktop.ShellLive` private state.
|
|
||||||
"""
|
|
||||||
def handle_action(socket, params, callbacks) do
|
|
||||||
surface_id = Map.get(params, "surface-id", "")
|
|
||||||
|
|
||||||
payload =
|
|
||||||
params
|
|
||||||
|> Map.get("payload")
|
|
||||||
|> decode_payload()
|
|
||||||
|> maybe_put_form_data(socket, surface_id)
|
|
||||||
|
|
||||||
case normalize_action(Map.get(params, "action", "")) do
|
|
||||||
:open_post ->
|
|
||||||
case Map.get(payload, "postId") || Map.get(payload, "post_id") do
|
|
||||||
post_id when is_binary(post_id) and post_id != "" ->
|
|
||||||
socket
|
|
||||||
|> clear_action_error()
|
|
||||||
|> callbacks.open_sidebar.(
|
|
||||||
%{
|
|
||||||
"route" => "post",
|
|
||||||
"id" => post_id,
|
|
||||||
"title" => TabHelpers.post_title(post_id),
|
|
||||||
"subtitle" => TabHelpers.post_subtitle(post_id)
|
|
||||||
},
|
|
||||||
:pin
|
|
||||||
)
|
|
||||||
|
|
||||||
_other ->
|
|
||||||
ChatEditor.set_action_error(
|
|
||||||
socket,
|
|
||||||
socket.assigns.current_tab.id,
|
|
||||||
"Invalid payload for openPost action",
|
|
||||||
callbacks.reload
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
:open_media ->
|
|
||||||
case Map.get(payload, "mediaId") || Map.get(payload, "media_id") do
|
|
||||||
media_id when is_binary(media_id) and media_id != "" ->
|
|
||||||
socket
|
|
||||||
|> clear_action_error()
|
|
||||||
|> callbacks.open_sidebar.(
|
|
||||||
%{
|
|
||||||
"route" => "media",
|
|
||||||
"id" => media_id,
|
|
||||||
"title" => TabHelpers.media_title(media_id),
|
|
||||||
"subtitle" => TabHelpers.media_subtitle(media_id)
|
|
||||||
},
|
|
||||||
:pin
|
|
||||||
)
|
|
||||||
|
|
||||||
_other ->
|
|
||||||
ChatEditor.set_action_error(
|
|
||||||
socket,
|
|
||||||
socket.assigns.current_tab.id,
|
|
||||||
"Invalid payload for openMedia action",
|
|
||||||
callbacks.reload
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
:open_settings ->
|
|
||||||
socket
|
|
||||||
|> clear_action_error()
|
|
||||||
|> callbacks.open_sidebar.(
|
|
||||||
%{"route" => "settings", "id" => "settings-ai", "title" => "Settings", "subtitle" => "AI"},
|
|
||||||
:pin
|
|
||||||
)
|
|
||||||
|
|
||||||
:open_chat ->
|
|
||||||
chat_id =
|
|
||||||
Map.get(payload, "conversationId") || Map.get(payload, "conversation_id") ||
|
|
||||||
socket.assigns.current_tab.id
|
|
||||||
|
|
||||||
socket
|
|
||||||
|> clear_action_error()
|
|
||||||
|> callbacks.open_sidebar.(
|
|
||||||
%{
|
|
||||||
"route" => "chat",
|
|
||||||
"id" => chat_id,
|
|
||||||
"title" => Map.get(payload, "title", "Chat"),
|
|
||||||
"subtitle" => Map.get(payload, "subtitle", "")
|
|
||||||
},
|
|
||||||
:pin
|
|
||||||
)
|
|
||||||
|
|
||||||
:switch_view ->
|
|
||||||
case safe_existing_atom(Map.get(payload, "view")) do
|
|
||||||
nil ->
|
|
||||||
ChatEditor.set_action_error(
|
|
||||||
socket,
|
|
||||||
socket.assigns.current_tab.id,
|
|
||||||
"Invalid payload for switchView action",
|
|
||||||
callbacks.reload
|
|
||||||
)
|
|
||||||
|
|
||||||
view ->
|
|
||||||
socket
|
|
||||||
|> clear_action_error()
|
|
||||||
|> callbacks.reload.(Workbench.click_activity(socket.assigns.workbench, view))
|
|
||||||
end
|
|
||||||
|
|
||||||
:toggle_sidebar ->
|
|
||||||
socket
|
|
||||||
|> clear_action_error()
|
|
||||||
|> callbacks.reload.(Workbench.toggle_sidebar(socket.assigns.workbench))
|
|
||||||
|
|
||||||
:toggle_panel ->
|
|
||||||
socket
|
|
||||||
|> clear_action_error()
|
|
||||||
|> callbacks.reload.(Workbench.toggle_panel(socket.assigns.workbench))
|
|
||||||
|
|
||||||
:toggle_assistant_sidebar ->
|
|
||||||
socket
|
|
||||||
|> clear_action_error()
|
|
||||||
|> callbacks.reload.(Workbench.toggle_assistant_sidebar(socket.assigns.workbench))
|
|
||||||
|
|
||||||
:unknown ->
|
|
||||||
ChatEditor.set_action_error(
|
|
||||||
socket,
|
|
||||||
socket.assigns.current_tab.id,
|
|
||||||
"Unsupported assistant action",
|
|
||||||
callbacks.reload
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def assistant_turn(prompt, socket) do
|
def assistant_turn(prompt, socket) do
|
||||||
[
|
[
|
||||||
@@ -143,12 +12,12 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def assistant_project_name(nil), do: translated("Projects")
|
def assistant_project_name(nil), do: dgettext("ui", "Projects")
|
||||||
def assistant_project_name(project), do: project.name
|
def assistant_project_name(project), do: project.name
|
||||||
|
|
||||||
def assistant_message_label("assistant"), do: translated("Assistant")
|
def assistant_message_label("assistant"), do: dgettext("ui", "Assistant")
|
||||||
def assistant_message_label("user"), do: translated("You")
|
def assistant_message_label("user"), do: dgettext("ui", "You")
|
||||||
def assistant_message_label(_role), do: translated("Assistant")
|
def assistant_message_label(_role), do: dgettext("ui", "Assistant")
|
||||||
|
|
||||||
def assistant_message_testid(role), do: "assistant-message-#{role}"
|
def assistant_message_testid(role), do: "assistant-message-#{role}"
|
||||||
|
|
||||||
@@ -159,75 +28,19 @@ defmodule BDS.Desktop.ShellLive.ChatSurface do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def clear_action_error(%{assigns: %{current_tab: %{type: :chat, id: conversation_id}}} = socket) do
|
|
||||||
assign(socket, :chat_editor_action_errors, Map.delete(socket.assigns.chat_editor_action_errors, conversation_id))
|
|
||||||
end
|
|
||||||
|
|
||||||
def clear_action_error(socket), do: socket
|
|
||||||
|
|
||||||
defp decode_payload(nil), do: %{}
|
|
||||||
defp decode_payload(""), do: %{}
|
|
||||||
|
|
||||||
defp decode_payload(payload) when is_binary(payload) do
|
|
||||||
case Jason.decode(payload) do
|
|
||||||
{:ok, decoded} when is_map(decoded) -> decoded
|
|
||||||
_other -> %{}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp decode_payload(_payload), do: %{}
|
|
||||||
|
|
||||||
defp maybe_put_form_data(payload, socket, surface_id) when is_binary(surface_id) and surface_id != "" do
|
|
||||||
form_data = ChatEditor.current_surface_data(socket, surface_id)
|
|
||||||
|
|
||||||
if form_data == %{} do
|
|
||||||
payload
|
|
||||||
else
|
|
||||||
Map.put(payload, "formData", form_data)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp maybe_put_form_data(payload, _socket, _surface_id), do: payload
|
|
||||||
|
|
||||||
defp normalize_action(action) do
|
|
||||||
action
|
|
||||||
|> to_string()
|
|
||||||
|> String.replace("_", "")
|
|
||||||
|> String.downcase()
|
|
||||||
|> case do
|
|
||||||
"openpost" -> :open_post
|
|
||||||
"openmedia" -> :open_media
|
|
||||||
"opensettings" -> :open_settings
|
|
||||||
"openchat" -> :open_chat
|
|
||||||
"switchview" -> :switch_view
|
|
||||||
"setactiveview" -> :switch_view
|
|
||||||
"togglesidebar" -> :toggle_sidebar
|
|
||||||
"togglepanel" -> :toggle_panel
|
|
||||||
"openpanel" -> :toggle_panel
|
|
||||||
"toggleassistantsidebar" -> :toggle_assistant_sidebar
|
|
||||||
_other -> :unknown
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp safe_existing_atom(action) when is_binary(action) do
|
|
||||||
String.to_existing_atom(action)
|
|
||||||
rescue
|
|
||||||
ArgumentError -> nil
|
|
||||||
end
|
|
||||||
|
|
||||||
defp safe_existing_atom(_), do: nil
|
|
||||||
|
|
||||||
defp assistant_reply(socket) do
|
defp assistant_reply(socket) do
|
||||||
if socket.assigns.offline_mode do
|
if socket.assigns.offline_mode do
|
||||||
ShellData.translate("Automatic AI actions stay gated by airplane mode.", %{}, socket.assigns.page_language)
|
BDS.Gettext.lgettext(
|
||||||
|
socket.assigns.page_language,
|
||||||
|
"ui",
|
||||||
|
"Automatic AI actions stay gated by airplane mode."
|
||||||
|
)
|
||||||
else
|
else
|
||||||
ShellData.translate(
|
BDS.Gettext.lgettext(
|
||||||
"The assistant sidebar chat surface is ready, but model execution is not connected yet.",
|
socket.assigns.page_language,
|
||||||
%{},
|
"ui",
|
||||||
socket.assigns.page_language
|
"The assistant sidebar chat surface is ready, but model execution is not connected yet."
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp translated(text), do: ShellData.translate(text, %{}, Process.get(:bds_ui_locale))
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,26 +3,28 @@ defmodule BDS.Desktop.ShellLive.CliSync do
|
|||||||
|
|
||||||
import Phoenix.Component, only: [assign: 3]
|
import Phoenix.Component, only: [assign: 3]
|
||||||
|
|
||||||
alias BDS.Media.Media
|
alias BDS.{Media, Posts}
|
||||||
|
alias BDS.MapUtils
|
||||||
|
alias BDS.Media.Media, as: MediaRecord
|
||||||
alias BDS.Posts.Post
|
alias BDS.Posts.Post
|
||||||
alias BDS.Repo
|
|
||||||
alias BDS.UI.Workbench
|
alias BDS.UI.Workbench
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Apply a CLI entity change payload to the shell socket. `reload_fun` is
|
Apply a CLI entity change payload to the shell socket. `reload_fun` is
|
||||||
called with `(socket, workbench)` to refresh derived data.
|
called with `(socket, workbench)` to refresh derived data.
|
||||||
"""
|
"""
|
||||||
@spec apply_entity_change(Phoenix.LiveView.Socket.t(), map(),
|
@spec apply_entity_change(Phoenix.LiveView.Socket.t(), map(), (Phoenix.LiveView.Socket.t(),
|
||||||
(Phoenix.LiveView.Socket.t(), map() -> Phoenix.LiveView.Socket.t())) ::
|
map() ->
|
||||||
|
Phoenix.LiveView.Socket.t())) ::
|
||||||
Phoenix.LiveView.Socket.t()
|
Phoenix.LiveView.Socket.t()
|
||||||
def apply_entity_change(socket, payload, reload_fun) do
|
def apply_entity_change(socket, payload, reload_fun) do
|
||||||
entity = Map.get(payload, :entity) || Map.get(payload, "entity") || Map.get(payload, :entity_type) || Map.get(payload, "entity_type")
|
entity = MapUtils.attr(payload, :entity) || MapUtils.attr(payload, :entity_type)
|
||||||
|
|
||||||
entity_id =
|
entity_id =
|
||||||
Map.get(payload, :entity_id) || Map.get(payload, "entity_id") || Map.get(payload, :entityId) ||
|
MapUtils.attr(payload, :entity_id) || Map.get(payload, :entityId) ||
|
||||||
Map.get(payload, "entityId")
|
Map.get(payload, "entityId")
|
||||||
|
|
||||||
action = normalize_action(Map.get(payload, :action) || Map.get(payload, "action"))
|
action = normalize_action(MapUtils.attr(payload, :action))
|
||||||
|
|
||||||
if is_binary(entity) and entity != "" and is_binary(entity_id) and entity_id != "" and
|
if is_binary(entity) and entity != "" and is_binary(entity_id) and entity_id != "" and
|
||||||
action in [:created, :updated, :deleted] do
|
action in [:created, :updated, :deleted] do
|
||||||
@@ -44,14 +46,6 @@ defmodule BDS.Desktop.ShellLive.CliSync do
|
|||||||
|> assign(:workbench, workbench)
|
|> assign(:workbench, workbench)
|
||||||
|> assign(:shell_overlay, nil)
|
|> assign(:shell_overlay, nil)
|
||||||
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id}))
|
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:post, post_id}))
|
||||||
|> assign(:post_editor_drafts, Map.delete(socket.assigns.post_editor_drafts, post_id))
|
|
||||||
|> assign(:post_editor_active_languages, Map.delete(socket.assigns.post_editor_active_languages, post_id))
|
|
||||||
|> assign(:post_editor_tag_queries, Map.delete(socket.assigns.post_editor_tag_queries, post_id))
|
|
||||||
|> assign(:post_editor_category_queries, Map.delete(socket.assigns.post_editor_category_queries, post_id))
|
|
||||||
|> assign(:post_editor_quick_actions_open, Map.delete(socket.assigns.post_editor_quick_actions_open, post_id))
|
|
||||||
|> assign(:post_editor_modes, Map.delete(socket.assigns.post_editor_modes, post_id))
|
|
||||||
|> assign(:post_editor_expanded, Map.delete(socket.assigns.post_editor_expanded, post_id))
|
|
||||||
|> assign(:post_editor_save_states, Map.delete(socket.assigns.post_editor_save_states, post_id))
|
|
||||||
|
|
||||||
{socket, workbench}
|
{socket, workbench}
|
||||||
end
|
end
|
||||||
@@ -64,32 +58,38 @@ defmodule BDS.Desktop.ShellLive.CliSync do
|
|||||||
|> assign(:workbench, workbench)
|
|> assign(:workbench, workbench)
|
||||||
|> assign(:shell_overlay, nil)
|
|> assign(:shell_overlay, nil)
|
||||||
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id}))
|
|> assign(:tab_meta, Map.delete(socket.assigns.tab_meta, {:media, media_id}))
|
||||||
|> assign(:media_editor_drafts, Map.delete(socket.assigns.media_editor_drafts, media_id))
|
|
||||||
|> assign(:media_editor_quick_actions_open, Map.delete(socket.assigns.media_editor_quick_actions_open, media_id))
|
|
||||||
|> assign(:media_editor_post_pickers_open, Map.delete(socket.assigns.media_editor_post_pickers_open, media_id))
|
|
||||||
|> assign(:media_editor_post_picker_queries, Map.delete(socket.assigns.media_editor_post_picker_queries, media_id))
|
|
||||||
|> assign(:media_editor_save_states, Map.delete(socket.assigns.media_editor_save_states, media_id))
|
|
||||||
|> assign(:media_editor_translation_forms, Map.delete(socket.assigns.media_editor_translation_forms, media_id))
|
|
||||||
|
|
||||||
{socket, workbench}
|
{socket, workbench}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_close_deleted_tab(socket, _entity, _entity_id, _action), do: {socket, socket.assigns.workbench}
|
defp maybe_close_deleted_tab(socket, _entity, _entity_id, _action),
|
||||||
|
do: {socket, socket.assigns.workbench}
|
||||||
|
|
||||||
defp maybe_refresh_tab_meta(socket, "post", post_id, action) when action in [:created, :updated] do
|
defp maybe_refresh_tab_meta(socket, "post", post_id, action)
|
||||||
|
when action in [:created, :updated] do
|
||||||
maybe_put_tab_meta(socket, :post, post_id, fn ->
|
maybe_put_tab_meta(socket, :post, post_id, fn ->
|
||||||
case Repo.get(Post, post_id) do
|
case Posts.get_post(post_id) do
|
||||||
%Post{} = post -> %{title: post.title || post.slug || post.id, subtitle: Atom.to_string(post.status || :draft)}
|
%Post{} = post ->
|
||||||
_other -> nil
|
%{title: post.title || post.slug || post.id, subtitle: Atom.to_string(post.status)}
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp maybe_refresh_tab_meta(socket, "media", media_id, action) when action in [:created, :updated] do
|
defp maybe_refresh_tab_meta(socket, "media", media_id, action)
|
||||||
|
when action in [:created, :updated] do
|
||||||
maybe_put_tab_meta(socket, :media, media_id, fn ->
|
maybe_put_tab_meta(socket, :media, media_id, fn ->
|
||||||
case Repo.get(Media, media_id) do
|
case Media.get_media(media_id) do
|
||||||
%Media{} = media -> %{title: media.title || media.filename || media.id, subtitle: media.filename || media.mime_type || "media"}
|
%MediaRecord{} = media ->
|
||||||
_other -> nil
|
%{
|
||||||
|
title: media.title || media.filename || media.id,
|
||||||
|
subtitle: media.filename || media.mime_type || "media"
|
||||||
|
}
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
@@ -102,7 +102,9 @@ defmodule BDS.Desktop.ShellLive.CliSync do
|
|||||||
if tab_present?(socket.assigns.workbench, key) or Map.has_key?(socket.assigns.tab_meta, key) do
|
if tab_present?(socket.assigns.workbench, key) or Map.has_key?(socket.assigns.tab_meta, key) do
|
||||||
case meta_fun.() do
|
case meta_fun.() do
|
||||||
%{} = fresh_meta ->
|
%{} = fresh_meta ->
|
||||||
updated_meta = Map.update(socket.assigns.tab_meta, key, fresh_meta, &Map.merge(&1, fresh_meta))
|
updated_meta =
|
||||||
|
Map.update(socket.assigns.tab_meta, key, fresh_meta, &Map.merge(&1, fresh_meta))
|
||||||
|
|
||||||
assign(socket, :tab_meta, updated_meta)
|
assign(socket, :tab_meta, updated_meta)
|
||||||
|
|
||||||
_other ->
|
_other ->
|
||||||
|
|||||||
@@ -1,268 +0,0 @@
|
|||||||
defmodule BDS.Desktop.ShellLive.CodeEntityEditor do
|
|
||||||
@moduledoc false
|
|
||||||
|
|
||||||
use Phoenix.Component
|
|
||||||
|
|
||||||
alias BDS.Desktop.ShellData
|
|
||||||
alias BDS.{MCP, Repo, Scripts, Scripting, Templates}
|
|
||||||
alias BDS.Scripts.Script
|
|
||||||
alias BDS.Templates.Template
|
|
||||||
|
|
||||||
embed_templates "code_entity_editor_html/*"
|
|
||||||
|
|
||||||
def assign_socket(socket) do
|
|
||||||
socket
|
|
||||||
|> assign(:script_editor, build_script(socket.assigns))
|
|
||||||
|> assign(:template_editor, build_template(socket.assigns))
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_script(socket, params, reload) do
|
|
||||||
%{id: script_id} = socket.assigns.current_tab
|
|
||||||
|
|
||||||
socket
|
|
||||||
|> assign(:script_editor_drafts, Map.put(socket.assigns.script_editor_drafts, script_id, normalize_script_params(params)))
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
end
|
|
||||||
|
|
||||||
def save_script(socket, reload, append_output) do
|
|
||||||
%{id: script_id} = socket.assigns.current_tab
|
|
||||||
|
|
||||||
case Repo.get(Script, script_id) do
|
|
||||||
nil -> reload.(socket, socket.assigns.workbench)
|
|
||||||
%Script{} = script ->
|
|
||||||
draft = current_script_draft(socket.assigns, script)
|
|
||||||
|
|
||||||
case Scripting.validate(draft["content"] || "") do
|
|
||||||
:ok ->
|
|
||||||
case Scripts.update_script(script.id, script_attrs(draft)) do
|
|
||||||
{:ok, _updated} ->
|
|
||||||
socket
|
|
||||||
|> assign(:script_editor_drafts, Map.delete(socket.assigns.script_editor_drafts, script.id))
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
socket
|
|
||||||
|> append_output.(translated("Scripts"), inspect(reason), nil, "error")
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
end
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
socket
|
|
||||||
|> append_output.(translated("Scripts"), inspect(reason), nil, "error")
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def check_script(socket, reload, append_output) do
|
|
||||||
%{id: script_id} = socket.assigns.current_tab
|
|
||||||
|
|
||||||
case Repo.get(Script, script_id) do
|
|
||||||
nil -> reload.(socket, socket.assigns.workbench)
|
|
||||||
%Script{} = script ->
|
|
||||||
case Scripting.validate(current_script_draft(socket.assigns, script)["content"] || "") do
|
|
||||||
:ok -> append_output.(socket, translated("Scripts"), translated("Syntax is valid")) |> reload.(socket.assigns.workbench)
|
|
||||||
{:error, reason} -> append_output.(socket, translated("Scripts"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def run_script(socket, reload, append_output) do
|
|
||||||
%{id: script_id} = socket.assigns.current_tab
|
|
||||||
|
|
||||||
case Repo.get(Script, script_id) do
|
|
||||||
nil -> reload.(socket, socket.assigns.workbench)
|
|
||||||
%Script{} = script ->
|
|
||||||
draft = current_script_draft(socket.assigns, script)
|
|
||||||
|
|
||||||
case Scripting.execute_project_script(script.project_id, draft["content"] || "", draft["entrypoint"] || "main", []) do
|
|
||||||
{:ok, result} ->
|
|
||||||
socket
|
|
||||||
|> append_output.(translated("Scripts"), inspect(result))
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
socket
|
|
||||||
|> append_output.(translated("Scripts"), inspect(reason), nil, "error")
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete_script(socket, reload, append_output) do
|
|
||||||
%{id: script_id} = socket.assigns.current_tab
|
|
||||||
|
|
||||||
case Scripts.delete_script(script_id) do
|
|
||||||
{:ok, _deleted} -> reload.(socket, BDS.UI.Workbench.close_tab(socket.assigns.workbench, :scripts, script_id))
|
|
||||||
{:error, reason} -> append_output.(socket, translated("Scripts"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_template(socket, params, reload) do
|
|
||||||
%{id: template_id} = socket.assigns.current_tab
|
|
||||||
|
|
||||||
socket
|
|
||||||
|> assign(:template_editor_drafts, Map.put(socket.assigns.template_editor_drafts, template_id, normalize_template_params(params)))
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
end
|
|
||||||
|
|
||||||
def save_template(socket, reload, append_output) do
|
|
||||||
%{id: template_id} = socket.assigns.current_tab
|
|
||||||
|
|
||||||
case Repo.get(Template, template_id) do
|
|
||||||
nil -> reload.(socket, socket.assigns.workbench)
|
|
||||||
%Template{} = template ->
|
|
||||||
draft = current_template_draft(socket.assigns, template)
|
|
||||||
|
|
||||||
with {:ok, %{valid: true}} <- MCP.validate_template(draft["content"] || ""),
|
|
||||||
{:ok, _updated} <- Templates.update_template(template.id, template_attrs(draft)) do
|
|
||||||
socket
|
|
||||||
|> assign(:template_editor_drafts, Map.delete(socket.assigns.template_editor_drafts, template.id))
|
|
||||||
|> reload.(socket.assigns.workbench)
|
|
||||||
else
|
|
||||||
{:ok, %{valid: false, errors: errors}} -> append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error") |> reload.(socket.assigns.workbench)
|
|
||||||
{:error, reason} -> append_output.(socket, translated("Templates"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_template(socket, reload, append_output) do
|
|
||||||
%{id: template_id} = socket.assigns.current_tab
|
|
||||||
|
|
||||||
case Repo.get(Template, template_id) do
|
|
||||||
nil -> reload.(socket, socket.assigns.workbench)
|
|
||||||
%Template{} = template ->
|
|
||||||
case MCP.validate_template(current_template_draft(socket.assigns, template)["content"] || "") do
|
|
||||||
{:ok, %{valid: true}} -> append_output.(socket, translated("Templates"), translated("Template syntax is valid")) |> reload.(socket.assigns.workbench)
|
|
||||||
{:ok, %{valid: false, errors: errors}} -> append_output.(socket, translated("Templates"), Enum.join(errors, "; "), nil, "error") |> reload.(socket.assigns.workbench)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def delete_template(socket, reload, append_output) do
|
|
||||||
%{id: template_id} = socket.assigns.current_tab
|
|
||||||
|
|
||||||
case Templates.delete_template(template_id, force: true) do
|
|
||||||
{:ok, _deleted} -> reload.(socket, BDS.UI.Workbench.close_tab(socket.assigns.workbench, :templates, template_id))
|
|
||||||
{:error, reason} -> append_output.(socket, translated("Templates"), inspect(reason), nil, "error") |> reload.(socket.assigns.workbench)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_script(%{current_tab: %{type: :scripts, id: script_id}} = assigns) do
|
|
||||||
case Repo.get(Script, script_id) do
|
|
||||||
nil -> nil
|
|
||||||
%Script{} = script ->
|
|
||||||
draft = current_script_draft(assigns, script)
|
|
||||||
%{
|
|
||||||
id: script.id,
|
|
||||||
title: draft["title"],
|
|
||||||
slug: draft["slug"],
|
|
||||||
kind: draft["kind"],
|
|
||||||
entrypoint: draft["entrypoint"],
|
|
||||||
enabled: draft["enabled"],
|
|
||||||
content: draft["content"],
|
|
||||||
entrypoints: discover_entrypoints(draft["content"]),
|
|
||||||
created_at: script.created_at,
|
|
||||||
updated_at: script.updated_at
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_script(_assigns), do: nil
|
|
||||||
|
|
||||||
def build_template(%{current_tab: %{type: :templates, id: template_id}} = assigns) do
|
|
||||||
case Repo.get(Template, template_id) do
|
|
||||||
nil -> nil
|
|
||||||
%Template{} = template ->
|
|
||||||
draft = current_template_draft(assigns, template)
|
|
||||||
%{
|
|
||||||
id: template.id,
|
|
||||||
title: draft["title"],
|
|
||||||
slug: draft["slug"],
|
|
||||||
kind: draft["kind"],
|
|
||||||
enabled: draft["enabled"],
|
|
||||||
content: draft["content"],
|
|
||||||
created_at: template.created_at,
|
|
||||||
updated_at: template.updated_at
|
|
||||||
}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_template(_assigns), do: nil
|
|
||||||
|
|
||||||
def translated(text, bindings \\ %{}), do: ShellData.translate(text, bindings, Process.get(:bds_ui_locale))
|
|
||||||
|
|
||||||
def format_timestamp(nil), do: ""
|
|
||||||
def format_timestamp(timestamp), do: BDS.Persistence.timestamp_to_iso8601(timestamp)
|
|
||||||
|
|
||||||
defp normalize_script_params(params) do
|
|
||||||
%{
|
|
||||||
"title" => Map.get(params, "title", ""),
|
|
||||||
"slug" => Map.get(params, "slug", ""),
|
|
||||||
"kind" => Map.get(params, "kind", "utility"),
|
|
||||||
"entrypoint" => Map.get(params, "entrypoint", "main"),
|
|
||||||
"enabled" => Map.get(params, "enabled") in [true, "true", "on", "1", 1],
|
|
||||||
"content" => Map.get(params, "content", "")
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp normalize_template_params(params) do
|
|
||||||
%{
|
|
||||||
"title" => Map.get(params, "title", ""),
|
|
||||||
"slug" => Map.get(params, "slug", ""),
|
|
||||||
"kind" => Map.get(params, "kind", "post"),
|
|
||||||
"enabled" => Map.get(params, "enabled") in [true, "true", "on", "1", 1],
|
|
||||||
"content" => Map.get(params, "content", "")
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp current_script_draft(assigns, %Script{} = script) do
|
|
||||||
Map.get(assigns.script_editor_drafts, script.id, %{
|
|
||||||
"title" => script.title || "",
|
|
||||||
"slug" => script.slug || "",
|
|
||||||
"kind" => to_string(script.kind || :utility),
|
|
||||||
"entrypoint" => script.entrypoint || "main",
|
|
||||||
"enabled" => script.enabled != false,
|
|
||||||
"content" => script.content || ""
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
defp current_template_draft(assigns, %Template{} = template) do
|
|
||||||
Map.get(assigns.template_editor_drafts, template.id, %{
|
|
||||||
"title" => template.title || "",
|
|
||||||
"slug" => template.slug || "",
|
|
||||||
"kind" => to_string(template.kind || :post),
|
|
||||||
"enabled" => template.enabled != false,
|
|
||||||
"content" => template.content || ""
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
defp script_attrs(draft) do
|
|
||||||
%{
|
|
||||||
title: draft["title"],
|
|
||||||
slug: draft["slug"],
|
|
||||||
kind: String.to_existing_atom(draft["kind"]),
|
|
||||||
entrypoint: draft["entrypoint"],
|
|
||||||
enabled: draft["enabled"],
|
|
||||||
content: draft["content"]
|
|
||||||
}
|
|
||||||
rescue
|
|
||||||
_error -> %{title: draft["title"], slug: draft["slug"], kind: :utility, entrypoint: draft["entrypoint"], enabled: draft["enabled"], content: draft["content"]}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp template_attrs(draft) do
|
|
||||||
%{title: draft["title"], slug: draft["slug"], kind: normalize_template_kind(draft["kind"]), enabled: draft["enabled"], content: draft["content"]}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp normalize_template_kind("post"), do: :post
|
|
||||||
defp normalize_template_kind("list"), do: :list
|
|
||||||
defp normalize_template_kind("not-found"), do: :"not-found"
|
|
||||||
defp normalize_template_kind("partial"), do: :partial
|
|
||||||
defp normalize_template_kind(_kind), do: :post
|
|
||||||
|
|
||||||
defp discover_entrypoints(content) do
|
|
||||||
["main" | Regex.scan(~r/function\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/, content || "", capture: :all_but_first)
|
|
||||||
|> List.flatten()
|
|
||||||
|> Enum.reject(&(&1 == "main"))]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<div class="scripts-view-shell" data-testid="script-editor">
|
|
||||||
<div class="editor-header scripts-header">
|
|
||||||
<div class="editor-tabs"><div class="editor-tab active"><span class="editor-tab-title"><%= @script_editor.title %></span></div></div>
|
|
||||||
<div class="editor-actions">
|
|
||||||
<button class="scripts-save-button" type="button" phx-click="save_script_editor"><%= translated("Save") %></button>
|
|
||||||
<button class="scripts-run-button" type="button" phx-click="run_script_editor"><%= translated("Run") %></button>
|
|
||||||
<button class="scripts-check-button" type="button" phx-click="check_script_editor"><%= translated("Check Syntax") %></button>
|
|
||||||
<button class="secondary danger" type="button" phx-click="delete_script_editor"><%= translated("Delete") %></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form class="editor-content scripts-view" phx-change="change_script_editor">
|
|
||||||
<div class="editor-header-row scripts-meta-row">
|
|
||||||
<div class="editor-meta">
|
|
||||||
<div class="editor-field-row">
|
|
||||||
<div class="editor-field"><label><%= translated("Title") %></label><input type="text" name="script_editor[title]" value={@script_editor.title} /></div>
|
|
||||||
<div class="editor-field"><label><%= translated("Slug") %></label><input type="text" name="script_editor[slug]" value={@script_editor.slug} /></div>
|
|
||||||
</div>
|
|
||||||
<div class="editor-field-row">
|
|
||||||
<div class="editor-field"><label><%= translated("Kind") %></label><select name="script_editor[kind]"><option value="utility" selected={@script_editor.kind == "utility"}>utility</option><option value="macro" selected={@script_editor.kind == "macro"}>macro</option><option value="transform" selected={@script_editor.kind == "transform"}>transform</option></select></div>
|
|
||||||
<div class="editor-field"><label><%= translated("Entrypoint") %></label><select name="script_editor[entrypoint]"><%= for entrypoint <- @script_editor.entrypoints do %><option value={entrypoint} selected={entrypoint == @script_editor.entrypoint}><%= entrypoint %></option><% end %></select></div>
|
|
||||||
<div class="editor-field scripts-enabled-field"><label><input type="checkbox" name="script_editor[enabled]" checked={@script_editor.enabled} /> <%= translated("Enabled") %></label></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="editor-body scripts-editor">
|
|
||||||
<div class="editor-toolbar scripts-toolbar"><div class="editor-toolbar-left"><label><%= translated("Content") %></label></div></div>
|
|
||||||
<div
|
|
||||||
id={"script-editor-monaco-shell-#{@script_editor.id}"}
|
|
||||||
class="scripts-monaco monaco-editor-shell"
|
|
||||||
phx-hook="MonacoEditor"
|
|
||||||
data-monaco-editor-id={@script_editor.id}
|
|
||||||
data-monaco-input-id={"script-editor-content-#{@script_editor.id}"}
|
|
||||||
data-monaco-language="lua"
|
|
||||||
data-monaco-word-wrap="on"
|
|
||||||
>
|
|
||||||
<div id={"script-editor-monaco-#{@script_editor.id}"} class="monaco-editor-instance" phx-update="ignore"></div>
|
|
||||||
<textarea id={"script-editor-content-#{@script_editor.id}"} class="monaco-editor-input code-editor-textarea" name="script_editor[content]" spellcheck="false"><%= @script_editor.content %></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="editor-footer"><span class="text-muted text-small"><%= translated("Created") %>: <%= format_timestamp(@script_editor.created_at) %></span><span class="text-muted text-small"><%= translated("Updated") %>: <%= format_timestamp(@script_editor.updated_at) %></span></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
<div class="templates-view-shell" data-testid="template-editor">
|
|
||||||
<div class="editor-header templates-header">
|
|
||||||
<div class="editor-tabs"><div class="editor-tab active"><span class="editor-tab-title"><%= @template_editor.title %></span></div></div>
|
|
||||||
<div class="editor-actions">
|
|
||||||
<button class="templates-save-button" type="button" phx-click="save_template_editor"><%= translated("Save") %></button>
|
|
||||||
<button class="templates-validate-button" type="button" phx-click="validate_template_editor"><%= translated("Validate") %></button>
|
|
||||||
<button class="secondary danger" type="button" phx-click="delete_template_editor"><%= translated("Delete") %></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form class="editor-content templates-view" phx-change="change_template_editor">
|
|
||||||
<div class="editor-header-row templates-meta-row">
|
|
||||||
<div class="editor-meta">
|
|
||||||
<div class="editor-field-row">
|
|
||||||
<div class="editor-field"><label><%= translated("Title") %></label><input type="text" name="template_editor[title]" value={@template_editor.title} /></div>
|
|
||||||
<div class="editor-field"><label><%= translated("Slug") %></label><input type="text" name="template_editor[slug]" value={@template_editor.slug} /></div>
|
|
||||||
</div>
|
|
||||||
<div class="editor-field-row">
|
|
||||||
<div class="editor-field"><label><%= translated("Kind") %></label><select name="template_editor[kind]"><option value="post" selected={@template_editor.kind == :post or @template_editor.kind == "post"}>post</option><option value="list" selected={@template_editor.kind == :list or @template_editor.kind == "list"}>list</option><option value="not-found" selected={@template_editor.kind == :"not-found" or @template_editor.kind == "not-found"}>not-found</option><option value="partial" selected={@template_editor.kind == :partial or @template_editor.kind == "partial"}>partial</option></select></div>
|
|
||||||
<div class="editor-field templates-enabled-field"><label><input type="checkbox" name="template_editor[enabled]" checked={@template_editor.enabled} /> <%= translated("Enabled") %></label></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="editor-body templates-editor">
|
|
||||||
<div class="editor-toolbar templates-toolbar"><div class="editor-toolbar-left"><label><%= translated("Content") %></label></div></div>
|
|
||||||
<div
|
|
||||||
id={"template-editor-monaco-shell-#{@template_editor.id}"}
|
|
||||||
class="templates-monaco monaco-editor-shell"
|
|
||||||
phx-hook="MonacoEditor"
|
|
||||||
data-monaco-editor-id={@template_editor.id}
|
|
||||||
data-monaco-input-id={"template-editor-content-#{@template_editor.id}"}
|
|
||||||
data-monaco-language="liquid"
|
|
||||||
data-monaco-word-wrap="on"
|
|
||||||
>
|
|
||||||
<div id={"template-editor-monaco-#{@template_editor.id}"} class="monaco-editor-instance" phx-update="ignore"></div>
|
|
||||||
<textarea id={"template-editor-content-#{@template_editor.id}"} class="monaco-editor-input code-editor-textarea" name="template_editor[content]" spellcheck="false"><%= @template_editor.content %></textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="editor-footer"><span class="text-muted text-small"><%= translated("Created") %>: <%= format_timestamp(@template_editor.created_at) %></span><span class="text-muted text-small"><%= translated("Updated") %>: <%= format_timestamp(@template_editor.updated_at) %></span></div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
File diff suppressed because it is too large
Load Diff
312
lib/bds/desktop/shell_live/import_editor/analysis_state.ex
Normal file
312
lib/bds/desktop/shell_live/import_editor/analysis_state.ex
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.ImportEditor.AnalysisState do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias BDS.{ImportAnalysis, ImportDefinitions, Metadata}
|
||||||
|
alias BDS.Desktop.{FilePicker, FolderPicker}
|
||||||
|
use Gettext, backend: BDS.Gettext
|
||||||
|
|
||||||
|
@spec change_definition(term(), term(), term()) :: term()
|
||||||
|
def change_definition(socket, params, reload) do
|
||||||
|
with %{id: definition_id} <- socket.assigns.current_tab,
|
||||||
|
{:ok, _definition} <-
|
||||||
|
ImportDefinitions.update_definition(definition_id, %{name: Map.get(params, "name", "")}) do
|
||||||
|
reload.(socket, socket.assigns.workbench)
|
||||||
|
else
|
||||||
|
_other -> reload.(socket, socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec select_uploads_folder(term(), term(), term()) :: term()
|
||||||
|
def select_uploads_folder(socket, reload, append_output) do
|
||||||
|
with %{id: definition_id} <- socket.assigns.current_tab do
|
||||||
|
case FolderPicker.choose_directory(dgettext("ui", "Uploads Folder")) do
|
||||||
|
{:ok, uploads_folder_path} ->
|
||||||
|
{:ok, _definition} =
|
||||||
|
ImportDefinitions.update_definition(definition_id, %{
|
||||||
|
uploads_folder_path: uploads_folder_path
|
||||||
|
})
|
||||||
|
|
||||||
|
reload.(socket, socket.assigns.workbench)
|
||||||
|
|
||||||
|
:cancel ->
|
||||||
|
reload.(socket, socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, %{message: message}} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
_other -> reload.(socket, socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec select_and_analyze(term(), term(), term()) :: term()
|
||||||
|
def select_and_analyze(socket, reload, append_output) do
|
||||||
|
with %{id: definition_id} <- socket.assigns.current_tab,
|
||||||
|
%{} = definition <- ImportDefinitions.get_definition(definition_id) do
|
||||||
|
case FilePicker.choose_file(dgettext("ui", "WXR File")) do
|
||||||
|
{:ok, wxr_file_path} ->
|
||||||
|
project_id = socket.assigns.projects.active_project_id
|
||||||
|
|
||||||
|
{:ok, _definition} =
|
||||||
|
ImportDefinitions.update_definition(definition_id, %{
|
||||||
|
wxr_file_path: wxr_file_path,
|
||||||
|
last_analysis_result: nil
|
||||||
|
})
|
||||||
|
|
||||||
|
live_view_pid = self()
|
||||||
|
|
||||||
|
task =
|
||||||
|
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
||||||
|
ImportAnalysis.analyze_wxr(
|
||||||
|
project_id,
|
||||||
|
wxr_file_path,
|
||||||
|
definition.uploads_folder_path,
|
||||||
|
on_progress: fn step, detail ->
|
||||||
|
send(
|
||||||
|
live_view_pid,
|
||||||
|
{:import_analysis_progress, definition_id, translate_phase(step), detail}
|
||||||
|
)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok = allow_repo_sandbox(task.pid)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> Phoenix.Component.assign(
|
||||||
|
:import_editor_analysis_states,
|
||||||
|
Map.put(socket.assigns.import_editor_analysis_states, definition_id, %{
|
||||||
|
loading: true,
|
||||||
|
step: dgettext("ui", "Analyzing WXR file..."),
|
||||||
|
detail: Path.basename(wxr_file_path),
|
||||||
|
file_path: wxr_file_path,
|
||||||
|
ref: task.ref
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|> Phoenix.Component.assign(
|
||||||
|
:import_editor_analysis_task_refs,
|
||||||
|
Map.put(socket.assigns.import_editor_analysis_task_refs, task.ref, definition_id)
|
||||||
|
)
|
||||||
|
|> Phoenix.Component.assign(
|
||||||
|
:import_editor_execution_states,
|
||||||
|
Map.delete(socket.assigns.import_editor_execution_states, definition_id)
|
||||||
|
)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
:cancel ->
|
||||||
|
reload.(socket, socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, %{message: message}} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
_other -> reload.(socket, socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec note_analysis_progress(term(), term(), term(), term(), term()) :: term()
|
||||||
|
def note_analysis_progress(socket, definition_id, step, detail, reload) do
|
||||||
|
socket
|
||||||
|
|> Phoenix.Component.assign(
|
||||||
|
:import_editor_analysis_states,
|
||||||
|
Map.update(
|
||||||
|
socket.assigns.import_editor_analysis_states,
|
||||||
|
definition_id,
|
||||||
|
default_analysis_state(),
|
||||||
|
fn state ->
|
||||||
|
state
|
||||||
|
|> Map.put(:loading, true)
|
||||||
|
|> Map.put(:step, step)
|
||||||
|
|> Map.put(:detail, detail)
|
||||||
|
end
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec finish_analysis(term(), term(), term(), term(), term()) :: term()
|
||||||
|
def finish_analysis(socket, ref, result, reload, append_output) do
|
||||||
|
case Map.get(socket.assigns.import_editor_analysis_task_refs, ref) do
|
||||||
|
nil ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
definition_id ->
|
||||||
|
analysis_state =
|
||||||
|
Map.get(
|
||||||
|
socket.assigns.import_editor_analysis_states,
|
||||||
|
definition_id,
|
||||||
|
default_analysis_state()
|
||||||
|
)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> Phoenix.Component.assign(
|
||||||
|
:import_editor_analysis_task_refs,
|
||||||
|
Map.delete(socket.assigns.import_editor_analysis_task_refs, ref)
|
||||||
|
)
|
||||||
|
|> Phoenix.Component.assign(
|
||||||
|
:import_editor_analysis_states,
|
||||||
|
Map.delete(socket.assigns.import_editor_analysis_states, definition_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:ok, report} ->
|
||||||
|
attrs =
|
||||||
|
%{
|
||||||
|
wxr_file_path: analysis_state.file_path,
|
||||||
|
last_analysis_result: report
|
||||||
|
}
|
||||||
|
|> maybe_put(:name, suggested_definition_name(report))
|
||||||
|
|
||||||
|
case ImportDefinitions.update_definition(definition_id, attrs) do
|
||||||
|
{:ok, _definition} ->
|
||||||
|
reload.(socket, socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(dgettext("ui", "Import"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, %{message: message}} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
socket
|
||||||
|
|> append_output.(dgettext("ui", "Import"), inspect(reason), nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec handle_analysis_task_down(term(), term(), term(), term(), term()) :: term()
|
||||||
|
def handle_analysis_task_down(socket, ref, message, reload, append_output) do
|
||||||
|
case Map.get(socket.assigns.import_editor_analysis_task_refs, ref) do
|
||||||
|
nil ->
|
||||||
|
socket
|
||||||
|
|
||||||
|
definition_id ->
|
||||||
|
socket
|
||||||
|
|> Phoenix.Component.assign(
|
||||||
|
:import_editor_analysis_task_refs,
|
||||||
|
Map.delete(socket.assigns.import_editor_analysis_task_refs, ref)
|
||||||
|
)
|
||||||
|
|> Phoenix.Component.assign(
|
||||||
|
:import_editor_analysis_states,
|
||||||
|
Map.delete(socket.assigns.import_editor_analysis_states, definition_id)
|
||||||
|
)
|
||||||
|
|> append_output.(dgettext("ui", "Import"), message, nil, "error")
|
||||||
|
|> reload.(socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec importable_counts(term()) :: term()
|
||||||
|
def importable_counts(nil), do: %{total: 0, tags: 0, posts: 0, media: 0, pages: 0}
|
||||||
|
|
||||||
|
def importable_counts(report) do
|
||||||
|
tag_count =
|
||||||
|
(Map.get(report.items, :categories, []) ++ Map.get(report.items, :tags, []))
|
||||||
|
|> Enum.count(&(not &1.exists_in_project and not present?(&1.mapped_to)))
|
||||||
|
|
||||||
|
posts = importable_entity_count(Map.get(report.items, :posts, []))
|
||||||
|
pages = importable_entity_count(Map.get(report.items, :pages, []))
|
||||||
|
media = importable_entity_count(Map.get(report.items, :media, []))
|
||||||
|
|
||||||
|
%{
|
||||||
|
total: tag_count + posts + pages + media,
|
||||||
|
tags: tag_count,
|
||||||
|
posts: posts,
|
||||||
|
media: media,
|
||||||
|
pages: pages
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec importable_entity_count(term()) :: term()
|
||||||
|
def importable_entity_count(items) do
|
||||||
|
Enum.count(items || [], fn item ->
|
||||||
|
item.status == "new" or
|
||||||
|
(item.status == "conflict" and conflict_importable?(Map.get(item, :resolution, "ignore")))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp conflict_importable?(resolution), do: resolution in ["overwrite", "merge", "import"]
|
||||||
|
|
||||||
|
@spec detail_items(term(), term()) :: term()
|
||||||
|
def detail_items(nil, _bucket), do: []
|
||||||
|
|
||||||
|
def detail_items(report, bucket) do
|
||||||
|
get_in(report, [:details, bucket]) || get_in(report, [:items, bucket]) || []
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec default_analysis_state() :: term()
|
||||||
|
def default_analysis_state do
|
||||||
|
%{loading: false, step: nil, detail: nil, file_path: nil, ref: nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec default_sections() :: term()
|
||||||
|
def default_sections do
|
||||||
|
%{
|
||||||
|
post_conflicts: true,
|
||||||
|
page_conflicts: true,
|
||||||
|
posts: false,
|
||||||
|
other: false,
|
||||||
|
pages: false,
|
||||||
|
media: false,
|
||||||
|
taxonomy: true,
|
||||||
|
macros: true
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec default_author(term()) :: term()
|
||||||
|
def default_author(project_id) do
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project_id)
|
||||||
|
metadata.default_author
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec suggested_definition_name(term()) :: term()
|
||||||
|
def suggested_definition_name(report) do
|
||||||
|
get_in(report, [:site_info, :url]) || get_in(report, [:site_info, :title])
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec maybe_put(term(), term(), term()) :: term()
|
||||||
|
def maybe_put(map, _key, nil), do: map
|
||||||
|
def maybe_put(map, key, value), do: Map.put(map, key, value)
|
||||||
|
|
||||||
|
@spec allow_repo_sandbox(term()) :: term()
|
||||||
|
def 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
|
||||||
|
|
||||||
|
def translate_phase(step) when is_binary(step) do
|
||||||
|
case step do
|
||||||
|
"parsing" -> dgettext("ui", "Parsing WXR file...")
|
||||||
|
"scanning" -> dgettext("ui", "Scanning entries...")
|
||||||
|
"taxonomies" -> dgettext("ui", "Analyzing taxonomies...")
|
||||||
|
"posts" -> dgettext("ui", "Analyzing posts...")
|
||||||
|
"media" -> dgettext("ui", "Analyzing media...")
|
||||||
|
"complete" -> dgettext("ui", "Analysis complete")
|
||||||
|
other -> other
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec translate_phase(term()) :: term()
|
||||||
|
def translate_phase(other), do: other
|
||||||
|
|
||||||
|
defp present?(value), do: value not in [nil, ""]
|
||||||
|
end
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
defmodule BDS.Desktop.ShellLive.ImportEditor.ConflictResolution do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias BDS.ImportDefinitions
|
||||||
|
|
||||||
|
@spec change_conflict_resolution(term(), term(), term()) :: term()
|
||||||
|
def change_conflict_resolution(
|
||||||
|
socket,
|
||||||
|
%{"item_type" => item_type, "item_name" => item_name, "resolution" => resolution},
|
||||||
|
reload
|
||||||
|
) do
|
||||||
|
with %{id: definition_id} <- socket.assigns.current_tab,
|
||||||
|
%{} = definition <- ImportDefinitions.get_definition(definition_id),
|
||||||
|
%{} = report <- ImportDefinitions.decode_analysis_result(definition),
|
||||||
|
updated_report <- update_conflict_resolution(report, item_type, item_name, resolution),
|
||||||
|
{:ok, _definition} <-
|
||||||
|
ImportDefinitions.update_definition(definition_id, %{
|
||||||
|
last_analysis_result: updated_report
|
||||||
|
}) do
|
||||||
|
reload.(socket, socket.assigns.workbench)
|
||||||
|
else
|
||||||
|
_other -> reload.(socket, socket.assigns.workbench)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec update_conflict_resolution(term(), term(), term(), term()) :: term()
|
||||||
|
def update_conflict_resolution(report, item_type, item_name, resolution) do
|
||||||
|
report
|
||||||
|
|> update_in([:conflicts], fn conflicts ->
|
||||||
|
Enum.map(conflicts || [], fn conflict ->
|
||||||
|
if conflict.item_type == item_type and conflict.item_name == item_name do
|
||||||
|
%{conflict | resolution: resolution}
|
||||||
|
else
|
||||||
|
conflict
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|> update_in([:items], &update_conflict_bucket(&1, item_type, item_name, resolution))
|
||||||
|
|> update_in([:details], &update_conflict_bucket(&1, item_type, item_name, resolution))
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec update_conflict_bucket(term(), term(), term(), term()) :: term()
|
||||||
|
def update_conflict_bucket(nil, _item_type, _item_name, _resolution), do: nil
|
||||||
|
|
||||||
|
def update_conflict_bucket(buckets, item_type, item_name, resolution) do
|
||||||
|
bucket_key =
|
||||||
|
if(item_type == "page",
|
||||||
|
do: :pages,
|
||||||
|
else: if(item_type == "media", do: :media, else: :posts)
|
||||||
|
)
|
||||||
|
|
||||||
|
update_in(buckets, [bucket_key], fn items ->
|
||||||
|
Enum.map(items || [], fn item ->
|
||||||
|
identity = Map.get(item, :slug) || Map.get(item, :filename)
|
||||||
|
|
||||||
|
if identity == item_name do
|
||||||
|
Map.put(item, :resolution, resolution)
|
||||||
|
else
|
||||||
|
item
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user