Compare commits
411
Commits
8322d3cb3a
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
655069a28a | ||
|
|
593f1646a1 | ||
|
|
c77450d497 | ||
|
|
23f05b72f7 | ||
|
|
dd53ca3fbc | ||
|
|
6c9dd9605b | ||
|
|
46c239df56 | ||
|
|
2fd132e827 | ||
|
|
0f3f1efa08 | ||
|
|
e4fa61ae07 | ||
|
|
8fe18d108f | ||
|
|
b517672663 | ||
|
|
b016f6d812 | ||
|
|
505527ae4f | ||
|
|
b6b1d16e54 | ||
|
|
46fd6c1b85 | ||
|
|
5433cb59ac | ||
|
|
e027364c0e | ||
|
|
3675a26407 | ||
|
|
9a1f301527 | ||
|
|
59333ac920 | ||
|
|
381deb417d | ||
|
|
5801e49dc1 | ||
|
|
a8ca3b643b | ||
|
|
39bb232b57 | ||
|
|
824313f106 | ||
|
|
01b90e00a0 | ||
|
|
c77686250e | ||
|
|
48e0903542 | ||
|
|
df2bdc2041 | ||
|
|
a3ca44640c | ||
|
|
3a8fafd2fd | ||
|
|
16f45d77b2 | ||
|
|
700ed79c43 | ||
|
|
6ec61610f1 | ||
|
|
89859684eb | ||
|
|
dc35518bad | ||
|
|
4a03fc0ba2 | ||
|
|
10fa174388 | ||
|
|
8ee51c6626 | ||
|
|
d31ceb72cb | ||
|
|
c8d2c73a72 | ||
|
|
a772e3abea | ||
|
|
c735c1afa7 | ||
|
|
d85654d4b2 | ||
|
|
ae2e534fdb | ||
|
|
ae1c8703e7 | ||
|
|
d944c07838 | ||
|
|
bb82f1d4a1 | ||
|
|
7dfcd78abd | ||
|
|
8e60fe31a5 | ||
|
|
28398b17f4 | ||
|
|
cf1d4bd46f | ||
|
|
7fa6e6232c | ||
|
|
fcc574428c | ||
|
|
1917043c10 | ||
|
|
4b52ac6156 | ||
|
|
1487c0337e | ||
|
|
43f04dcd86 | ||
|
|
c07ba9a4d8 | ||
|
|
ef6c970654 | ||
|
|
e7d5cba01b | ||
|
|
82c69a2c35 | ||
|
|
b11be9b521 | ||
|
|
1b0bae37bf | ||
|
|
c2b2b1ff11 | ||
|
|
5f77641f3f | ||
|
|
ba9634c478 | ||
|
|
49675a49d2 | ||
|
|
d1acfd4d71 | ||
|
|
68de265137 | ||
|
|
57bdcef990 | ||
|
|
52809b39b1 | ||
|
|
e3e94ac2d9 | ||
|
|
d78cb5c07b | ||
|
|
211271efca | ||
|
|
c2cecd9383 | ||
|
|
d0e1ac5559 | ||
|
|
cea47f10e2 | ||
|
|
e1abe457e8 | ||
|
|
562eed2b98 | ||
|
|
7c38061c1c | ||
|
|
67a1f8a5f0 | ||
|
|
d3c21247e0 | ||
|
|
cd4a01a2c8 | ||
|
|
b93b650689 | ||
|
|
e284f030ae | ||
|
|
4c9c432d33 | ||
|
|
64ca5d4637 | ||
|
|
a89175e230 | ||
|
|
26f06f3298 | ||
|
|
6a7a8c4288 | ||
|
|
62f87b8ab4 | ||
|
|
ff777e590f | ||
|
|
7f1dbc390c | ||
|
|
59c236d871 | ||
|
|
c3a8a8a755 | ||
|
|
2e61059568 | ||
|
|
936a20a03e | ||
|
|
0da149e105 | ||
|
|
dc6fc4c631 | ||
|
|
d3e2397217 | ||
|
|
3b14c1ecf3 | ||
|
|
bb58f98afd | ||
|
|
66c24b565f | ||
|
|
ccfaa4afe6 | ||
|
|
f46239188a | ||
|
|
4a8a420c62 | ||
|
|
b3d69f0291 | ||
|
|
f8f6a242d6 | ||
|
|
341c1bf8ee | ||
|
|
4f6f7a6e1e | ||
|
|
72f6cc7d97 | ||
|
|
f52e33760e | ||
|
|
e87c35e256 | ||
|
|
2d98780007 | ||
|
|
26c481ea1f | ||
|
|
65d89a198e | ||
|
|
8f073bbebd | ||
|
|
4dd4781c5a | ||
|
|
a00e4b85ac | ||
|
|
caaec98225 | ||
|
|
4b1557cf6a | ||
|
|
985d8b53c2 | ||
|
|
941db4c6f4 | ||
|
|
a73af6b44d | ||
|
|
e2054c9c12 | ||
|
|
28e08451e4 | ||
|
|
abbcef594a | ||
|
|
8224b3d59f | ||
|
|
f7e1662bca | ||
|
|
ae66775cb7 | ||
|
|
741979fc39 | ||
|
|
4859c9708a | ||
|
|
cd72998a13 | ||
|
|
f088cfb77b | ||
|
|
bad656924b | ||
|
|
8ee2b9a7f7 | ||
|
|
8d245b3492 | ||
|
|
66938c23f2 | ||
|
|
2e633922f9 | ||
|
|
e3a1010ae9 | ||
|
|
eac6d543d2 | ||
|
|
8546080a3d | ||
|
|
d8b24c9b72 | ||
|
|
63e35d19e3 | ||
|
|
9325de2db4 | ||
|
|
a5391e8e25 | ||
|
|
284637970f | ||
|
|
21b11ef87e | ||
|
|
e6a2055e18 | ||
|
|
bf9340352c | ||
|
|
db98944d10 | ||
|
|
5e99cb7a09 | ||
|
|
040b5db37b | ||
|
|
a33131ddea | ||
|
|
ef6f8a54b2 | ||
|
|
70d2342274 | ||
|
|
c1b7ceae6c | ||
|
|
1d17b6e884 | ||
|
|
360a8d971a | ||
|
|
c30757b3b7 | ||
|
|
f6e1b679f0 | ||
|
|
63d6c9f215 | ||
|
|
2ba8be2fc6 | ||
|
|
4731bc0cd2 | ||
|
|
8bc371eb3f | ||
|
|
b65c2be29b | ||
|
|
ee4d0dd33f | ||
|
|
8c71ece887 | ||
|
|
b1438d5222 | ||
|
|
87f2f22241 | ||
|
|
60acda3fee | ||
|
|
ab6a03dc54 | ||
|
|
0afb017e43 | ||
|
|
cf553e2f78 | ||
|
|
cb658aba1a | ||
|
|
544ff65e3b | ||
|
|
08eb9e4ea1 | ||
|
|
723a7ec1f7 | ||
|
|
dfb2f8870b | ||
|
|
f0919f24a5 | ||
|
|
72f2c829ca | ||
|
|
7c7f629dd2 | ||
|
|
dd760d0f2b | ||
|
|
fb794ae833 | ||
|
|
df0ae6a41b | ||
|
|
e515cfacc6 | ||
|
|
7e9cc72e1f | ||
|
|
257a06e5d1 | ||
|
|
1b37f1fcec | ||
|
|
56caa653bb | ||
|
|
925fe97007 | ||
|
|
d688c61b0e | ||
|
|
8db7bcf357 | ||
|
|
2bed225133 | ||
|
|
7045b10738 | ||
|
|
ebf6136d2f | ||
|
|
ae6659bcf3 | ||
|
|
8bfc509472 | ||
|
|
e89a061d8f | ||
|
|
d606d9b26b | ||
|
|
a9740207cc | ||
|
|
535ab81082 | ||
|
|
0ce90e96e5 | ||
|
|
8cb6d238b9 | ||
|
|
cf8b0af15f | ||
|
|
9d5764b251 | ||
|
|
3a77761f96 | ||
|
|
aff4b63188 | ||
|
|
91b0ffe4c5 | ||
|
|
84b91750fb | ||
|
|
d03d033548 | ||
|
|
74ceaeb971 | ||
|
|
61ff2a77c0 | ||
|
|
744f7543d7 | ||
|
|
a1004d72bf | ||
|
|
489d787306 | ||
|
|
babae1838d | ||
|
|
5b619f492a | ||
|
|
b3434b3054 | ||
|
|
5b21dcb17d | ||
|
|
1f645f6e5e | ||
|
|
99d36e6e2f | ||
|
|
d7e30b94cb | ||
|
|
f1265ee326 | ||
|
|
c5e09e7316 | ||
|
|
1ae6152da7 | ||
|
|
0305d80051 | ||
|
|
a021fc45cd | ||
|
|
fceb995c7c | ||
|
|
e58d68e73e | ||
|
|
0f30221907 | ||
|
|
d423b6db98 | ||
|
|
3adb4407a0 | ||
|
|
05923f255b | ||
|
|
ff89d78ab4 | ||
|
|
e2c92cb90d | ||
|
|
82ce445c44 | ||
|
|
f99e139fa5 | ||
|
|
1914b05f39 | ||
|
|
b09b14cc03 | ||
|
|
721b1ae626 | ||
|
|
f7a4a9512c | ||
|
|
141c2bfc89 | ||
|
|
a5ac74db91 | ||
|
|
beca4d992f | ||
|
|
9e6d93a4b3 | ||
|
|
e29dfb490a | ||
|
|
f2b340ba86 | ||
|
|
d18e0ef7f2 | ||
|
|
2d796cee83 | ||
|
|
b052d59376 | ||
|
|
4a089b0856 | ||
|
|
2632649cdc | ||
|
|
782511d523 | ||
|
|
1cb59d7a78 | ||
|
|
9844f3555a | ||
|
|
99dc1c2216 | ||
|
|
71fb99af16 | ||
|
|
0808b27057 | ||
|
|
ac4f5a3580 | ||
|
|
43a435f35d | ||
|
|
7b383d31ab | ||
|
|
09df925e9b | ||
|
|
a4ecbabc21 | ||
|
|
2be43ca06d | ||
|
|
d231f42363 | ||
|
|
7c00279b9d | ||
|
|
b6f9cf58e1 | ||
|
|
3f77488e33 | ||
|
|
5c17751d55 | ||
|
|
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 | ||
|
|
5a464920de | ||
|
|
13a86e92bd | ||
|
|
95088f2d42 | ||
|
|
8c7698adbe | ||
|
|
10e2355817 | ||
|
|
a7747bd1e1 | ||
|
|
fc25154d1c | ||
|
|
6b7603b1cf | ||
|
|
9a44e6acc8 | ||
|
|
a80ce7c845 | ||
|
|
8358f9000e | ||
|
|
a6033cb86a | ||
|
|
f178b5b207 | ||
|
|
155fda8b81 | ||
|
|
4ae6c55e83 | ||
|
|
dccb6a8786 | ||
|
|
f88f9dc0fd | ||
|
|
92ae2b601c | ||
|
|
0929a4e798 | ||
|
|
0f3c1e12c1 | ||
|
|
2f09bf527d | ||
|
|
0e1d8852f7 | ||
|
|
dba10eab24 | ||
|
|
22f625ced0 | ||
|
|
3923144976 | ||
|
|
f6f3c0c255 |
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "phoenix",
|
||||||
|
"runtimeExecutable": "mix",
|
||||||
|
"runtimeArgs": ["phx.server"],
|
||||||
|
"port": 4000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(mix compile *)",
|
||||||
|
"Bash(mix test *)",
|
||||||
|
"Bash(mix dialyzer *)",
|
||||||
|
"Bash(mix ecto.migrate)",
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git push *)",
|
||||||
|
"Bash(git -C /Users/gb/Projects/bDS2 status)",
|
||||||
|
"Bash(git status *)",
|
||||||
|
"Bash(mix assets.deploy)",
|
||||||
|
"Bash(mix phx.server)",
|
||||||
|
"mcp__Claude_Preview__preview_start",
|
||||||
|
"mcp__Claude_in_Chrome__navigate",
|
||||||
|
"mcp__Claude_in_Chrome__computer",
|
||||||
|
"mcp__Claude_in_Chrome__browser_batch",
|
||||||
|
"mcp__Claude_in_Chrome__javascript_tool",
|
||||||
|
"Bash(allium check *)",
|
||||||
|
"Bash(mix deps.get)",
|
||||||
|
"Bash(allium --help)",
|
||||||
|
"WebSearch",
|
||||||
|
"WebFetch(domain:github.com)",
|
||||||
|
"WebFetch(domain:raw.githubusercontent.com)",
|
||||||
|
"Bash(echo \"EXIT: $?\")",
|
||||||
|
"Bash(echo \"exit: $?\")",
|
||||||
|
"Bash(MIX_ENV=prod mix release bds --overwrite)",
|
||||||
|
"Bash(MIX_ENV=prod mix bds.bundle.macos)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
%{
|
||||||
|
configs: [
|
||||||
|
%{
|
||||||
|
name: "default",
|
||||||
|
files: %{
|
||||||
|
included: ["lib/", "test/", "config/", "mix.exs"],
|
||||||
|
excluded: [~r"/deps/", ~r"/_build/", ~r"/priv/static/"]
|
||||||
|
},
|
||||||
|
strict: true,
|
||||||
|
parse_timeout: 10_000,
|
||||||
|
color: true,
|
||||||
|
checks: [
|
||||||
|
{Credo.Check.Consistency.ExceptionNames},
|
||||||
|
{Credo.Check.Consistency.LineEndings},
|
||||||
|
{Credo.Check.Consistency.SpaceAroundOperators},
|
||||||
|
{Credo.Check.Consistency.SpaceInParentheses},
|
||||||
|
{Credo.Check.Consistency.TabsOrSpaces},
|
||||||
|
{Credo.Check.Design.AliasUsage, false},
|
||||||
|
{Credo.Check.Readability.BlockPipe, false},
|
||||||
|
{Credo.Check.Readability.AliasOrder, false},
|
||||||
|
{Credo.Check.Readability.LargeNumbers, false},
|
||||||
|
{Credo.Check.Readability.MaxLineLength, false},
|
||||||
|
{Credo.Check.Readability.ModuleDoc, false},
|
||||||
|
{Credo.Check.Readability.PreferImplicitTry, false},
|
||||||
|
{Credo.Check.Readability.Semicolons, false},
|
||||||
|
{Credo.Check.Readability.StringSigils, false},
|
||||||
|
{Credo.Check.Readability.TrailingBlankLine, false},
|
||||||
|
{Credo.Check.Readability.UnnecessaryAliasExpansion, false},
|
||||||
|
{Credo.Check.Readability.WithSingleClause, false},
|
||||||
|
{Credo.Check.Refactor.Apply, false},
|
||||||
|
{Credo.Check.Refactor.CondStatements, false},
|
||||||
|
{Credo.Check.Refactor.CyclomaticComplexity, false},
|
||||||
|
{Credo.Check.Refactor.FilterFilter, false},
|
||||||
|
{Credo.Check.Refactor.FilterReject, false},
|
||||||
|
{Credo.Check.Refactor.FunctionArity, false},
|
||||||
|
{Credo.Check.Refactor.MapJoin, false},
|
||||||
|
{Credo.Check.Refactor.Nesting, false},
|
||||||
|
{Credo.Check.Refactor.NegatedConditionsWithElse, false},
|
||||||
|
{Credo.Check.Refactor.RejectFilter, false},
|
||||||
|
{Credo.Check.Refactor.RejectReject, false},
|
||||||
|
{Credo.Check.Refactor.RedundantWithClauseResult, false},
|
||||||
|
{Credo.Check.Warning.ApplicationConfigInModuleAttribute},
|
||||||
|
{Credo.Check.Warning.BoolOperationOnSameValues},
|
||||||
|
{Credo.Check.Warning.ExpensiveEmptyEnumCheck},
|
||||||
|
{Credo.Check.Warning.IExPry},
|
||||||
|
{Credo.Check.Warning.LazyLogging},
|
||||||
|
{Credo.Check.Warning.MissedMetadataKeyInLoggerConfig, false},
|
||||||
|
{Credo.Check.Warning.OperationOnSameValues},
|
||||||
|
{Credo.Check.Warning.RaiseInsideRescue}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Downloaded JS — exclude from language detection
|
||||||
|
priv/**/*.js linguist-vendored=true
|
||||||
+8
-1
@@ -3,10 +3,17 @@
|
|||||||
/deps/
|
/deps/
|
||||||
/dist/
|
/dist/
|
||||||
/doc/
|
/doc/
|
||||||
|
/tmp/
|
||||||
/.elixir_ls/
|
/.elixir_ls/
|
||||||
/erl_crash.dump
|
/erl_crash.dump
|
||||||
/node_modules/
|
/node_modules/
|
||||||
/priv/data/*.db
|
/priv/data/*.db
|
||||||
/priv/data/*.db-shm
|
/priv/data/*.db-shm
|
||||||
/priv/data/*.db-wal
|
/priv/data/*.db-wal
|
||||||
*.ez
|
# Project public content (posts, media, templates, generated html) lives in the
|
||||||
|
# per-user default content folder, never the repo. See PublicContentLivesInProjectFolder.
|
||||||
|
/priv/data/projects/
|
||||||
|
# Embeddings index artifacts are per-project runtime caches, never committed.
|
||||||
|
*.usearch
|
||||||
|
*.usearch.meta.json
|
||||||
|
*.eztmp/
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# GHSA-rhv4-8758-jx7v is pinned transitively through bumblebee -> progress_bar.
|
||||||
|
# ecto_sqlite3 0.24.x can move to decimal 3.x, but that line is currently
|
||||||
|
# unsatisfiable alongside the app's Bumblebee dependency.
|
||||||
|
GHSA-rhv4-8758-jx7v
|
||||||
Vendored
+2
-1
@@ -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
|
||||||
@@ -27,7 +28,9 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
- we have an allium spec in the specs/ folder. you must weed the specs against built code to make sure you follow the spec.
|
- we have an allium spec in the specs/ folder. you must weed the specs against built code to make sure you follow the spec.
|
||||||
- when changing the spec, validate the spec with the available command line tool.
|
- when changing the spec, validate the spec with the available command line tool.
|
||||||
- you MUST run tests with command line tools at least once to capture compile errors in tests, do not use the integrated testing of vscode, as that blocks on compile errors
|
- you MUST run tests with command line tools at least once to capture compile errors in tests, do not use the integrated testing of vscode, as that blocks on compile errors
|
||||||
- you MUST run build, test and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests and clean dialyzer results
|
- you MUST run build, test, credo, deps.audit and check dialyzer messages and you MUST treet warnings as errors and fix them. we want clean builds, clean tests, clean credo, clean dependency audits and clean dialyzer results
|
||||||
|
- on a headless Linux machine, you have to run tests with this command (if mix test complains about DISPLAX): xvfb-run mix test
|
||||||
|
- CSS precedence: Tailwind utilities and the ui-* kit (utilities.css) are in @layer and ALWAYS lose to the unlayered hand-written files (shell.css, panel.css, ...). Convention: hand-written CSS owns appearance, Tailwind utilities in templates own layout — never put display/flex-direction/size rules in hand-written CSS for elements whose layout the template controls, and define each selector in exactly one file.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -53,7 +56,16 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
|
|
||||||
- Never leave tests failing, even if they appear unrelated to your changes
|
- Never leave tests failing, even if they appear unrelated to your changes
|
||||||
- If a test failure is pre-existing, fix it as part of your current work
|
- If a test failure is pre-existing, fix it as part of your current work
|
||||||
- Run the full test suite (`npm test`) before considering any task complete
|
- **Always write the full test run to a log file first, then grep that file for failures.** Never run `mix test` in the terminal and try to grep live output — the output is interleaved, truncated, and nearly impossible to parse. Pattern:
|
||||||
|
```
|
||||||
|
mix test 2>&1 > /tmp/test_run.log
|
||||||
|
grep -E "FAILED|^[ ]*1\)" /tmp/test_run.log
|
||||||
|
grep -A 15 "^[ ]*1\)" /tmp/test_run.log # details for the first failure
|
||||||
|
```
|
||||||
|
Work from the log file, not from repeated terminal runs.
|
||||||
|
|
||||||
|
> **Zero failing tests. No exceptions.**
|
||||||
|
- Run the full test suite (`mix test`) before considering any task complete
|
||||||
- If you cannot fix a test, explain why and propose a solution
|
- If you cannot fix a test, explain why and propose a solution
|
||||||
|
|
||||||
> **Zero failing tests. No exceptions.**
|
> **Zero failing tests. No exceptions.**
|
||||||
@@ -100,11 +112,52 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ⚠️ MANDATORY: macOS Bundle Must Be Fully Standalone
|
||||||
|
|
||||||
|
**The built `BDS2.app` MUST run on a Mac without Homebrew.**
|
||||||
|
|
||||||
|
- No Mach-O inside the `.app` may reference a Homebrew/local path (`/opt/homebrew`, `/usr/local`)
|
||||||
|
- Every external dylib reachable from the release's NIFs must be copied into `Contents/Frameworks` and rewritten to `@loader_path`-relative install names (`BDS.MacBundle.Dylibs`)
|
||||||
|
- The same physical dylib referenced under multiple names must be deduped (one real file + symlinks) so dyld loads a single image — duplicate images cause duplicate Objective-C class / wx event-table registrations
|
||||||
|
- `BDS.MacBundle.verify_standalone/1` runs after relocation and fails the build if any external reference remains — do not weaken or bypass this gate
|
||||||
|
|
||||||
|
> **The bundle must be self-contained. No outside references. No exceptions.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ MANDATORY: macOS Bundle Build Order and Blogmark Verification
|
||||||
|
|
||||||
|
**`mix release` copies `priv/static` as-is — it never rebuilds assets.** Always build in this order, or the bundle ships stale JS while the server code is current (this silently broke blogmark deep links in July 2026: the server waited for the client's `shell_ready` event that the stale `app.js` never sent, so every deep link queued forever with no error, no toast, no log):
|
||||||
|
|
||||||
|
```
|
||||||
|
mix assets.deploy
|
||||||
|
MIX_ENV=prod mix release bds --overwrite
|
||||||
|
mix bds.bundle.macos
|
||||||
|
```
|
||||||
|
|
||||||
|
**After building a bundle, verify blogmark deep links end-to-end with:**
|
||||||
|
|
||||||
|
```
|
||||||
|
scripts/verify_blogmark_macos.sh [path/to/BDS2.app] # defaults to dist/macos/BDS2.app
|
||||||
|
```
|
||||||
|
|
||||||
|
The script launches the bundle via LaunchServices with an isolated database (`launchctl setenv BDS_DATABASE_PATH ...`), fires `bds2://new-post` links with `open -a` exactly like a browser bookmarklet, and asserts draft posts appear in the database — both for cold start (link launches the app, queued until the shell attaches) and for an already-running app. It refuses to run while any BDS2 instance is up; quit the app first.
|
||||||
|
|
||||||
|
Things learned the hard way, encoded in the script — do not "simplify" them away:
|
||||||
|
|
||||||
|
- Deep links only reach an app **launched through LaunchServices** (`open -a`). A process started by exec'ing `Contents/MacOS/bds2` from a shell never receives Apple Events, so deep links vanish without a trace. For the same reason `open` does not inherit the shell environment — inject env vars with `launchctl setenv` (and unset them afterwards).
|
||||||
|
- To introspect a running bundle, launch it with `RELEASE_DISTRIBUTION=name` and `RELEASE_NODE=bds@127.0.0.1` set (via `launchctl setenv`), then use `.../Contents/Resources/rel/bin/bds rpc '<elixir>'` with the same env. The default `-sname` node is unreachable when the hostname resolves to LAN addresses.
|
||||||
|
- The wx → `Desktop.Env` → `BDS.Desktop.DeepLink` → shell pipeline downstream of the OS can be exercised without LaunchServices via `send(Process.whereis(BDS.Desktop.DeepLink), {:open_url, ~c"bds2://new-post?..."})` over rpc.
|
||||||
|
|
||||||
|
> **Assets before release. Verify blogmarks after bundling. No exceptions.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text
|
## ⚠️ MANDATORY: Proper I18N for UI and Rendering Text
|
||||||
|
|
||||||
**All user-facing text MUST follow proper i18n patterns.**
|
**All user-facing text MUST follow proper i18n patterns.**
|
||||||
|
|
||||||
- Do not hardcode UI strings directly in React components, menu templates, dialogs, or toasts
|
- Do not hardcode UI strings directly in LiveView/HEEx components, menu templates, dialogs, or toasts
|
||||||
- Store UI copy in language resources and resolve text through i18n helpers/hooks
|
- Store UI copy in language resources and resolve text through i18n helpers/hooks
|
||||||
- UI language MUST come from the operating system locale
|
- UI language MUST come from the operating system locale
|
||||||
- Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale
|
- Rendering/preview/generated-content language MUST come from project preferences (`mainLanguage`), not UI locale
|
||||||
@@ -112,6 +165,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
|
- For supported locales, translations MUST come from that locale file only; do not copy English terms into supported locale files as fallback
|
||||||
- English fallback is allowed only when the requested locale is unsupported by available locale files
|
- English fallback is allowed only when the requested locale is unsupported by available locale files
|
||||||
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
|
- The project `mainLanguage` selector must expose exactly all supported render languages and no unsupported extras
|
||||||
|
- When adding new `msgid` entries, you MUST provide translations for ALL supported locales (de, fr, it, es) — empty `msgstr` values are not acceptable
|
||||||
|
|
||||||
> **No hardcoded user-facing text. No exceptions.**
|
> **No hardcoded user-facing text. No exceptions.**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,498 @@
|
|||||||
|
# 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)
|
||||||
|
- [Running a headless server and the terminal UI](#running-a-headless-server-and-the-terminal-ui)
|
||||||
|
- [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)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Running a headless server and the terminal UI
|
||||||
|
|
||||||
|
bDS2 can run without a window on a server (for example a Linux VPS) and be used from several places at once. Start it with `BDS_MODE=server`; the same release binary that runs the desktop app then runs headless. All clients connect through one SSH port (default 2222) — the web endpoint itself stays private on the server.
|
||||||
|
|
||||||
|
Access is controlled by SSH keys, not passwords. On first start the server creates an `ssh/` folder next to its database (for example `~/Library/Application Support/BDS2/ssh/` on macOS) containing the host key and an empty `authorized_keys` file. Paste the public keys of every machine that may connect into `authorized_keys`, one per line — the same file format OpenSSH uses.
|
||||||
|
|
||||||
|
To work in a terminal, connect with plain `ssh -p 2222 user@server`: the terminal UI opens directly. You can browse posts, media, templates, scripts, and tags, create and edit posts, publish, preview images, and run the one-shot AI actions. The status line at the bottom always shows the available keys.
|
||||||
|
|
||||||
|
To work in the desktop app against a remote server, use **File → Connect to Server…** and enter `user@host` (or `user@host:port`). The app connects with the SSH key from its own local `ssh/` folder and shows the remote workspace in the window; **File → Disconnect from Server** returns to the local workspace. A plain browser works too, through a manual SSH tunnel (`ssh -p 2222 -L 4010:127.0.0.1:4010 user@server`, then open `http://127.0.0.1:4010`).
|
||||||
|
|
||||||
|
Everything you see stays synchronized: when a script, another client, or a pipeline changes content on the server, every connected terminal and window updates. The interface language is a server-side setting — changing it in any client changes it for all of them.
|
||||||
|
|
||||||
|
### Key takeaways
|
||||||
|
|
||||||
|
- `BDS_MODE=server` runs the same app headless; only the SSH port is exposed.
|
||||||
|
- Access is public-key only: manage `authorized_keys` in the server's `ssh/` folder.
|
||||||
|
- `ssh` gives the terminal UI; **File → Connect to Server…** gives the desktop app; both use the same keys.
|
||||||
|
- All connected clients stay synchronized and share one interface language.
|
||||||
|
|
||||||
|
[↑ Back to In this article](#in-this-article)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting and recovery
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# bDS2 Plan
|
|
||||||
|
|
||||||
This document tracks the current implementation state of bDS2 against the Allium specs and the old bDS application, and it defines the ordered plan to reach full feature parity.
|
|
||||||
|
|
||||||
## 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, and the shared modal/overlay layer for AI suggestions, picker flows, confirmations, and gallery/lightbox interactions.
|
|
||||||
|
|
||||||
### Implemented But Not Yet At Parity
|
|
||||||
|
|
||||||
- Layout, tabs, and sidebar shell behavior exist, but many editor routes still render generic content instead of feature-complete editors.
|
|
||||||
- Maintenance and validation routes such as metadata diff, site validation, translation validation, and duplicate detection have dedicated payload renderers, but most authoring routes do not.
|
|
||||||
- AI gating, translation, image analysis, tag suggestions, and side-effect chains exist in backend services, but the end-to-end UI flows around those operations are still incomplete.
|
|
||||||
- Shared workbench/session state exists, but the richer cross-surface behaviors described in the UI data-flow specs are only partially realized.
|
|
||||||
|
|
||||||
### Missing Or Materially Incomplete
|
|
||||||
|
|
||||||
- Rich route-specific editors for post, media, settings, tags, chat, script, template, and misc surfaces.
|
|
||||||
- Full UI wiring for create/import/publish/preview/edit-menu flows described by the editor specs.
|
|
||||||
- Full parity validation against the old application for every spec-defined edge case in editor behavior, media processing details, and cross-feature action chains.
|
|
||||||
|
|
||||||
## 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; route bodies remain generic until the editor UX phase. |
|
|
||||||
| 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` | Partial | Shared modal workflows are implemented; route registration exists, but feature-complete editors are not done. |
|
|
||||||
|
|
||||||
## Plan To Full Feature Parity
|
|
||||||
|
|
||||||
The remaining work needs to proceed from base contracts upward. Later phases should not outrun the lower layers they depend on.
|
|
||||||
|
|
||||||
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. Build feature-complete editors.
|
|
||||||
Replace generic editor bodies with real editors for posts, media, settings, tags, chat, scripts, templates, and misc maintenance views, including save/discard/publish/delete/import flows.
|
|
||||||
|
|
||||||
6. Wire cross-surface UI behavior.
|
|
||||||
Complete the sidebar-editor-tab-reactivity model, dirty-state rules, background task feedback, offline/airplane-mode gating in the UI, and route-specific state transitions described by the UI data-flow and action-pattern specs.
|
|
||||||
|
|
||||||
7. Reach legacy parity and harden releases.
|
|
||||||
Add executable tests mapped to the Allium specs and old-app behaviors, then validate packaging, desktop runtime behavior, MCP packaging, and end-to-end workflows as release criteria.
|
|
||||||
@@ -1,52 +1,123 @@
|
|||||||
# bDS2
|
# bDS2
|
||||||
|
|
||||||
bDS2 is the Elixir rewrite of bDS, the offline-first desktop blogging workspace in [../bDS](/Users/gb/Projects/bDS). The repository now contains a substantial BEAM application: Ecto persistence, filesystem-backed content workflows, rendering/generation/publishing pipelines, AI and MCP integrations, and a bundled desktop shell served by the Elixir runtime.
|
bDS2 is the Elixir rewrite of bDS, the offline-first desktop blogging workspace. It is no longer just a rewrite scaffold: the repository now contains the main desktop runtime, Ecto persistence, filesystem-backed content workflows, rendering and publishing pipelines, Lua scripting, AI and MCP integration, and a Phoenix LiveView shell embedded in a native desktop window.
|
||||||
|
|
||||||
The Allium specifications in [specs/](/Users/gb/Projects/bDS2/specs) remain the behavioral contract for the rewrite. For current implementation status and the parity roadmap, see [PLAN.md](/Users/gb/Projects/bDS2/PLAN.md).
|
The Allium specs in [specs/](/Users/gb/Projects/bDS2/specs) remain the behavioral contract for the rewrite. For end-user operation, see [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md). For the scripting surface, see [API.md](/Users/gb/Projects/bDS2/API.md).
|
||||||
|
|
||||||
## Scope
|
## Current Status
|
||||||
|
|
||||||
The rewrite aims to preserve the product behavior of bDS while replacing the technical stack.
|
The major architectural rework is in place.
|
||||||
|
|
||||||
Behaviour that should remain stable includes:
|
- The desktop UI is served by Phoenix LiveView inside the desktop shell rather than by a separate handwritten frontend runtime.
|
||||||
|
- Assets use Phoenix-default Tailwind and esbuild tooling from [assets/](/Users/gb/Projects/bDS2/assets) into [priv/static/](/Users/gb/Projects/bDS2/priv/static).
|
||||||
|
- Core editorial flows are implemented in the main application: posts, media, tags, templates, scripts, imports, preview, generation, publishing, maintenance, AI, and MCP.
|
||||||
|
- Localization is now a first-class architectural concern rather than an afterthought: UI chrome and rendered site output have separate locale flows, and post/media translation workflows are built into the domain model.
|
||||||
|
- The app boots in three modes: the desktop app, a headless server (SSH-served terminal UI + tunneled GUI), and a local TUI — see [Headless Server Mode And Terminal UI](#headless-server-mode-and-terminal-ui).
|
||||||
|
|
||||||
- Offline-first editorial workflows.
|
The rewrite still aims to preserve the product behavior of bDS while replacing the technical stack. The contract is product behavior, not the old implementation language or framework choices.
|
||||||
- Filesystem-backed content with stable frontmatter, media sidecars, templates, scripts, and menu formats.
|
|
||||||
- Project, post, media, translation, tag, template, generation, preview, publishing, AI, and MCP workflows.
|
|
||||||
- Generated site output, search behavior, metadata synchronization, and rebuild behavior where those are part of the product contract.
|
|
||||||
|
|
||||||
The following are intentionally not part of the behavioral contract:
|
## Architecture Overview
|
||||||
|
|
||||||
- The implementation language.
|
### Runtime
|
||||||
- Desktop container or UI framework.
|
|
||||||
- ORM choice.
|
|
||||||
- Internal state management, concurrency model, or runtime libraries.
|
|
||||||
|
|
||||||
## Scripting Direction
|
[BDS.Application](/Users/gb/Projects/bDS2/lib/bds/application.ex) is the supervision root. It starts the Phoenix endpoint, database, preview and publishing workers, task supervisors, scripting jobs, and the desktop server/window adapters.
|
||||||
|
|
||||||
bDS2 should use Lua as its user-facing scripting language.
|
At a high level, the stack is:
|
||||||
|
|
||||||
The reason is host fit, not language fashion: Lua has a better embedding story for the BEAM than Python does, while still being small, expressive, and suitable for user-authored macros, transforms, and utility scripts. The current direction is:
|
- Native windowing through the `:desktop` integration.
|
||||||
|
- Phoenix endpoint and LiveView shell for the actual app UI.
|
||||||
|
- Ecto + SQLite for indexed state, editor state, and app data.
|
||||||
|
- Filesystem-backed project data for published content, media, sidecars, scripts, templates, generated output, and rebuild workflows.
|
||||||
|
|
||||||
- Lua script files as the persisted user script format.
|
### Desktop Shell
|
||||||
- A BEAM-hosted execution boundary with explicit host capabilities instead of unrestricted runtime access.
|
|
||||||
- Bounded but long-running script execution for user-authored code, with explicit progress reporting through host APIs.
|
|
||||||
|
|
||||||
The initial runtime baseline in this repository uses a dedicated Elixir scripting boundary with a Luerl-backed Lua adapter. The goal is to keep scripting integration native to the BEAM while making sandboxing and host capability exposure explicit at the application boundary.
|
The desktop workbench lives under [lib/bds/desktop/](/Users/gb/Projects/bDS2/lib/bds/desktop). The main screen is [BDS.Desktop.ShellLive](/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live.ex), with feature-specific editors and sidebar logic under [lib/bds/desktop/shell_live/](/Users/gb/Projects/bDS2/lib/bds/desktop/shell_live).
|
||||||
|
|
||||||
This keeps the scripting surface lightweight and aligned with the Elixir host application. Python remains a possible integration boundary for specialized tasks, but it is no longer the default scripting model for the rewrite.
|
If you are tracing UI behavior, start there first:
|
||||||
|
|
||||||
## Repository Layout
|
- LiveView event routing, workbench state, overlays, and menu handling live in the desktop shell modules.
|
||||||
|
- HEEx templates under the same tree now own most common layout and state styling.
|
||||||
|
- Monaco is bundled as an ESM bundle via esbuild into `priv/static/assets/monaco.js` and `monaco.css` (source entry at `assets/js/monaco_entry.js`).
|
||||||
|
|
||||||
- [mix.exs](/Users/gb/Projects/bDS2/mix.exs): Mix project definition.
|
### Domain Modules
|
||||||
- [config/](/Users/gb/Projects/bDS2/config): Elixir and Ecto configuration.
|
|
||||||
- [lib/](/Users/gb/Projects/bDS2/lib): application bootstrap and shared runtime modules.
|
Most application behavior lives under [lib/bds/](/Users/gb/Projects/bDS2/lib/bds):
|
||||||
- [priv/repo/](/Users/gb/Projects/bDS2/priv/repo): Ecto migrations.
|
|
||||||
- [specs/](/Users/gb/Projects/bDS2/specs): Allium specs distilled from the existing bDS product and being normalized for implementation-agnostic use.
|
- posts, media, tags, templates, scripts, and project settings
|
||||||
|
- metadata, frontmatter, sidecars, rebuild, and maintenance
|
||||||
|
- rendering, generation, preview, and publishing
|
||||||
|
- AI runtimes, chat tooling, embeddings, and MCP
|
||||||
|
- scripting capabilities and generated API docs
|
||||||
|
|
||||||
|
The repo has been pushed toward smaller feature-focused modules rather than one large mixed runtime. For new work, prefer finding the owning feature module instead of adding more behavior to broad catch-all files.
|
||||||
|
|
||||||
|
### Storage Model
|
||||||
|
|
||||||
|
The database is important, but it is not the whole source of truth.
|
||||||
|
|
||||||
|
- Ecto models hold app state, indexes, editor state, and workflow data.
|
||||||
|
- The filesystem holds published content artifacts and sidecar metadata that must stay stable and reviewable.
|
||||||
|
- Rebuild and metadata-diff flows exist because database state and filesystem state are expected to stay in sync.
|
||||||
|
|
||||||
|
When you change persisted behavior, think in both directions: database writes and filesystem writes/readback.
|
||||||
|
|
||||||
|
## Localization And i18n
|
||||||
|
|
||||||
|
Localization now has two separate layers, and confusing them causes bugs.
|
||||||
|
|
||||||
|
### 1. UI Localization
|
||||||
|
|
||||||
|
UI chrome, menus, dashboard text, editor labels, and toasts use Gettext through [BDS.Gettext](/Users/gb/Projects/bDS2/lib/bds/gettext.ex) and the `ui` domain. Locale normalization lives in [BDS.I18n](/Users/gb/Projects/bDS2/lib/bds/i18n.ex), and the desktop shell binds the active UI locale through [BDS.Desktop.UILocale](/Users/gb/Projects/bDS2/lib/bds/desktop/ui_locale.ex).
|
||||||
|
|
||||||
|
In practice, this is the language of the app itself.
|
||||||
|
|
||||||
|
### 2. Render Localization
|
||||||
|
|
||||||
|
Rendered site output uses a separate locale flow. Archive labels, pagination text, template-facing render strings, and generated site language handling use the `render` Gettext domain and the project's `main_language` and `blog_languages` settings.
|
||||||
|
|
||||||
|
In practice, this is the language of the blog output, not necessarily the UI.
|
||||||
|
|
||||||
|
### 3. Content Translation
|
||||||
|
|
||||||
|
Posts and media have translation-aware workflows. Post translations and media metadata translations are modeled explicitly, and generation/preview/publishing use the project's configured languages when building output.
|
||||||
|
|
||||||
|
Relevant translation resources live under:
|
||||||
|
|
||||||
|
- [priv/gettext/](/Users/gb/Projects/bDS2/priv/gettext) for Gettext catalogs
|
||||||
|
- [priv/i18n/](/Users/gb/Projects/bDS2/priv/i18n) for additional locale data used by the app
|
||||||
|
|
||||||
|
If you touch i18n-sensitive behavior, check whether the change belongs to UI locale, render locale, or content translation. They are related, but they are not interchangeable.
|
||||||
|
|
||||||
|
## Frontend And Assets
|
||||||
|
|
||||||
|
Frontend source now follows the Phoenix asset layout:
|
||||||
|
|
||||||
|
- [assets/css/](/Users/gb/Projects/bDS2/assets/css) for Tailwind-based CSS modules
|
||||||
|
- [assets/js/](/Users/gb/Projects/bDS2/assets/js) for LiveView hooks, bridges, Monaco integration, and UI helpers
|
||||||
|
- [priv/static/assets/](/Users/gb/Projects/bDS2/priv/static/assets) for generated outputs
|
||||||
|
|
||||||
|
The rule of thumb is simple:
|
||||||
|
|
||||||
|
- common layout, spacing, state, and typography belong in HEEx and small shared UI primitives
|
||||||
|
- authored CSS stays for tokens and desktop-specific selectors
|
||||||
|
- JavaScript stays focused on LiveView hooks, editor integration, drag/drop, and browser APIs
|
||||||
|
|
||||||
|
## Repository Map
|
||||||
|
|
||||||
|
- [mix.exs](/Users/gb/Projects/bDS2/mix.exs): Mix project definition, aliases, releases, and dependencies
|
||||||
|
- [config/](/Users/gb/Projects/bDS2/config): runtime, dev, test, and asset configuration
|
||||||
|
- [lib/bds/](/Users/gb/Projects/bDS2/lib/bds): core application modules
|
||||||
|
- [lib/bds/desktop/](/Users/gb/Projects/bDS2/lib/bds/desktop): desktop endpoint, shell, menus, controllers, and window integration
|
||||||
|
- [assets/](/Users/gb/Projects/bDS2/assets): Tailwind and esbuild source
|
||||||
|
- [priv/repo/](/Users/gb/Projects/bDS2/priv/repo): Ecto migrations and snapshots
|
||||||
|
- [priv/gettext/](/Users/gb/Projects/bDS2/priv/gettext): UI and render translation catalogs
|
||||||
|
- [specs/](/Users/gb/Projects/bDS2/specs): Allium behavior specs
|
||||||
|
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md): end-user guide
|
||||||
|
- [API.md](/Users/gb/Projects/bDS2/API.md): generated scripting API reference
|
||||||
|
|
||||||
## macOS Development Setup
|
## macOS Development Setup
|
||||||
|
|
||||||
If you are setting up a new macOS machine, start with the toolchain.
|
If you are setting up a new macOS machine, install the toolchain first.
|
||||||
|
|
||||||
### 1. Install Xcode Command Line Tools
|
### 1. Install Xcode Command Line Tools
|
||||||
|
|
||||||
@@ -56,8 +127,6 @@ xcode-select --install
|
|||||||
|
|
||||||
### 2. Install Homebrew
|
### 2. Install Homebrew
|
||||||
|
|
||||||
If Homebrew is not already installed:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
|
||||||
```
|
```
|
||||||
@@ -69,36 +138,159 @@ brew update
|
|||||||
brew install erlang elixir sqlite
|
brew install erlang elixir sqlite
|
||||||
```
|
```
|
||||||
|
|
||||||
Verify the installation:
|
### 4. Fetch Dependencies And Set Up The App
|
||||||
|
|
||||||
```bash
|
|
||||||
elixir --version
|
|
||||||
mix --version
|
|
||||||
sqlite3 --version
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Fetch Dependencies
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd /Users/gb/Projects/bDS2
|
cd /Users/gb/Projects/bDS2
|
||||||
mix deps.get
|
mix setup
|
||||||
|
mix assets.setup
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Create the Local Database
|
## Development Workflow
|
||||||
|
|
||||||
```bash
|
Useful commands:
|
||||||
mix ecto.create
|
|
||||||
mix ecto.migrate
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6. Run Tests
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
mix compile --warnings-as-errors
|
||||||
mix test
|
mix test
|
||||||
|
mix dialyzer
|
||||||
|
mix assets.build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development Notes
|
## Headless Server Mode And Terminal UI
|
||||||
|
|
||||||
- Use `mix test` for validation during development.
|
Since issue #26 the app has three boot modes, selected with the `BDS_MODE`
|
||||||
- The application behavior is defined by the Allium specs in [specs/](/Users/gb/Projects/bDS2/specs).
|
environment variable (any release or dev boot; default is the desktop app):
|
||||||
- Use [PLAN.md](/Users/gb/Projects/bDS2/PLAN.md) for implementation status and the parity roadmap.
|
|
||||||
|
- `desktop` — the native desktop app, unchanged.
|
||||||
|
- `server` — headless: no window, loopback-only HTTP endpoint, CLI sync
|
||||||
|
watcher, and an SSH daemon serving the terminal UI. Works on macOS,
|
||||||
|
Windows, and Linux (no display needed).
|
||||||
|
- `tui` — the headless server plus a TUI attached to the launching terminal.
|
||||||
|
|
||||||
|
### Build and run a headless server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIX_ENV=prod mix release bds --overwrite
|
||||||
|
BDS_MODE=server _build/prod/rel/bds/bin/bds start
|
||||||
|
```
|
||||||
|
|
||||||
|
The SSH daemon listens on port `2222` (`BDS_SSH_PORT` or
|
||||||
|
`config :bds, :server, ssh_port:` to change). Authentication is public-key
|
||||||
|
only: on first boot the server generates a host key and an empty
|
||||||
|
`authorized_keys` (mode 600) in `ssh/` next to the database (default
|
||||||
|
`~/Library/Application Support/BDS2/ssh/`, or under `BDS_DATABASE_PATH`).
|
||||||
|
Add your public key there, then connect from any terminal:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 2222 <server-host> # opens the TUI
|
||||||
|
```
|
||||||
|
|
||||||
|
Each SSH connection gets its own TUI session on the server; only terminal
|
||||||
|
cells cross the wire. All clients stay synchronized through the domain
|
||||||
|
event bus (`BDS.Events`) — a post created by a pipeline or another client
|
||||||
|
appears in every open shell.
|
||||||
|
|
||||||
|
### TUI keys
|
||||||
|
|
||||||
|
`↑/↓` or `j/k` navigate · `enter` open · `n` new post · `1-5` switch view
|
||||||
|
(posts/media/templates/scripts/tags) · `:` command prompt (metadata
|
||||||
|
diff, validate site, force render, rebuilds, …; `:?` lists all) ·
|
||||||
|
metadata-diff/site-validation reports open as panels — `enter` applies
|
||||||
|
the whole report (repair from files / incremental apply), `esc` cancels ·
|
||||||
|
`r` refresh · in the editor:
|
||||||
|
`ctrl+s` save, `ctrl+p` publish, `ctrl+e` word-wrapped preview,
|
||||||
|
`ctrl+t` title, `ctrl+l` language, `ctrl+g` AI suggestions
|
||||||
|
(airplane-mode gated), `esc` back · `ctrl+q` quit.
|
||||||
|
Images preview inline via the terminal image protocol. The UI language is
|
||||||
|
the server-side setting shared by every client.
|
||||||
|
|
||||||
|
### Remote GUI
|
||||||
|
|
||||||
|
The HTTP endpoint stays bound to `127.0.0.1` and is never exposed; GUI
|
||||||
|
clients tunnel through the same SSH daemon and keys:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh -p 2222 -L 4010:127.0.0.1:4010 -N <server-host> &
|
||||||
|
open http://127.0.0.1:4010
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the desktop app directly: **File → Connect to Server…** takes
|
||||||
|
`user@host[:port]`, connects with the client key from the same local
|
||||||
|
`ssh/` directory (public-key auth, trust-on-first-use `known_hosts`),
|
||||||
|
tunnels to the server, and points the window at it. **File → Disconnect
|
||||||
|
from Server** returns to the local workspace.
|
||||||
|
|
||||||
|
## Monaco Editor
|
||||||
|
|
||||||
|
Monaco is bundled via esbuild using its ESM entry point (`monaco-editor/esm/vs/editor/editor.main.js`). The
|
||||||
|
main bundle is built as part of `mix assets.build` and lives at `priv/static/assets/monaco.js` and
|
||||||
|
`monaco.css`; ESM worker bundles live under `priv/static/assets/monaco/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mix monaco.version # show current monaco-editor version
|
||||||
|
mix monaco.update 0.55.1 # update to a specific version and rebuild the bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
The update task runs `npm install monaco-editor@<version>`, cleans the old bundle, and rebuilds via esbuild.
|
||||||
|
See https://www.npmjs.com/package/monaco-editor for available versions.
|
||||||
|
|
||||||
|
Notes for developers:
|
||||||
|
|
||||||
|
- Specs in [specs/](/Users/gb/Projects/bDS2/specs) define the intended product behavior.
|
||||||
|
- [DOCUMENTATION.md](/Users/gb/Projects/bDS2/DOCUMENTATION.md) is for end users, not implementation details.
|
||||||
|
- [API.md](/Users/gb/Projects/bDS2/API.md) is generated from the live scripting capability map and should stay in sync with runtime changes.
|
||||||
|
- When changing persistence or localization behavior, check both the database side and the filesystem/render side before assuming the change is complete.
|
||||||
|
|
||||||
|
## Packaging The macOS App
|
||||||
|
|
||||||
|
`mix bds.bundle.macos` produces a self-contained, ad-hoc-signed `BDS2.app`. It
|
||||||
|
bundles the `:bds` release (ERTS included) plus the wxWidgets dylibs relocated
|
||||||
|
via `@loader_path`, so it runs on a clean Mac with nothing installed. The bundle
|
||||||
|
registers the `bds2://` URL scheme (for blogmark deep links) and ships the red-pen
|
||||||
|
`AppIcon`. Output: `dist/macos/BDS2.app`. (arm64 / Apple Silicon only.)
|
||||||
|
|
||||||
|
### 1. Build the release, then the bundle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MIX_ENV=prod mix release bds --overwrite
|
||||||
|
mix bds.bundle.macos --app-release _build/prod/rel/bds
|
||||||
|
```
|
||||||
|
|
||||||
|
The icon `.icns` is generated on first run from
|
||||||
|
[priv/desktop/macos/icon-source.svg](/Users/gb/Projects/bDS2/priv/desktop/macos/icon-source.svg)
|
||||||
|
and committed under `priv/desktop/macos/`. Pass `--regen-icon` to rebuild it.
|
||||||
|
|
||||||
|
### 2. Register the `bds2://` scheme (optional)
|
||||||
|
|
||||||
|
`--register` tells LaunchServices about the scheme without moving the app to
|
||||||
|
`/Applications` — useful for testing blogmark deep links locally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mix bds.bundle.macos --app-release _build/prod/rel/bds --register
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run / smoke-test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
open dist/macos/BDS2.app
|
||||||
|
open "bds2://new-post?title=Hello&url=https://example.com" # opens a draft
|
||||||
|
```
|
||||||
|
|
||||||
|
### Useful flags
|
||||||
|
|
||||||
|
- `--app-release PATH` — release dir to wrap (default `_build/<env>/rel/bds`).
|
||||||
|
- `--output DIR` — output directory (default `dist/macos`).
|
||||||
|
- `--version V` — version string for `Info.plist` (default from `mix.exs`).
|
||||||
|
- `--regen-icon` — regenerate `AppIcon.icns` from the source SVG.
|
||||||
|
- `--register` — register the `bds2://` scheme via LaunchServices.
|
||||||
|
- `--no-codesign` — skip the ad-hoc codesign step.
|
||||||
|
|
||||||
|
### Verifying the bundle
|
||||||
|
|
||||||
|
```bash
|
||||||
|
plutil -lint dist/macos/BDS2.app/Contents/Info.plist
|
||||||
|
codesign --verify --deep --strict dist/macos/BDS2.app
|
||||||
|
# no /opt/homebrew or /usr/local references should appear:
|
||||||
|
otool -L dist/macos/BDS2.app/Contents/Resources/rel/lib/wx-*/priv/wxe_driver.so
|
||||||
|
```
|
||||||
+191
@@ -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
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Test Audit Procedure
|
||||||
|
|
||||||
|
Periodic review of the unit test suite to ensure every test exercises production
|
||||||
|
code against real assumptions and behavior.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
All `*_test.exs` files under `test/`.
|
||||||
|
|
||||||
|
## What counts as a valid unit test
|
||||||
|
|
||||||
|
A valid unit test **calls at least one production function** from `lib/bds/` and
|
||||||
|
**asserts on its return value, side effects, or observable behavior**.
|
||||||
|
|
||||||
|
Acceptable patterns:
|
||||||
|
|
||||||
|
- Calling a production function and asserting its return value.
|
||||||
|
- Calling a production function with injected test doubles (fake HTTP clients,
|
||||||
|
fake runtimes) and asserting the production code's orchestration logic.
|
||||||
|
- Mounting a LiveView or rendering a LiveComponent and asserting HTML output
|
||||||
|
or database state after interactions.
|
||||||
|
- Sending events to a GenServer and asserting state transitions.
|
||||||
|
|
||||||
|
### Source-property tests (acceptable, not flagged)
|
||||||
|
|
||||||
|
Tests that verify structural properties of source code are acceptable and should
|
||||||
|
not be flagged during this audit. Examples:
|
||||||
|
|
||||||
|
- Checking that all public functions have `@spec` annotations (AST parsing).
|
||||||
|
- Asserting absence of `String.to_atom` or `cond do` in specific files.
|
||||||
|
- Verifying CSS/JS/template assets contain expected class names or imports.
|
||||||
|
- Checking that `API.md` matches the output of a documentation generator.
|
||||||
|
- Verifying database indexes exist via `EXPLAIN QUERY PLAN`.
|
||||||
|
- Asserting `.allium` spec files have consistent parameter signatures.
|
||||||
|
- Checking config files for expected values.
|
||||||
|
- Verifying function decomposition patterns in source.
|
||||||
|
|
||||||
|
These are linting/contract/consistency checks. They serve a purpose but are
|
||||||
|
distinct from behavioral tests.
|
||||||
|
|
||||||
|
## What gets flagged
|
||||||
|
|
||||||
|
1. **Export-existence-only tests** — tests that call `function_exported?/3` or
|
||||||
|
`Code.ensure_loaded?/1` without ever invoking the function. These verify
|
||||||
|
compilation, not behavior. They are redundant when the same module is already
|
||||||
|
tested via rendering or direct calls in another test file.
|
||||||
|
|
||||||
|
2. **Mock-only tests** — tests that define a fake/stub module and only assert
|
||||||
|
on that fake's behavior without routing through any production code path.
|
||||||
|
|
||||||
|
3. **Trivially-passing tests** — tests whose assertions succeed regardless of
|
||||||
|
whether the production code is correct (e.g., asserting on a hardcoded value
|
||||||
|
that never touches production logic).
|
||||||
|
|
||||||
|
## How to run the audit
|
||||||
|
|
||||||
|
Ask Claude Code to:
|
||||||
|
|
||||||
|
> Analyse the unit tests of the project and check if all of them actually call
|
||||||
|
> proper production code or if there are tests that essentially only test
|
||||||
|
> scaffolds, mocks and helper functions. Every unit test must test proper
|
||||||
|
> production code against assumptions and behaviour. Source-property tests
|
||||||
|
> (structure, @spec, asset presence, schema verification, doc staleness) are
|
||||||
|
> acceptable and should not be flagged.
|
||||||
|
|
||||||
|
The audit should:
|
||||||
|
|
||||||
|
1. Read every `*_test.exs` file under `test/` in full.
|
||||||
|
2. For each test block, identify which production function (if any) is called.
|
||||||
|
3. Flag any test that falls into the categories above.
|
||||||
|
4. Report flagged tests with file path, line number, and explanation.
|
||||||
|
|
||||||
|
## Audit log
|
||||||
|
|
||||||
|
### 2026-05-11
|
||||||
|
|
||||||
|
Reviewed all 71 test files (69 after cleanup). Found 2 redundant files:
|
||||||
|
|
||||||
|
- `test/bds/desktop/shell_live/chat_editor_test.exs` — single test only called
|
||||||
|
`function_exported?` for `ChatEditor`. The component was already fully tested
|
||||||
|
via `render_component` in `shell_live_test.exs`. **Deleted.**
|
||||||
|
|
||||||
|
- `test/bds/desktop/shell_live/import_editor_test.exs` — single test only called
|
||||||
|
`Code.ensure_loaded?` + `function_exported?` for `ImportEditor`. The component
|
||||||
|
was already exercised in `import_shell_live_test.exs`. **Deleted.**
|
||||||
|
|
||||||
|
Result after cleanup: 646 tests, 0 failures, 4 skipped.
|
||||||
+223
@@ -0,0 +1,223 @@
|
|||||||
|
# TUI Editor — Implementation Plan (Issue #32)
|
||||||
|
|
||||||
|
Tracking issue: **#32** — "markdown, liquid and lua code editor with syntax
|
||||||
|
highlighting in the TUI".
|
||||||
|
Spec under change: `specs/tui.allium`.
|
||||||
|
|
||||||
|
Goal: a syntax-highlighted, word-wrapping TUI editor for posts (markdown
|
||||||
|
with `[[macro]]` syntax), templates (HTML + Liquid), and scripts (Lua),
|
||||||
|
replacing the current plain `ExRatatui.Widgets.Textarea` body.
|
||||||
|
|
||||||
|
## Decision: pure Elixir — no new Rust
|
||||||
|
|
||||||
|
`ExRatatui` already ships everything we need:
|
||||||
|
|
||||||
|
- `ExRatatui.CodeBlock.highlight/3` (`deps/ex_ratatui/lib/ex_ratatui/code_block.ex:103`)
|
||||||
|
returns `[%ExRatatui.Text.Line{}]` of styled spans via the bundled
|
||||||
|
syntect NIF. Supports Lua, Markdown, HTML, … out of the box.
|
||||||
|
- `ExRatatui.Widgets.CodeBlock` is read-only; we do **not** use it
|
||||||
|
directly for editing.
|
||||||
|
- `ExRatatui.Widget` protocol (`deps/ex_ratatui/lib/ex_ratatui/widget.ex:1`)
|
||||||
|
lets us implement a custom widget in pure Elixir that composes
|
||||||
|
primitives every frame. This is the seam we use.
|
||||||
|
- The editing engine stays Rust-owned
|
||||||
|
(`ExRatatui.textarea_new/0`, `textarea_handle_key/3`, `textarea_get_value/1`,
|
||||||
|
`textarea_cursor/1`, `textarea_line_count/1`). The custom widget only
|
||||||
|
re-renders the buffer through a styling + wrap pipeline.
|
||||||
|
- `ExRatatui.Widgets.Paragraph` accepts `[%Text.Line{}]` of styled spans
|
||||||
|
with `wrap: true` and a `scroll: {vertical, horizontal}` offset —
|
||||||
|
exactly the output shape we need.
|
||||||
|
|
||||||
|
The existing comment at `lib/bds/tui.ex:1451-1454` already pre-announces
|
||||||
|
this approach as "the planned MarkdownEditor custom widget".
|
||||||
|
|
||||||
|
Languages supported (matches GUI Monaco registrations in
|
||||||
|
`assets/js/monaco/languages.js`):
|
||||||
|
|
||||||
|
| Entity | Base lexer (NIF) | Elixir overlay |
|
||||||
|
|-----------------------|------------------|---------------------------------|
|
||||||
|
| post body | `markdown` | `[[macro …]]` recoloured |
|
||||||
|
| script body | `lua` | none |
|
||||||
|
| template body | `html` | `{{ … }}` and `{% … %}` recoloured |
|
||||||
|
|
||||||
|
Liquid is always HTML+Liquid in this codebase (no markdown+Liquid
|
||||||
|
templates exist). Plain text falls back to syntect's plain renderer.
|
||||||
|
|
||||||
|
## GUI styling parity (from `assets/js/monaco/theme.js` + `languages.js`)
|
||||||
|
|
||||||
|
- **macro** `[[ … ]]` → `keyword.macro` = `#C586C0`, **bold**.
|
||||||
|
- inside macro: `attribute.name` (e.g. `names`) → `#9CDCFE`,
|
||||||
|
`attribute.value` (e.g. `"x"`) → `#CE9178`.
|
||||||
|
- Liquid uses base vs-dark tokens; terminal approximation uses the same
|
||||||
|
base theme accent colours. No custom Liquid token colours overridden.
|
||||||
|
|
||||||
|
Use `{:rgb,197,134,192}` etc. via `ExRatatui.Style` (already supported
|
||||||
|
by `ExRatatui.CodeBlock.from_native/1`).
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
state.editor.textarea (unchanged, Rust-owned buffer/cursor/undo)
|
||||||
|
state.editor.language (:markdown_macros | :liquid | :lua | :text)
|
||||||
|
state.editor.preview? (unchanged ctrl+e toggle — default false for posts)
|
||||||
|
|
||||||
|
body_widget/3 edit branch: %BDS.UI.CodeEditor{textarea:, language:, theme:, wrap:, cursor_style:, line_highlight_style:, block:}
|
||||||
|
↳ impl ExRatatui.Widget.render:
|
||||||
|
1. content = ExRatatui.textarea_get_value(textarea)
|
||||||
|
2. {row, col} = ExRatatui.textarea_cursor(textarea)
|
||||||
|
3. lines = BDS.UI.CodeEditor.Highlight.highlight(content, language, theme)
|
||||||
|
4. apply current-line bg style into every span on lines[row]
|
||||||
|
5. cut the span at byte col on lines[row], splice cursor-style cell
|
||||||
|
6. top = vertical-scroll offset so cursor visual row stays in window (Latin-width count)
|
||||||
|
7. return [{%Paragraph{text: lines, wrap: true, scroll: {top,0}, block: block}, rect}]
|
||||||
|
```
|
||||||
|
|
||||||
|
`editor_key/1` in `lib/bds/tui.ex` keeps calling
|
||||||
|
`ExRatatui.textarea_handle_key/3` — no edit-model rewrite, the
|
||||||
|
textarea's own undo/redo/clipboard keeps working.
|
||||||
|
|
||||||
|
## Phases (test-first, per AGENTS.md)
|
||||||
|
|
||||||
|
### Phase 0 — Spec
|
||||||
|
|
||||||
|
Edit `specs/tui.allium`:
|
||||||
|
|
||||||
|
- `OpenEntry`: posts open in the **editor** (wrap on, highlighting on)
|
||||||
|
by default — not the rendered Markdown preview. New empty posts still
|
||||||
|
open in the editor (unchanged).
|
||||||
|
- Rename `WrappedPreview` → `EditorMode`: `ctrl+e` toggles editor ↔
|
||||||
|
read-only rendered-Markdown preview (via `ExRatatui.Widgets.Markdown`).
|
||||||
|
Drop the "textarea cannot wrap" caveat.
|
||||||
|
- Add `CodeEditorWrapping` rule: syntax highlighting + auto wrap +
|
||||||
|
highlighted current line. Explicitly **no line-number gutter**.
|
||||||
|
- Validate with `allium check specs/tui.allium`.
|
||||||
|
|
||||||
|
### Phase 1 — Red tests
|
||||||
|
|
||||||
|
New file `test/bds/ui/code_editor_test.exs`:
|
||||||
|
|
||||||
|
- `BDS.UI.CodeEditor.Highlight.highlight/3` for `:lua` colours a
|
||||||
|
`function … end` keyword with the theme's keyword colour.
|
||||||
|
- `:markdown_macros` recolours `[[gallery names="x"]]`:
|
||||||
|
- `[[`, `]]` and macro name → `{:rgb,197,134,192}` bold
|
||||||
|
- `names` → `{:rgb,156,220,254}`
|
||||||
|
- `"x"` → `{:rgb,206,145,120}`
|
||||||
|
- surrounding markdown still base-styled.
|
||||||
|
- `:liquid` recolours `{{ x }}` and `{% if x %}` over an HTML base;
|
||||||
|
`if` keyword distinct from identifiers.
|
||||||
|
- Widget emits a `%Paragraph{wrap: true, scroll:}`, never a bare `%Textarea`,
|
||||||
|
when language is one of the highlighted set.
|
||||||
|
|
||||||
|
Extended `test/bds/tui_test.exs`:
|
||||||
|
|
||||||
|
- Opening an existing post lands in the **editor** (`preview? == false`),
|
||||||
|
not the rendered preview. Replace the assertion in
|
||||||
|
`test "opening a post lands in the markdown preview, not the editor"`.
|
||||||
|
Test name/logic flips.
|
||||||
|
- Long line wraps across N visual rows in a 40-wide viewport
|
||||||
|
(assert via `CellSession.take_cells/1`).
|
||||||
|
- Cursor at the last visual row stays visible when scrolling a small
|
||||||
|
viewport (assert cursor-style cell appears in snapshot cells).
|
||||||
|
- Current line's cells have a distinct `bg` from non-current rows on
|
||||||
|
the same buffer.
|
||||||
|
- `ctrl+e` flips to preview and back; preview still rendered Markdown.
|
||||||
|
- `ctrl+s` persists textarea bytes unchanged regardless of highlight/wrap
|
||||||
|
(existing assertion still covers this — keep it green).
|
||||||
|
|
||||||
|
### Phase 2 — `lib/bds/ui/code_editor/highlight.ex` + `overlay.ex`
|
||||||
|
|
||||||
|
- `BDS.UI.CodeEditor.Highlight.highlight(content, language, theme)` →
|
||||||
|
`[%ExRatatui.Text.Line{}]`:
|
||||||
|
- `:lua`, `:text` → pass straight through to
|
||||||
|
`ExRatatui.CodeBlock.highlight/3`.
|
||||||
|
- `:markdown_macros` → base highlight as `"markdown"`, then apply
|
||||||
|
`Overlay.markdown_macros/1` per source line.
|
||||||
|
- `:liquid` → base highlight as `"html"`, then apply
|
||||||
|
`Overlay.liquid/1` per source line.
|
||||||
|
- `BDS.UI.CodeEditor.Overlay`:
|
||||||
|
- `apply_ranges(line, ranges_with_styles)` — re-splits a `Line`'s
|
||||||
|
spans into a new span list, preserving the base styling outside
|
||||||
|
each range.
|
||||||
|
- `markdown_macro_ranges/1` — `~r/\[\[\s*[a-zA-Z][\w-]*[^]]*\]\]/s`.
|
||||||
|
- `macro_inner_ranges/1` — break `attr=` (attr-name) and quoted value
|
||||||
|
into separate styled sub-ranges inside a macro.
|
||||||
|
- `liquid_ranges/1` — `~r/(\{\{.*?\}\}|\{%-?\s*(\w+).*?-?%\})/s`,
|
||||||
|
with a sub-range for the tag keyword (`if`, `for`, `assign`, …).
|
||||||
|
- Cache results by `{content_hash, language, theme}` if profiling shows
|
||||||
|
re-tokenisation overhead on keystroke (Phase 2 stretch goal —
|
||||||
|
skip until a test says otherwise).
|
||||||
|
|
||||||
|
### Phase 3 — `lib/bds/ui/code_editor/widget.ex`
|
||||||
|
|
||||||
|
- `%BDS.UI.CodeEditor{}` struct fields: `textarea`, `language`,
|
||||||
|
`theme` (default `:base16_ocean_dark`, readable in dark terminals),
|
||||||
|
`wrap: true`, `cursor_style`, `line_highlight_style`, `block`, `scroll`.
|
||||||
|
- `defimpl ExRatatui.Widget` per the pipeline above.
|
||||||
|
- Cursor cell: re-style the single codepoint at `col` on source line
|
||||||
|
`row` into `cursor_style`. Paragraph wraps the same line; the styled
|
||||||
|
cell rides the wrap.
|
||||||
|
- Scroll math: count display rows by wrapping each source line into
|
||||||
|
`width` columns (Latin-width assumption — 1 char = 1 cell). Note the
|
||||||
|
limitation explicitly; CJK width is a follow-up if/when needed.
|
||||||
|
- Line highlight: apply `line_highlight_style` (a `bg:` colour) to
|
||||||
|
every span on the cursor's source line before cursor cut-in.
|
||||||
|
|
||||||
|
### Phase 4 — Wire into `lib/bds/tui.ex`
|
||||||
|
|
||||||
|
- `build_editor/4` (around `lib/bds/tui.ex:1968`): default
|
||||||
|
`preview? = false` for existing posts. New posts already open in
|
||||||
|
editor — unchanged.
|
||||||
|
- `body_widget/3` edit branch (`lib/bds/tui.ex:1466`): emit
|
||||||
|
`%BDS.UI.CodeEditor{}` instead of `%ExRatatui.Widgets.Textarea{}` for
|
||||||
|
entities that have a syntax highlighter (`:lua`, `:markdown_macros`,
|
||||||
|
`:liquid`). Plain text can keep `%Textarea{}` or feed `:text` through
|
||||||
|
the new widget — pick whichever keeps the test surface smaller.
|
||||||
|
- Remove the "wrap limitation" comment block at
|
||||||
|
`lib/bds/tui.ex:1451-1454`.
|
||||||
|
- `cycle_language/1` continues per post; cycle through the active
|
||||||
|
set (`:markdown_macros`, `:liquid`, `:lua`, `:text`) matching the
|
||||||
|
existing GUI editor language options.
|
||||||
|
- Preview branch (`body_widget/3` preview arm at `lib/bds/tui.ex:1455`)
|
||||||
|
stays on `ExRatatui.Widgets.Markdown` — unchanged.
|
||||||
|
- No new user-facing strings: the existing body title already flows
|
||||||
|
through `dgettext("ui", …)`. If a new toolbar/hint is added later,
|
||||||
|
it MUST go through gettext and provide de/fr/it/es translations.
|
||||||
|
|
||||||
|
### Phase 5 — Verify
|
||||||
|
|
||||||
|
- `mix test` (write to `/tmp/test_run.log` first, grep for failures —
|
||||||
|
per AGENTS.md).
|
||||||
|
- `mix credo --strict`
|
||||||
|
- `mix deps.audit --ignore-file .mix_audit.ignore`
|
||||||
|
- `mix dialyzer` — treat warnings as errors.
|
||||||
|
|
||||||
|
No bundle rebuild, no asset change, no NIF rebuild. Monaco still drives
|
||||||
|
the GUI editor; this change is TUI-only.
|
||||||
|
|
||||||
|
## Risks / limits
|
||||||
|
|
||||||
|
- **Per-frame NIF cost:** re-tokenises the whole buffer on every
|
||||||
|
keystroke. For typical post sizes (<50 KB) syntect is sub-ms. Add the
|
||||||
|
`{content_hash, language, theme}` cache only if a test shows it.
|
||||||
|
- **CJK cell width:** wrap math assumes 1 char = 1 cell. Fine for
|
||||||
|
de/fr/it/es. Note as TODO; not in scope for this phase.
|
||||||
|
- **Liquid mis-lexing inside HTML** is cosmetically possible (an HTML
|
||||||
|
attribute containing `{{`). Overlay wins after the base lexer; the
|
||||||
|
result is visually tolerable. A custom `.sublime-syntax` in the
|
||||||
|
dep would be the real fix — out of scope here per "stay on Elixir".
|
||||||
|
- **Cursor in a wrapped paragraph** rides the styled cell. Equivalent
|
||||||
|
to a cell-caret; GUI uses a real caret — acceptable TUI parity.
|
||||||
|
|
||||||
|
## Files touched (expected)
|
||||||
|
|
||||||
|
- `specs/tui.allium` — edited.
|
||||||
|
- `lib/bds/ui/code_editor/highlight.ex` — new.
|
||||||
|
- `lib/bds/ui/code_editor/overlay.ex` — new.
|
||||||
|
- `lib/bds/ui/code_editor/widget.ex` — new.
|
||||||
|
- `lib/bds/tui.ex` — edited (`build_editor`, `body_widget`, comment
|
||||||
|
removal, default `preview?`).
|
||||||
|
- `test/bds/ui/code_editor_test.exs` — new.
|
||||||
|
- `test/bds/tui_test.exs` — edited (flip the "opens in preview"
|
||||||
|
assertion, add wrap/cursor/current-line tests).
|
||||||
|
|
||||||
|
No changes to `mix.exs`, no new deps, no Rust, no assets.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@import "tailwindcss" source(none);
|
||||||
|
|
||||||
|
@source "../css";
|
||||||
|
@source "../js";
|
||||||
|
@source "../../lib/bds/desktop";
|
||||||
|
@source "../../deps/live_toast/lib";
|
||||||
|
|
||||||
|
@import "./tokens.css";
|
||||||
|
@import "./shell.css";
|
||||||
|
@import "./sidebar.css";
|
||||||
|
@import "./git_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 "./misc_editor.css";
|
||||||
|
@import "./utilities.css";
|
||||||
@@ -0,0 +1,772 @@
|
|||||||
|
.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%;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-message.user {
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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: var(--vscode-button-background, var(--accent-color, #007acc));
|
||||||
|
color: var(--vscode-button-foreground, var(--vscode-list-activeSelectionForeground, #ffffff));
|
||||||
|
border: 1px solid var(--vscode-button-background, var(--accent-color, #007acc));
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Inline surfaces (<details> wrappers) ──────────────────────────── */
|
||||||
|
|
||||||
|
.chat-inline-surface {
|
||||||
|
margin: 10px 0;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--vscode-sideBar-background);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-header::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-header::marker {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-title {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-dismiss {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface:hover .chat-inline-surface-dismiss {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-dismiss:hover {
|
||||||
|
background: var(--vscode-toolbar-hoverBackground);
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-body {
|
||||||
|
padding: 0 12px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-inline-surface-body h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Chart surface ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-chart-type {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: baseline;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-meta span:first-child {
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-meta span:last-child {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-bar {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--accent-color);
|
||||||
|
min-width: 0;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stacked bars: segments sit side by side inside the track. */
|
||||||
|
.chat-surface-chart-bar-stacked {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-bar-segment {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared legend (pie/donut/stacked-bar). */
|
||||||
|
.chat-surface-chart-legend {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-legend-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-legend-swatch {
|
||||||
|
display: inline-block;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pie / donut. */
|
||||||
|
.chat-surface-chart-pie {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-pie-svg {
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-pie-slice {
|
||||||
|
stroke: var(--vscode-editor-background, #1e1e1e);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-donut-hole {
|
||||||
|
fill: var(--vscode-editor-background, #1e1e1e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-donut-total {
|
||||||
|
fill: var(--vscode-editor-foreground);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Line / area. */
|
||||||
|
.chat-surface-chart-line-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-line-grid {
|
||||||
|
stroke: rgba(255, 255, 255, 0.08);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-line-y-label,
|
||||||
|
.chat-surface-chart-line-x-label {
|
||||||
|
fill: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-line-path {
|
||||||
|
stroke: var(--accent-color);
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-area-fill {
|
||||||
|
fill: var(--accent-color);
|
||||||
|
opacity: 0.18;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-line-dot {
|
||||||
|
fill: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Heatmap. */
|
||||||
|
.chat-surface-chart-heatmap {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-heatmap-corner {
|
||||||
|
/* empty top-left cell */
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-heatmap-col-label {
|
||||||
|
text-align: center;
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-heatmap-row-label {
|
||||||
|
text-align: right;
|
||||||
|
padding-right: 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-chart-heatmap-cell {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-width: 14px;
|
||||||
|
min-height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Card surface ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-body {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-action-button {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-action-button:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metric surface ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-metric {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-metric-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-metric-value {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── List surface ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mindmap surface ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-mindmap {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-mindmap li {
|
||||||
|
padding: 4px 0;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-mindmap li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-mindmap strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-mindmap-children {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
padding-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Tabs surface ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-tab-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-tab-button {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-tab-button.active {
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
border-bottom-color: var(--accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-tab-button:hover:not(.active) {
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-tab-panel {
|
||||||
|
padding: 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Form surface ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-form-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-form-field input,
|
||||||
|
.chat-surface-form-field textarea,
|
||||||
|
.chat-surface-form-field select {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-form-field textarea {
|
||||||
|
min-height: 60px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-surface-form-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Text surface ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-surface-text {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Table surface wrapper ─────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.chat-tool-surface-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input-container {
|
||||||
|
--chat-input-line-height: 22px;
|
||||||
|
--chat-input-min-height: 24px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--vscode-sideBar-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input-wrapper {
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
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;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-input::placeholder {
|
||||||
|
color: var(--vscode-input-placeholderForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-panel .chat-send-button {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
max-width: 22px;
|
||||||
|
max-height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Colour picker popover */
|
||||||
|
.colour-picker-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-trigger {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-trigger:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
left: 0;
|
||||||
|
z-index: 30;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--vscode-dropdown-border, var(--vscode-panel-border));
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--vscode-dropdown-background, var(--vscode-sideBar-background));
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
width: 196px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(6, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-swatch {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
transition: border-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-swatch:hover {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-swatch.selected {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
box-shadow: 0 0 0 1px var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-custom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-custom label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.colour-picker-custom input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
@@ -0,0 +1,931 @@
|
|||||||
|
.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 {
|
||||||
|
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 {
|
||||||
|
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-section-label {
|
||||||
|
padding: 4px 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--vscode-descriptionForeground, #a6a6a6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .tag-suggestion.create-new {
|
||||||
|
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: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.post-editor .editor-toolbar-center {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ponytail: right group keeps its content width so buttons never wrap or
|
||||||
|
overlap the mode toggle; the left label shrinks instead */
|
||||||
|
.post-editor .editor-toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 0 auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 .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 {
|
||||||
|
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 .ui-editor-actions,
|
||||||
|
.scripts-view-shell .ui-editor-actions,
|
||||||
|
.templates-view-shell .ui-editor-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
/* ── Git sidebar ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-header {
|
||||||
|
padding: 8px 12px 12px;
|
||||||
|
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-branch {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-branch-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-upstream {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--vscode-badge-background);
|
||||||
|
color: var(--vscode-badge-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-ahead {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-testing-iconPassed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-behind {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-notificationsInfoIcon-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sync legend ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-legend-item {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sync-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sync-synced {
|
||||||
|
background-color: var(--vscode-testing-iconPassed);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sync-local_only {
|
||||||
|
background-color: var(--vscode-editorWarning-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-sync-remote_only {
|
||||||
|
background-color: var(--vscode-notificationsInfoIcon-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Actions ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-actions {
|
||||||
|
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-action-button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
background-color: var(--vscode-button-secondaryBackground);
|
||||||
|
color: var(--vscode-button-secondaryForeground);
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-action-button:hover:not(:disabled) {
|
||||||
|
background-color: var(--vscode-button-secondaryHoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-action-button:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Sections ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-section {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-section-count {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Forms ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-commit-form,
|
||||||
|
.git-init-form {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--vscode-sideBar-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-commit-form input,
|
||||||
|
.git-init-form input {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-input-border);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-commit-form input::placeholder,
|
||||||
|
.git-init-form input::placeholder {
|
||||||
|
color: var(--vscode-input-placeholderForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-commit-form input:focus,
|
||||||
|
.git-init-form input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-commit-form .git-action-button,
|
||||||
|
.git-init-form .git-action-button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status file list ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-status-file {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-file:hover {
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-path {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-added {
|
||||||
|
color: var(--vscode-testing-iconPassed);
|
||||||
|
background: color-mix(in srgb, var(--vscode-testing-iconPassed) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-modified {
|
||||||
|
color: var(--vscode-editorWarning-foreground);
|
||||||
|
background: color-mix(in srgb, var(--vscode-editorWarning-foreground) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-deleted {
|
||||||
|
color: var(--vscode-errorForeground);
|
||||||
|
background: color-mix(in srgb, var(--vscode-errorForeground) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-renamed {
|
||||||
|
color: var(--vscode-notificationsInfoIcon-foreground);
|
||||||
|
background: color-mix(in srgb, var(--vscode-notificationsInfoIcon-foreground) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-status-untracked {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
background: color-mix(in srgb, var(--vscode-descriptionForeground) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Commit history list ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-history-entry {
|
||||||
|
width: 100%;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-history-entry:hover {
|
||||||
|
background-color: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-history-subject {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-sideBar-foreground);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-history-meta {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-history-hash,
|
||||||
|
.git-history-author,
|
||||||
|
.git-history-date {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-history-hash {
|
||||||
|
font-family: var(--vscode-editor-font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-history-more {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty / Not-a-repo state ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-not-a-repo {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-empty-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Divider between sections ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-section + .git-section {
|
||||||
|
border-top: 1px solid var(--vscode-sideBar-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section header that matches sidebar-section-header ───────────────── */
|
||||||
|
|
||||||
|
.git-header + .git-actions {
|
||||||
|
border-top: 1px solid var(--vscode-sideBar-border);
|
||||||
|
}
|
||||||
@@ -0,0 +1,671 @@
|
|||||||
|
.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 select {
|
||||||
|
border: 1px solid var(--vscode-button-border, transparent);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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, #0e639c);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
[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 {
|
||||||
|
max-width: 100%;
|
||||||
|
max-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"] .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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,544 @@
|
|||||||
|
/* ── Misc-editor shell (shared by all misc tabs) ──────────────────────── */
|
||||||
|
|
||||||
|
.misc-editor-shell {
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-header {
|
||||||
|
padding: 12px 16px 8px;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
background: var(--vscode-tab-activeBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-header p {
|
||||||
|
margin: 2px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-summary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-editor-content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Summary pills ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.misc-summary-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-summary-pill span {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-summary-pill strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Misc card (used by site-validation, empty states) ───────────────── */
|
||||||
|
|
||||||
|
.misc-card {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-card h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-card p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-card ul {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Misc columns (site-validation 3-column layout) ──────────────────── */
|
||||||
|
|
||||||
|
.misc-columns {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Misc list (find-duplicates) ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.misc-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.misc-list-item:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-pair-row label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-pair-row .linkish {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--vscode-textLink-foreground, #3794ff);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
font: inherit;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 0.14em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-pair-row .linkish:hover {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metadata-diff: tab bar ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.metadata-diff-tool {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-tab-inactiveForeground, var(--vscode-descriptionForeground));
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-tab:hover {
|
||||||
|
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-tab.active {
|
||||||
|
color: var(--vscode-tab-activeForeground, var(--vscode-foreground));
|
||||||
|
border-bottom-color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--vscode-activityBarBadge-background, #007acc);
|
||||||
|
color: var(--vscode-activityBarBadge-foreground, #ffffff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metadata-diff: field pills ──────────────────────────────────────── */
|
||||||
|
|
||||||
|
.metadata-diff-field-pills {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: border-color 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill.active {
|
||||||
|
border-color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 12%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill-toggle:hover {
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-pill-label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-pill-count {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-field-pill-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-left: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-action-button {
|
||||||
|
font-size: 11px !important;
|
||||||
|
padding: 2px 8px !important;
|
||||||
|
min-height: 22px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Metadata-diff: results area ─────────────────────────────────────── */
|
||||||
|
|
||||||
|
.metadata-diff-results {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metadata-diff-empty p {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Diff item cards (shared by metadata-diff and orphan sections) ──── */
|
||||||
|
|
||||||
|
.diff-item-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-card {
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-card.orphan-file {
|
||||||
|
border-left: 3px solid var(--vscode-editorWarning-foreground, #cca700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-sideBar-background) 50%, transparent);
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-header strong {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-meta {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-item-fields {
|
||||||
|
padding: 8px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 120px 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 5px 0;
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--vscode-panel-border) 50%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-name {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-values {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-value.db-value {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-field-value.file-value {
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diff-source-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 28px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-value .diff-source-label {
|
||||||
|
background: color-mix(in srgb, var(--vscode-focusBorder, #007fd4) 22%, transparent);
|
||||||
|
color: var(--vscode-focusBorder, #007fd4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-value .diff-source-label {
|
||||||
|
background: color-mix(in srgb, var(--vscode-testing-iconPassed, #73c991) 22%, transparent);
|
||||||
|
color: var(--vscode-testing-iconPassed, #73c991);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Orphan files section ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.orphan-files-section {
|
||||||
|
border: 1px solid color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 35%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editorWarning-foreground, #cca700) 5%, var(--vscode-editor-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-files-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-files-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-files-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphan-path span {
|
||||||
|
font-family: "SFMono-Regular", Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Translation validation ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.translation-validation-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-summary {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-summary p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-section h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-empty {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 84%, var(--vscode-input-background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-db {
|
||||||
|
border-left: 3px solid var(--vscode-focusBorder, #007fd4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-file {
|
||||||
|
border-left: 3px solid var(--vscode-testing-iconPassed, #73c991);
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 3px 12px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-meta dt {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-card-meta dd {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.translation-validation-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Git diff ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.git-diff-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-empty {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-toolbar label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-toolbar select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-diff-editor {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monaco-diff-editor-instance {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
.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,
|
||||||
|
.git-run-backdrop,
|
||||||
|
.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,
|
||||||
|
.git-run-modal,
|
||||||
|
.gallery-overlay-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
background: #1e1e1e;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-modal {
|
||||||
|
width: min(760px, calc(100vw - 32px));
|
||||||
|
max-height: calc(100vh - 48px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-bottom: 1px solid #3c3c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-status {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-status.running {
|
||||||
|
color: #d7ba7d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-status.ok {
|
||||||
|
color: #4ec9b0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-status.fail {
|
||||||
|
color: #f48771;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-output {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
overflow: auto;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 160px;
|
||||||
|
max-height: 60vh;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
background: #141414;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-stdout {
|
||||||
|
color: #d4d4d4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-stderr {
|
||||||
|
color: #f48771;
|
||||||
|
}
|
||||||
|
|
||||||
|
.git-run-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid #3c3c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-suggestions-modal,
|
||||||
|
.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 {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border-top: 1px solid #3c3c3c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-picker-row,
|
||||||
|
.shared-popover-entry,
|
||||||
|
.colour-swatch {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-results {
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-item:hover {
|
||||||
|
background: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-title {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-result-meta {
|
||||||
|
color: #9d9d9d;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-status {
|
||||||
|
padding: 16px 20px;
|
||||||
|
color: #9d9d9d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-external {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-external .insert-modal-input {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid #3c3c3c;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #252526;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-label {
|
||||||
|
color: #9d9d9d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.insert-modal-submit {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Alert modal (errors) — reuses .confirm-delete-modal with a tone accent. */
|
||||||
|
.alert-modal-error {
|
||||||
|
border-top: 3px solid #ff6b6b;
|
||||||
|
}
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
.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: 2px;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-sidebar-start-button:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .panel-entry (shell.css) forces flex-direction: column and, being unlayered,
|
||||||
|
beats the Tailwind row utilities on this button — force the row here. */
|
||||||
|
.task-group-toggle {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.output-entry {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,773 @@
|
|||||||
|
.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-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 {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-message-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-item.theme-badge {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar-item.offline-badge.active {
|
||||||
|
background-color: #e6a800;
|
||||||
|
color: #000;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-meta-card,
|
||||||
|
.panel-entry {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
@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-button-secondaryBackground: rgba(255, 255, 255, 0.08);
|
||||||
|
--vscode-button-secondaryForeground: #cccccc;
|
||||||
|
--vscode-button-secondaryHoverBackground: #4a4d51;
|
||||||
|
--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 {
|
||||||
|
color: var(--vscode-button-foreground, #ffffff);
|
||||||
|
background-color: var(--vscode-button-background, #0e639c);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary:hover {
|
||||||
|
background-color: var(--vscode-button-hoverBackground, #1177bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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-";
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
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";
|
||||||
|
import { copyTextToClipboard } from "../utils/clipboard.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.handleEvent("clipboard", ({ text }) => {
|
||||||
|
copyTextToClipboard(text || "");
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
// Rehydration is done (any stored-session push is ordered before this):
|
||||||
|
// tell the server it may now deliver queued deep links.
|
||||||
|
this.pushEvent("shell_ready", {});
|
||||||
|
},
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
export const ChatSurface = {
|
||||||
|
mounted() {
|
||||||
|
this.stickToBottom = true;
|
||||||
|
this.scrollContainer = null;
|
||||||
|
|
||||||
|
this._enterKeyHandled = false;
|
||||||
|
this._prevInputValue = "";
|
||||||
|
|
||||||
|
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._submitChat = () => {
|
||||||
|
const form = this.el.querySelector(".chat-input-wrapper");
|
||||||
|
if (form && typeof form.requestSubmit === "function") {
|
||||||
|
form.requestSubmit();
|
||||||
|
} else {
|
||||||
|
const sendButton = this.el.querySelector("[data-testid='chat-send-button']");
|
||||||
|
if (sendButton) sendButton.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleInput = (event) => {
|
||||||
|
if (!event.target.closest(".chat-input")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textarea = event.target;
|
||||||
|
|
||||||
|
if (!this._enterKeyHandled && textarea.value.includes("\n") && !this._prevInputValue.includes("\n")) {
|
||||||
|
textarea.value = textarea.value.replace(/\n/g, "");
|
||||||
|
this._prevInputValue = textarea.value;
|
||||||
|
this.stickToBottom = true;
|
||||||
|
this.autoResize();
|
||||||
|
this._submitChat();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this._enterKeyHandled = false;
|
||||||
|
this._prevInputValue = textarea.value;
|
||||||
|
this.stickToBottom = true;
|
||||||
|
this.autoResize();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleKeyDown = (event) => {
|
||||||
|
if (!event.target.closest(".chat-input")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
|
||||||
|
event.preventDefault();
|
||||||
|
this._enterKeyHandled = true;
|
||||||
|
this._submitChat();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export const ColourPicker = {
|
||||||
|
mounted() {
|
||||||
|
this._onClickAway = (e) => {
|
||||||
|
if (!this.el.contains(e.target)) {
|
||||||
|
this.el.querySelector(".colour-picker-popover")?.classList.add("hidden");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", this._onClickAway);
|
||||||
|
|
||||||
|
this._setupCustomInput();
|
||||||
|
},
|
||||||
|
|
||||||
|
updated() {
|
||||||
|
this._setupCustomInput();
|
||||||
|
},
|
||||||
|
|
||||||
|
destroyed() {
|
||||||
|
document.removeEventListener("mousedown", this._onClickAway);
|
||||||
|
},
|
||||||
|
|
||||||
|
_setupCustomInput() {
|
||||||
|
const input = this.el.querySelector(".colour-picker-custom input");
|
||||||
|
if (!input || input._cpBound) return;
|
||||||
|
input._cpBound = true;
|
||||||
|
|
||||||
|
const pushColor = () => {
|
||||||
|
let val = input.value.trim();
|
||||||
|
if (val && !val.startsWith("#")) val = "#" + val;
|
||||||
|
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
|
||||||
|
const event = this.el.dataset.pickEvent;
|
||||||
|
this.pushEventTo(this.el.dataset.target, event, { color: val });
|
||||||
|
this.el.querySelector(".colour-picker-popover")?.classList.add("hidden");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
input.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
pushColor();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener("blur", pushColor);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { AppShell } from "./app_shell.js";
|
||||||
|
import { SidebarInteractions } from "./sidebar_interactions.js";
|
||||||
|
import { SettingsSectionScroll, TagsSectionScroll } from "./section_scroll.js";
|
||||||
|
import { ChatSurface } from "./chat_surface.js";
|
||||||
|
import { ColourPicker } from "./colour_picker.js";
|
||||||
|
import { MenuEditorTree } from "./menu_editor_tree.js";
|
||||||
|
import { MonacoEditor } from "./monaco_editor.js";
|
||||||
|
import { MonacoDiffEditor } from "./monaco_diff_editor.js";
|
||||||
|
import { createLiveToastHook } from "live_toast";
|
||||||
|
|
||||||
|
export const Hooks = {
|
||||||
|
LiveToast: createLiveToastHook(),
|
||||||
|
AppShell,
|
||||||
|
SidebarInteractions,
|
||||||
|
SettingsSectionScroll,
|
||||||
|
TagsSectionScroll,
|
||||||
|
ChatSurface,
|
||||||
|
ColourPicker,
|
||||||
|
MenuEditorTree,
|
||||||
|
MonacoEditor,
|
||||||
|
MonacoDiffEditor
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
import {
|
||||||
|
loadMonaco,
|
||||||
|
ensureMonacoTheme,
|
||||||
|
registerMonacoEditor,
|
||||||
|
unregisterMonacoEditor
|
||||||
|
} from "../monaco/services.js";
|
||||||
|
|
||||||
|
const WEBKIT_TEXTAREA_ARROW_COMMANDS = {
|
||||||
|
ArrowLeft: "cursorLeft",
|
||||||
|
ArrowRight: "cursorRight",
|
||||||
|
ArrowUp: "cursorUp",
|
||||||
|
ArrowDown: "cursorDown"
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWebKitEngine = () => {
|
||||||
|
const userAgent = globalThis.navigator?.userAgent || "";
|
||||||
|
return userAgent.includes("AppleWebKit") && !userAgent.includes("Chrome") && !userAgent.includes("Chromium");
|
||||||
|
};
|
||||||
|
|
||||||
|
const isMonacoInputArea = (target) =>
|
||||||
|
target instanceof HTMLTextAreaElement && target.classList.contains("inputarea");
|
||||||
|
|
||||||
|
export const webKitTextAreaArrowCommand = (event) => {
|
||||||
|
if (event?.altKey || event?.ctrlKey || event?.metaKey) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = WEBKIT_TEXTAREA_ARROW_COMMANDS[event?.key];
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return event.shiftKey ? `${command}Select` : command;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Applying a remote value via setValue resets the cursor to the buffer start,
|
||||||
|
// which then makes follow-up actions (like link inserts targeting the current
|
||||||
|
// selection) land at position 1,1. Capture and restore the selection so the
|
||||||
|
// cursor survives server-driven reconciles; Monaco clamps out-of-range
|
||||||
|
// positions to the new content.
|
||||||
|
export const applyRemoteEditorValue = (editor, value) => {
|
||||||
|
const selections = editor.getSelections ? editor.getSelections() : null;
|
||||||
|
editor.setValue(value);
|
||||||
|
|
||||||
|
if (selections && selections.length > 0 && editor.setSelections) {
|
||||||
|
editor.setSelections(selections);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const bridgeWebKitTextAreaArrowKey = (editor, event) => {
|
||||||
|
if (!editor || !isMonacoInputArea(event.target)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const command = webKitTextAreaArrowCommand(event);
|
||||||
|
|
||||||
|
if (!command) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
editor.trigger("keyboard", command, { source: "keyboard" });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MonacoEditor = {
|
||||||
|
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.handleWebKitTextAreaArrowKey = (event) => {
|
||||||
|
bridgeWebKitTextAreaArrowKey(this.editor, event);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 || "";
|
||||||
|
|
||||||
|
// ponytail: ignore the server echoing back our own last-emitted value;
|
||||||
|
// only a genuinely remote value (translation switch, etc.) should reset
|
||||||
|
// the editor. Otherwise mid-typing echoes clobber the cursor to offset 0.
|
||||||
|
if (value === this.lastKnownValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// While the user is actively typing, Monaco owns the content. Never let a
|
||||||
|
// server re-render (autosave, dirty toggle, metadata) yank it out from
|
||||||
|
// under the cursor — reconcile only once the editor is blurred.
|
||||||
|
if (this.editor.hasTextFocus()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.editor.getValue() !== value) {
|
||||||
|
this.isApplyingRemoteUpdate = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
applyRemoteEditorValue(this.editor, value);
|
||||||
|
} finally {
|
||||||
|
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.dropEvent = this.el.dataset.monacoDropEvent || "";
|
||||||
|
this.dropPostId = this.el.dataset.monacoDropPostId || "";
|
||||||
|
|
||||||
|
this.handleDragOver = (event) => {
|
||||||
|
if (event.dataTransfer && Array.from(event.dataTransfer.types || []).includes("Files")) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = "copy";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleDrop = (event) => {
|
||||||
|
if (!this.dropEvent || !event.dataTransfer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = Array.from(event.dataTransfer.files || []);
|
||||||
|
const images = files.filter((file) => (file.type || "").startsWith("image/") && file.path);
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
images.forEach((file) => {
|
||||||
|
this.pushEvent(this.dropEvent, { "post-id": this.dropPostId, path: file.path });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
this.handleInsert = ({ id, content }) => {
|
||||||
|
if (!this.editor || !content || String(id) !== String(this.editorId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dropEvent) {
|
||||||
|
this.el.addEventListener("dragover", this.handleDragOver);
|
||||||
|
this.el.addEventListener("drop", this.handleDrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWebKitEngine()) {
|
||||||
|
this.host.addEventListener("keydown", this.handleWebKitTextAreaArrowKey, true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (this.dropEvent) {
|
||||||
|
this.el.removeEventListener("dragover", this.handleDragOver);
|
||||||
|
this.el.removeEventListener("drop", this.handleDrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.host?.removeEventListener("keydown", this.handleWebKitTextAreaArrowKey, true);
|
||||||
|
|
||||||
|
unregisterMonacoEditor(this.editorId || this.el.id);
|
||||||
|
this.editor?.dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { ensureMonacoTheme } from "./theme.js";
|
||||||
|
import { registerLiquidLanguage, registerMarkdownWithMacrosLanguage } from "./languages.js";
|
||||||
|
|
||||||
|
let monacoLoaderPromise;
|
||||||
|
const monacoEditors = new Map();
|
||||||
|
|
||||||
|
const monacoWorkerUrls = {
|
||||||
|
editor: "/assets/monaco/editor.worker.js",
|
||||||
|
css: "/assets/monaco/css.worker.js",
|
||||||
|
html: "/assets/monaco/html.worker.js",
|
||||||
|
json: "/assets/monaco/json.worker.js",
|
||||||
|
ts: "/assets/monaco/ts.worker.js"
|
||||||
|
};
|
||||||
|
|
||||||
|
const workerNameForLanguage = (label) => {
|
||||||
|
switch (label) {
|
||||||
|
case "css":
|
||||||
|
case "scss":
|
||||||
|
case "less":
|
||||||
|
return "css";
|
||||||
|
case "html":
|
||||||
|
case "handlebars":
|
||||||
|
case "razor":
|
||||||
|
return "html";
|
||||||
|
case "json":
|
||||||
|
return "json";
|
||||||
|
case "typescript":
|
||||||
|
case "javascript":
|
||||||
|
return "ts";
|
||||||
|
default:
|
||||||
|
return "editor";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureMonacoEnvironment = () => {
|
||||||
|
if (globalThis.MonacoEnvironment?.getWorker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
globalThis.MonacoEnvironment = {
|
||||||
|
...globalThis.MonacoEnvironment,
|
||||||
|
getWorker(_workerId, label) {
|
||||||
|
const workerName = workerNameForLanguage(label);
|
||||||
|
return new Worker(monacoWorkerUrls[workerName], { name: label, type: "module" });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const loadMonaco = () => {
|
||||||
|
ensureMonacoEnvironment();
|
||||||
|
|
||||||
|
if (window.monaco?.editor) {
|
||||||
|
ensureMonacoTheme(window.monaco);
|
||||||
|
registerLiquidLanguage(window.monaco);
|
||||||
|
registerMarkdownWithMacrosLanguage(window.monaco);
|
||||||
|
return Promise.resolve(window.monaco);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (monacoLoaderPromise) {
|
||||||
|
return monacoLoaderPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
monacoLoaderPromise = import("/assets/monaco.js")
|
||||||
|
.then((monaco) => {
|
||||||
|
window.monaco = monaco;
|
||||||
|
if (!monaco.editor) throw new Error("Monaco loaded but monaco.editor is missing");
|
||||||
|
ensureMonacoTheme(monaco);
|
||||||
|
registerLiquidLanguage(monaco);
|
||||||
|
registerMarkdownWithMacrosLanguage(monaco);
|
||||||
|
return monaco;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Monaco load failed:", 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 };
|
||||||
@@ -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");
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// ESM entry point for Monaco. Bundled by esbuild into priv/static/assets/monaco.js
|
||||||
|
// editor.main.js loads the API AND registers all bundled languages (api.js only exposes the API)
|
||||||
|
export * from "monaco-editor/esm/vs/editor/editor.main.js";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/language/css/css.worker.js";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/editor/editor.worker.js";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/language/html/html.worker.js";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/language/json/json.worker.js";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import "monaco-editor/esm/vs/language/typescript/ts.worker.js";
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
// Copies text to the system clipboard, mirroring the old app's behavior:
|
||||||
|
// navigator.clipboard when available, with a hidden-textarea fallback for
|
||||||
|
// webviews that lack the async clipboard API.
|
||||||
|
export const copyTextToClipboard = async (text) => {
|
||||||
|
if (typeof navigator !== "undefined" && navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof document === "undefined" || typeof document.createElement !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.setAttribute("readonly", "");
|
||||||
|
textArea.style.position = "absolute";
|
||||||
|
textArea.style.left = "-9999px";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
if (typeof document.execCommand === "function") {
|
||||||
|
document.execCommand("copy");
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
};
|
||||||
@@ -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("")}`;
|
||||||
|
};
|
||||||
@@ -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));
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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 [];
|
||||||
|
}
|
||||||
|
};
|
||||||
+101
-6
@@ -4,8 +4,11 @@ config :bds,
|
|||||||
ecto_repos: [BDS.Repo]
|
ecto_repos: [BDS.Repo]
|
||||||
|
|
||||||
config :bds, BDS.Repo,
|
config :bds, BDS.Repo,
|
||||||
database: Path.expand("../priv/data/bds_dev.db", __DIR__),
|
database:
|
||||||
|
System.get_env("BDS_DATABASE_PATH") ||
|
||||||
|
Path.expand("~/Library/Application Support/BDS2/bds_dev.db"),
|
||||||
pool_size: 5,
|
pool_size: 5,
|
||||||
|
journal_mode: :wal,
|
||||||
busy_timeout: 15_000,
|
busy_timeout: 15_000,
|
||||||
log: false,
|
log: false,
|
||||||
stacktrace: true,
|
stacktrace: true,
|
||||||
@@ -13,31 +16,123 @@ config :bds, BDS.Repo,
|
|||||||
|
|
||||||
config :bds, BDS.Application, desktop_adapter: :desktop
|
config :bds, BDS.Application, desktop_adapter: :desktop
|
||||||
|
|
||||||
|
# No secrets live in this file: the endpoint signing secret is generated per
|
||||||
|
# boot (BDS.Application) and the AI secret master key comes from the OS
|
||||||
|
# keyring (BDS.AI.SecretKey).
|
||||||
config :bds, :desktop,
|
config :bds, :desktop,
|
||||||
port: 4010,
|
port: 4010,
|
||||||
window_size: {1280, 780},
|
window_size: {1280, 780},
|
||||||
window_min_size: {800, 600},
|
window_min_size: {800, 600},
|
||||||
title: "Blogging Desktop Server",
|
title: "Blogging Desktop Server"
|
||||||
secret_key_base: "bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001"
|
|
||||||
|
config :bds, :server, ssh_port: 2222
|
||||||
|
|
||||||
config :bds, BDS.Desktop.Endpoint,
|
config :bds, BDS.Desktop.Endpoint,
|
||||||
url: [host: "127.0.0.1"],
|
url: [host: "127.0.0.1"],
|
||||||
|
# Any-port loopback origins: the remote-GUI SSH tunnel terminates on an
|
||||||
|
# ephemeral local port, so the websocket origin port varies per connect.
|
||||||
|
check_origin: ["//127.0.0.1", "//localhost"],
|
||||||
adapter: Bandit.PhoenixAdapter,
|
adapter: Bandit.PhoenixAdapter,
|
||||||
render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false],
|
render_errors: [formats: [html: BDS.Desktop.ErrorHTML], layout: false],
|
||||||
pubsub_server: BDS.PubSub,
|
pubsub_server: BDS.PubSub,
|
||||||
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__)}
|
||||||
|
],
|
||||||
|
monaco: [
|
||||||
|
cd: Path.expand("../assets", __DIR__),
|
||||||
|
args: ~w(
|
||||||
|
js/monaco_entry.js
|
||||||
|
--bundle
|
||||||
|
--target=es2022
|
||||||
|
--format=esm
|
||||||
|
--outfile=../priv/static/assets/monaco.js
|
||||||
|
--loader:.ttf=dataurl
|
||||||
|
),
|
||||||
|
env: %{"NODE_PATH" => Path.expand("../node_modules", __DIR__)}
|
||||||
|
],
|
||||||
|
monaco_workers: [
|
||||||
|
cd: Path.expand("../assets", __DIR__),
|
||||||
|
args: ~w(
|
||||||
|
js/monaco_workers/editor.worker.js
|
||||||
|
js/monaco_workers/css.worker.js
|
||||||
|
js/monaco_workers/html.worker.js
|
||||||
|
js/monaco_workers/json.worker.js
|
||||||
|
js/monaco_workers/ts.worker.js
|
||||||
|
--bundle
|
||||||
|
--target=es2022
|
||||||
|
--format=esm
|
||||||
|
--outdir=../priv/static/assets/monaco
|
||||||
|
--loader:.ttf=dataurl
|
||||||
|
),
|
||||||
|
env: %{"NODE_PATH" => Path.expand("../node_modules", __DIR__)}
|
||||||
|
]
|
||||||
|
|
||||||
config :bds, :scripting,
|
config :bds, :scripting,
|
||||||
runtime: BDS.Scripting.Lua,
|
runtime: BDS.Scripting.Lua,
|
||||||
timeout: 300_000,
|
timeout: 300_000,
|
||||||
max_reductions: 5_000_000,
|
max_reductions: 5_000_000,
|
||||||
job_timeout: :infinity,
|
job_timeout: :infinity,
|
||||||
job_max_reductions: :none
|
job_max_reductions: :none,
|
||||||
|
transform_max_toasts_per_script: 5,
|
||||||
|
transform_max_toasts_total: 20,
|
||||||
|
transform_max_toast_length: 300
|
||||||
|
|
||||||
|
# streaming: chat completions use SSE when the provider supports it (set to
|
||||||
|
# false for OpenAI-compatible servers that reject the "stream" flag).
|
||||||
|
# stream_emit_interval_ms throttles how often streamed content reaches the UI.
|
||||||
|
# await_timeout_margin_ms is added on top of the per-request HTTP budget across
|
||||||
|
# the bounded tool-call loop, so the caller never waits forever.
|
||||||
|
config :bds, :chat,
|
||||||
|
max_tool_rounds: 10,
|
||||||
|
streaming: true,
|
||||||
|
stream_emit_interval_ms: 100,
|
||||||
|
await_timeout_margin_ms: 5_000
|
||||||
|
|
||||||
|
config :bds, :git,
|
||||||
|
local_timeout_ms: 15_000,
|
||||||
|
network_timeout_ms: 120_000
|
||||||
|
|
||||||
config :bds, :embeddings,
|
config :bds, :embeddings,
|
||||||
backend: BDS.Embeddings.Backends.InApp,
|
backend: BDS.Embeddings.Backends.Neural,
|
||||||
model_id: "Xenova/multilingual-e5-small",
|
model_id: "Xenova/multilingual-e5-small",
|
||||||
dimensions: 384
|
model_repo: "intfloat/multilingual-e5-small",
|
||||||
|
dimensions: 384,
|
||||||
|
# Inference is batched: batch_size texts per compiled run, truncated to
|
||||||
|
# sequence_length tokens. Tuning these trades throughput against memory.
|
||||||
|
batch_size: 16,
|
||||||
|
sequence_length: 256,
|
||||||
|
# Hardware acceleration: :auto prefers the Apple GPU (EMLX/Metal) on Apple
|
||||||
|
# Silicon and falls back to EXLA-CPU elsewhere. Force with :emlx or :exla.
|
||||||
|
accelerator: :auto
|
||||||
|
|
||||||
|
# Cache downloaded model files under the app data directory so they persist
|
||||||
|
# across sessions (ModelCaching invariant). Overridden at runtime in prod.
|
||||||
|
config :bumblebee, :cache_dir,
|
||||||
|
System.get_env("BDS_MODEL_CACHE_DIR") ||
|
||||||
|
Path.expand("~/Library/Application Support/BDS2/models")
|
||||||
|
|
||||||
config :logger, :console,
|
config :logger, :console,
|
||||||
format: "$time $metadata[$level] $message\n",
|
format: "$time $metadata[$level] $message\n",
|
||||||
|
|||||||
@@ -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)]}
|
||||||
|
]
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import Config
|
import Config
|
||||||
|
|
||||||
config :bds, BDS.Repo,
|
config :bds, BDS.Repo,
|
||||||
database: Path.expand("../priv/data/bds_prod.db", __DIR__),
|
|
||||||
pool_size: 5,
|
pool_size: 5,
|
||||||
stacktrace: false,
|
stacktrace: false,
|
||||||
show_sensitive_data_on_connection_error: false
|
show_sensitive_data_on_connection_error: false
|
||||||
|
|||||||
+21
-2
@@ -1,11 +1,30 @@
|
|||||||
import Config
|
import Config
|
||||||
|
|
||||||
|
# Headless boots (BDS_MODE=server/tui, or desktop mode without a graphical
|
||||||
|
# display — issue #33) must neuter wx before the :desktop dependency
|
||||||
|
# application starts it and crashes the VM. Local tui mode additionally
|
||||||
|
# silences all other terminal writers (console logging goes to a file,
|
||||||
|
# model-download progress bars are disabled) — the TUI owns the terminal
|
||||||
|
# and every stray line scrolls its rendering up one row. Runtime config is
|
||||||
|
# the only hook that runs before dependency applications start.
|
||||||
|
BDS.Server.prepare_boot_env()
|
||||||
|
|
||||||
if config_env() == :prod do
|
if config_env() == :prod do
|
||||||
database_path =
|
database_path =
|
||||||
System.get_env("BDS_DATABASE_PATH") ||
|
System.get_env("BDS_DATABASE_PATH") ||
|
||||||
Path.expand("../priv/data/bds_prod.db", __DIR__)
|
Path.expand("~/Library/Application Support/BDS2/bds.db")
|
||||||
|
|
||||||
|
File.mkdir_p!(Path.dirname(database_path))
|
||||||
|
|
||||||
|
# Keep prod on the same modest SQLite pool as dev so WAL + busy_timeout see
|
||||||
|
# the same concurrency behavior in both environments unless explicitly tuned.
|
||||||
config :bds, BDS.Repo,
|
config :bds, BDS.Repo,
|
||||||
database: database_path,
|
database: database_path,
|
||||||
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "1")
|
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
|
||||||
|
|
||||||
|
# Persist downloaded embedding model files alongside the database data dir.
|
||||||
|
config :bumblebee,
|
||||||
|
:cache_dir,
|
||||||
|
System.get_env("BDS_MODEL_CACHE_DIR") ||
|
||||||
|
Path.join(Path.dirname(Path.expand(database_path)), "models")
|
||||||
end
|
end
|
||||||
+20
-1
@@ -3,6 +3,25 @@ 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,
|
||||||
|
# Desktop/LiveView tests can leave short-lived reader/writer processes
|
||||||
|
# draining after assertions. Give SQLite more time to wait out those locks
|
||||||
|
# during serialized suite runs instead of failing the next test setup.
|
||||||
|
busy_timeout: 60_000
|
||||||
|
|
||||||
config :logger, level: :warning
|
config :logger, level: :warning
|
||||||
|
|
||||||
|
# Deterministic, test-only master key so secret round-trips never touch the
|
||||||
|
# OS keyring (Keychain) or write key files on developer machines.
|
||||||
|
config :bds, :ai_secret_key, "bds-test-only-ai-secret-key-not-used-outside-the-test-env"
|
||||||
|
|
||||||
|
# Tests use the deterministic lexical stub backend so the suite stays offline
|
||||||
|
# and never downloads the ~100 MB neural model.
|
||||||
|
config :bds, :embeddings,
|
||||||
|
backend: BDS.Embeddings.Backends.InApp,
|
||||||
|
model_id: "Xenova/multilingual-e5-small",
|
||||||
|
model_repo: "intfloat/multilingual-e5-small",
|
||||||
|
dimensions: 384,
|
||||||
|
batch_size: 16,
|
||||||
|
sequence_length: 256
|
||||||
+140
-1239
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,330 @@
|
|||||||
|
defmodule BDS.AI.Catalog do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
import BDS.AI.SettingsStore,
|
||||||
|
only: [
|
||||||
|
get_setting: 1,
|
||||||
|
put_setting: 2,
|
||||||
|
get_catalog_meta_value: 1,
|
||||||
|
put_catalog_meta: 2
|
||||||
|
]
|
||||||
|
|
||||||
|
alias BDS.AI.CatalogProvider
|
||||||
|
alias BDS.AI.Model
|
||||||
|
alias BDS.AI.ModelModality
|
||||||
|
alias BDS.AI.OpenAICompatibleRuntime
|
||||||
|
alias BDS.MapUtils
|
||||||
|
alias BDS.Persistence
|
||||||
|
alias BDS.Repo
|
||||||
|
|
||||||
|
@catalog_url "https://models.dev/api.json"
|
||||||
|
|
||||||
|
@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
|
||||||
|
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)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec refresh_model_catalog(keyword()) ::
|
||||||
|
{:ok, %{success: boolean(), models_updated: non_neg_integer(), not_modified: boolean()}}
|
||||||
|
| {:error, term()}
|
||||||
|
def refresh_model_catalog(opts \\ []) when is_list(opts) do
|
||||||
|
http_client = Keyword.get(opts, :http_client, BDS.AI.HttpClient)
|
||||||
|
|
||||||
|
headers =
|
||||||
|
%{"accept" => "application/json"}
|
||||||
|
|> maybe_put_header("if-none-match", get_catalog_meta_value("etag"))
|
||||||
|
|
||||||
|
with {:ok, response} <- http_get(http_client, @catalog_url, headers) do
|
||||||
|
case response.status do
|
||||||
|
304 ->
|
||||||
|
:ok = put_catalog_meta("last_fetched_at", DateTime.utc_now() |> DateTime.to_iso8601())
|
||||||
|
{:ok, %{success: true, models_updated: 0, not_modified: true}}
|
||||||
|
|
||||||
|
200 ->
|
||||||
|
payload = Jason.decode!(response.body)
|
||||||
|
models_updated = persist_catalog(payload)
|
||||||
|
|
||||||
|
if etag = response.headers["etag"] do
|
||||||
|
:ok = put_catalog_meta("etag", etag)
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok = put_catalog_meta("last_fetched_at", DateTime.utc_now() |> DateTime.to_iso8601())
|
||||||
|
|
||||||
|
{:ok, %{success: true, models_updated: models_updated, not_modified: false}}
|
||||||
|
|
||||||
|
status ->
|
||||||
|
{:error, %{kind: :http_error, status: status}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec list_catalog_providers() :: [map()]
|
||||||
|
def list_catalog_providers do
|
||||||
|
Repo.all(from(provider in CatalogProvider, order_by: [asc: provider.id]))
|
||||||
|
|> Enum.map(fn provider ->
|
||||||
|
%{
|
||||||
|
id: provider.id,
|
||||||
|
name: provider.name,
|
||||||
|
env_keys: decode_json_list(provider.env_keys),
|
||||||
|
package_ref: provider.package_ref,
|
||||||
|
api_url: provider.api_url,
|
||||||
|
doc_url: provider.doc_url,
|
||||||
|
updated_at: provider.updated_at
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec get_catalog_model(String.t(), String.t() | nil) :: {:ok, map()} | {:error, :not_found}
|
||||||
|
def get_catalog_model(model_id, provider_id \\ nil) when is_binary(model_id) do
|
||||||
|
query =
|
||||||
|
from(model in Model,
|
||||||
|
where: model.model_id == ^model_id,
|
||||||
|
order_by: [asc: model.provider]
|
||||||
|
)
|
||||||
|
|
||||||
|
query =
|
||||||
|
case provider_id do
|
||||||
|
nil -> query
|
||||||
|
provider -> from(model in query, where: model.provider == ^provider)
|
||||||
|
end
|
||||||
|
|
||||||
|
case Repo.one(query) do
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
model -> {:ok, format_model(model)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec catalog_meta(String.t()) :: {:ok, String.t() | nil}
|
||||||
|
def catalog_meta(key) when is_binary(key) do
|
||||||
|
{:ok, get_catalog_meta_value(key)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@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
|
||||||
|
capabilities = %{
|
||||||
|
supports_attachment: truthy?(BDS.MapUtils.attr(attrs, :supports_attachment)),
|
||||||
|
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))
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec format_model(map()) :: map()
|
||||||
|
def format_model(model) do
|
||||||
|
modalities =
|
||||||
|
Repo.all(
|
||||||
|
from(modality in ModelModality,
|
||||||
|
where: modality.provider == ^model.provider and modality.model_id == ^model.model_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
%{
|
||||||
|
provider: model.provider,
|
||||||
|
model_id: model.model_id,
|
||||||
|
name: model.name,
|
||||||
|
family: model.family,
|
||||||
|
supports_attachment: model.supports_attachment,
|
||||||
|
supports_reasoning: model.supports_reasoning,
|
||||||
|
supports_tool_calls: model.supports_tool_calls,
|
||||||
|
supports_structured_output: model.supports_structured_output,
|
||||||
|
supports_temperature: model.supports_temperature,
|
||||||
|
knowledge: model.knowledge,
|
||||||
|
release_date: model.release_date,
|
||||||
|
last_updated_date: model.last_updated_date,
|
||||||
|
open_weights: model.open_weights,
|
||||||
|
input_price: model.input_price,
|
||||||
|
output_price: model.output_price,
|
||||||
|
cache_read_price: model.cache_read_price,
|
||||||
|
cache_write_price: model.cache_write_price,
|
||||||
|
context_window: model.context_window,
|
||||||
|
max_input_tokens: model.max_input_tokens,
|
||||||
|
max_output_tokens: model.max_output_tokens,
|
||||||
|
interleaved: model.interleaved,
|
||||||
|
status: model.status,
|
||||||
|
updated_at: model.updated_at,
|
||||||
|
input_modalities:
|
||||||
|
modalities
|
||||||
|
|> Enum.filter(&(&1.direction == :input))
|
||||||
|
|> Enum.map(&Atom.to_string(&1.modality)),
|
||||||
|
output_modalities:
|
||||||
|
modalities
|
||||||
|
|> Enum.filter(&(&1.direction == :output))
|
||||||
|
|> Enum.map(&Atom.to_string(&1.modality))
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec model_capabilities(String.t()) :: %{
|
||||||
|
supports_attachment: boolean(),
|
||||||
|
supports_tool_calls: boolean(),
|
||||||
|
disables_reasoning: boolean()
|
||||||
|
}
|
||||||
|
def model_capabilities(model_id) do
|
||||||
|
overrides = decode_model_capabilities_override(model_id)
|
||||||
|
|
||||||
|
from_catalog =
|
||||||
|
case get_catalog_model(model_id) do
|
||||||
|
{:ok, model} ->
|
||||||
|
%{
|
||||||
|
supports_attachment: model.supports_attachment or "image" in model.input_modalities,
|
||||||
|
supports_tool_calls: model.supports_tool_calls,
|
||||||
|
disables_reasoning: false
|
||||||
|
}
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
inferred_model_capabilities(model_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
Map.merge(from_catalog, overrides)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec decode_nullable_json(nil | binary()) :: any()
|
||||||
|
def decode_nullable_json(nil), do: nil
|
||||||
|
def decode_nullable_json(value) when is_binary(value), do: Jason.decode!(value)
|
||||||
|
|
||||||
|
defp inferred_model_capabilities(model_id) do
|
||||||
|
normalized = String.downcase(model_id)
|
||||||
|
|
||||||
|
%{
|
||||||
|
supports_attachment:
|
||||||
|
String.contains?(normalized, "4o") or String.contains?(normalized, "vision") or
|
||||||
|
String.contains?(normalized, "llava"),
|
||||||
|
supports_tool_calls:
|
||||||
|
String.contains?(normalized, "gpt") or String.contains?(normalized, "claude") or
|
||||||
|
String.contains?(normalized, "tool"),
|
||||||
|
disables_reasoning: false
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_model_capabilities_override(model_id) do
|
||||||
|
case get_setting("ai.model_capabilities.#{model_id}") do
|
||||||
|
nil -> %{}
|
||||||
|
value -> Jason.decode!(value) |> atomize_map_keys()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp atomize_map_keys(map), do: MapUtils.safe_atomize_keys(map)
|
||||||
|
|
||||||
|
defp persist_catalog(payload) do
|
||||||
|
now = Persistence.now_ms()
|
||||||
|
|
||||||
|
Repo.transaction(fn ->
|
||||||
|
Repo.delete_all(ModelModality)
|
||||||
|
Repo.delete_all(Model)
|
||||||
|
Repo.delete_all(CatalogProvider)
|
||||||
|
|
||||||
|
Enum.reduce(payload, 0, fn {provider_id, provider_data}, count ->
|
||||||
|
provider_attrs = %{
|
||||||
|
id: provider_id,
|
||||||
|
name: Map.get(provider_data, "name", provider_id),
|
||||||
|
env_keys: Jason.encode!(Map.get(provider_data, "env", [])),
|
||||||
|
package_ref: Map.get(provider_data, "npm"),
|
||||||
|
api_url: Map.get(provider_data, "api"),
|
||||||
|
doc_url: Map.get(provider_data, "doc"),
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
%CatalogProvider{}
|
||||||
|
|> CatalogProvider.changeset(provider_attrs)
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
models = Map.get(provider_data, "models", %{})
|
||||||
|
|
||||||
|
Enum.reduce(models, count, fn {model_id, model_data}, inner_count ->
|
||||||
|
model_attrs = %{
|
||||||
|
provider: provider_id,
|
||||||
|
model_id: model_id,
|
||||||
|
name: Map.get(model_data, "name", model_id),
|
||||||
|
family: Map.get(model_data, "family"),
|
||||||
|
supports_attachment: Map.get(model_data, "attachment", false),
|
||||||
|
supports_reasoning: Map.get(model_data, "reasoning", false),
|
||||||
|
supports_tool_calls: Map.get(model_data, "tool_call", false),
|
||||||
|
supports_structured_output: Map.get(model_data, "structured_output", false),
|
||||||
|
supports_temperature: Map.get(model_data, "temperature", false),
|
||||||
|
knowledge: Map.get(model_data, "knowledge"),
|
||||||
|
release_date: Map.get(model_data, "release_date"),
|
||||||
|
last_updated_date: Map.get(model_data, "last_updated"),
|
||||||
|
open_weights: Map.get(model_data, "open_weights", false),
|
||||||
|
input_price: get_in(model_data, ["cost", "input"]),
|
||||||
|
output_price: get_in(model_data, ["cost", "output"]),
|
||||||
|
cache_read_price: get_in(model_data, ["cost", "cache_read"]),
|
||||||
|
cache_write_price: get_in(model_data, ["cost", "cache_write"]),
|
||||||
|
context_window: get_in(model_data, ["limit", "context"]) || 0,
|
||||||
|
max_input_tokens: get_in(model_data, ["limit", "input"]) || 0,
|
||||||
|
max_output_tokens: get_in(model_data, ["limit", "output"]) || 0,
|
||||||
|
interleaved: encode_nullable(Map.get(model_data, "interleaved")),
|
||||||
|
status: Map.get(model_data, "status"),
|
||||||
|
updated_at: now
|
||||||
|
}
|
||||||
|
|
||||||
|
%Model{}
|
||||||
|
|> Model.changeset(model_attrs)
|
||||||
|
|> Repo.insert!()
|
||||||
|
|
||||||
|
insert_modalities(
|
||||||
|
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
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
{:ok, count} -> count
|
||||||
|
{:error, reason} -> raise reason
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp insert_modalities(provider_id, model_id, modalities, direction) do
|
||||||
|
Enum.each(modalities, fn modality ->
|
||||||
|
%ModelModality{}
|
||||||
|
|> ModelModality.changeset(%{
|
||||||
|
provider: provider_id,
|
||||||
|
model_id: model_id,
|
||||||
|
direction: direction,
|
||||||
|
modality: parse_modality(modality)
|
||||||
|
})
|
||||||
|
|> Repo.insert!()
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_modality("text"), do: :text
|
||||||
|
defp parse_modality("image"), do: :image
|
||||||
|
defp parse_modality("audio"), do: :audio
|
||||||
|
defp parse_modality("file"), do: :file
|
||||||
|
defp parse_modality("tool"), do: :tool
|
||||||
|
defp parse_modality(other) when is_binary(other), do: MapUtils.safe_atomize_key(other)
|
||||||
|
|
||||||
|
defp encode_nullable(nil), do: nil
|
||||||
|
defp encode_nullable(value), do: Jason.encode!(value)
|
||||||
|
|
||||||
|
defp http_get(client, url, headers) when is_atom(client), do: client.get(url, headers)
|
||||||
|
defp http_get(client, url, headers) when is_function(client, 2), do: client.(url, headers)
|
||||||
|
|
||||||
|
defp maybe_put_header(headers, _key, nil), do: headers
|
||||||
|
defp maybe_put_header(headers, key, value), do: Map.put(headers, key, value)
|
||||||
|
|
||||||
|
defp decode_json_list(nil), do: []
|
||||||
|
defp decode_json_list(value), do: Jason.decode!(value)
|
||||||
|
|
||||||
|
defp truthy?(value), do: value in [true, "true", 1, "1"]
|
||||||
|
end
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,945 @@
|
|||||||
|
defmodule BDS.AI.Chat do
|
||||||
|
@moduledoc """
|
||||||
|
Context for AI chat conversations: start, list, fetch, and delete
|
||||||
|
conversations, persist surface state, and resolve the effective chat model.
|
||||||
|
|
||||||
|
Automatic AI activity is gated by the app's airplane (offline) mode; callers
|
||||||
|
must respect that gating before invoking model-backed operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias BDS.AI
|
||||||
|
alias BDS.AI.Catalog
|
||||||
|
alias BDS.AI.CatalogProvider
|
||||||
|
alias BDS.AI.ChatConversation
|
||||||
|
alias BDS.AI.ChatMessage
|
||||||
|
alias BDS.AI.ChatTitleGenerator
|
||||||
|
alias BDS.AI.ChatTools
|
||||||
|
alias BDS.AI.InFlight
|
||||||
|
alias BDS.AI.OpenAICompatibleRuntime
|
||||||
|
alias BDS.AI.Runtime
|
||||||
|
alias BDS.AI.SecretBackend
|
||||||
|
alias BDS.MapUtils
|
||||||
|
import BDS.AI.SettingsStore, only: [get_setting: 1]
|
||||||
|
alias BDS.Media.Media
|
||||||
|
alias BDS.Persistence
|
||||||
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.Projects.Project
|
||||||
|
alias BDS.Repo
|
||||||
|
|
||||||
|
@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
|
||||||
|
@chat_max_tool_rounds 10
|
||||||
|
@chat_await_timeout_margin_ms 5_000
|
||||||
|
@default_context_window 128_000
|
||||||
|
|
||||||
|
@spec start_chat(map()) :: {:ok, map()} | {:error, Ecto.Changeset.t()}
|
||||||
|
def start_chat(attrs \\ %{}) when is_map(attrs) do
|
||||||
|
now = Persistence.now_ms()
|
||||||
|
model = MapUtils.attr(attrs, :model)
|
||||||
|
title = MapUtils.attr(attrs, :title) || ChatTitleGenerator.generated_chat_title(model)
|
||||||
|
|
||||||
|
%ChatConversation{}
|
||||||
|
|> ChatConversation.changeset(%{
|
||||||
|
id: Ecto.UUID.generate(),
|
||||||
|
title: title,
|
||||||
|
model: model,
|
||||||
|
copilot_session_id: MapUtils.attr(attrs, :copilot_session_id),
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now
|
||||||
|
})
|
||||||
|
|> Repo.insert()
|
||||||
|
|> case do
|
||||||
|
{:ok, conversation} -> {:ok, format_conversation(conversation)}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec list_chat_conversations() :: [map()]
|
||||||
|
def list_chat_conversations do
|
||||||
|
Repo.all(from conversation in ChatConversation, order_by: [desc: conversation.updated_at])
|
||||||
|
|> Enum.map(&format_conversation/1)
|
||||||
|
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.transaction(fn ->
|
||||||
|
Repo.delete_all(
|
||||||
|
from message in ChatMessage, where: message.conversation_id == ^conversation_id
|
||||||
|
)
|
||||||
|
|
||||||
|
case delete_chat_conversation_test_hook(conversation_id) do
|
||||||
|
:ok ->
|
||||||
|
case Repo.delete(conversation) do
|
||||||
|
{:ok, _conversation} -> :ok
|
||||||
|
{:error, reason} -> Repo.rollback(reason)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Repo.rollback(reason)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
{:ok, :ok} -> {:ok, :deleted}
|
||||||
|
{:error, reason} -> {:error, reason}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec available_chat_models(String.t() | nil) :: [map()]
|
||||||
|
def available_chat_models(current_model \\ nil) do
|
||||||
|
endpoint_models = configured_chat_models()
|
||||||
|
|
||||||
|
preference_models =
|
||||||
|
[:chat, :airplane_chat]
|
||||||
|
|> Enum.flat_map(fn key ->
|
||||||
|
case AI.get_model_preference(key) do
|
||||||
|
{:ok, model} when is_binary(model) and model != "" -> [model]
|
||||||
|
_other -> []
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
provider_names = catalog_provider_name_map()
|
||||||
|
endpoint_provider_map = Map.new(endpoint_models, &{&1.id, &1.provider})
|
||||||
|
|
||||||
|
[current_model | Enum.map(endpoint_models, & &1.id) ++ preference_models]
|
||||||
|
|> Enum.filter(&(is_binary(&1) and String.trim(&1) != ""))
|
||||||
|
|> Enum.uniq()
|
||||||
|
|> Enum.map(&build_available_chat_model(&1, endpoint_provider_map, provider_names))
|
||||||
|
|> Enum.sort_by(fn model ->
|
||||||
|
{
|
||||||
|
String.downcase(to_string(model.provider_name || model.provider || "")),
|
||||||
|
String.downcase(to_string(model.name || model.id))
|
||||||
|
}
|
||||||
|
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()) ::
|
||||||
|
{:ok, map()} | {:error, :not_found | Ecto.Changeset.t()}
|
||||||
|
def set_conversation_model(conversation_id, model_id)
|
||||||
|
when is_binary(conversation_id) and is_binary(model_id) do
|
||||||
|
case Repo.get(ChatConversation, conversation_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, :not_found}
|
||||||
|
|
||||||
|
%ChatConversation{} = conversation ->
|
||||||
|
conversation
|
||||||
|
|> ChatConversation.changeset(%{model: model_id, updated_at: Persistence.now_ms()})
|
||||||
|
|> Repo.update()
|
||||||
|
|> case do
|
||||||
|
{:ok, updated_conversation} -> {:ok, format_conversation(updated_conversation)}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec list_chat_messages(String.t()) :: [map()]
|
||||||
|
def list_chat_messages(conversation_id) when is_binary(conversation_id) do
|
||||||
|
Repo.all(
|
||||||
|
from message in ChatMessage,
|
||||||
|
where: message.conversation_id == ^conversation_id,
|
||||||
|
order_by: [asc: message.created_at, asc: message.id]
|
||||||
|
)
|
||||||
|
|> Enum.map(&format_chat_message/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec send_chat_message(String.t(), String.t(), keyword()) ::
|
||||||
|
{:ok, map()} | {:error, :not_found | term()}
|
||||||
|
def send_chat_message(conversation_id, content, opts \\ [])
|
||||||
|
when is_binary(conversation_id) and is_binary(content) and is_list(opts) do
|
||||||
|
with %ChatConversation{} = conversation <- Repo.get(ChatConversation, conversation_id),
|
||||||
|
{:ok, user_message} <-
|
||||||
|
persist_chat_message(%{
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
role: :user,
|
||||||
|
content: content,
|
||||||
|
created_at: Persistence.now_ms()
|
||||||
|
}) do
|
||||||
|
task =
|
||||||
|
Task.Supervisor.async_nolink(BDS.Tasks.TaskSupervisor, fn ->
|
||||||
|
do_send_chat_message(conversation, user_message, opts)
|
||||||
|
end)
|
||||||
|
|
||||||
|
InFlight.register(conversation.id, task.pid)
|
||||||
|
|
||||||
|
try do
|
||||||
|
await_chat_task(task, chat_await_timeout_ms())
|
||||||
|
after
|
||||||
|
InFlight.unregister(conversation.id)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec cancel_chat(String.t()) :: :ok
|
||||||
|
def cancel_chat(conversation_id) when is_binary(conversation_id) do
|
||||||
|
case InFlight.lookup(conversation_id) do
|
||||||
|
nil ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
pid ->
|
||||||
|
_ = Task.Supervisor.terminate_child(BDS.Tasks.TaskSupervisor, pid)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|> List.flatten()
|
||||||
|
|> Enum.reject(&blank?/1)
|
||||||
|
|> MapSet.new()
|
||||||
|
|> MapSet.size()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def normalize_usage(usage) when is_map(usage) do
|
||||||
|
%{
|
||||||
|
input_tokens: usage[:input_tokens] || usage["input_tokens"],
|
||||||
|
output_tokens: usage[:output_tokens] || usage["output_tokens"],
|
||||||
|
cache_read_tokens: usage[:cache_read_tokens] || usage["cache_read_tokens"],
|
||||||
|
cache_write_tokens: usage[:cache_write_tokens] || usage["cache_write_tokens"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_usage(_usage) do
|
||||||
|
%{
|
||||||
|
input_tokens: nil,
|
||||||
|
output_tokens: nil,
|
||||||
|
cache_read_tokens: nil,
|
||||||
|
cache_write_tokens: nil
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_conversation(conversation) do
|
||||||
|
%{
|
||||||
|
id: conversation.id,
|
||||||
|
title: conversation.title,
|
||||||
|
model: conversation.model,
|
||||||
|
copilot_session_id: conversation.copilot_session_id,
|
||||||
|
created_at: conversation.created_at,
|
||||||
|
updated_at: conversation.updated_at
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_chat_message(message) do
|
||||||
|
%{
|
||||||
|
id: message.id,
|
||||||
|
conversation_id: message.conversation_id,
|
||||||
|
role: message.role,
|
||||||
|
content: message.content,
|
||||||
|
tool_call_id: message.tool_call_id,
|
||||||
|
tool_calls: Catalog.decode_nullable_json(message.tool_calls),
|
||||||
|
token_usage_input: message.token_usage_input,
|
||||||
|
token_usage_output: message.token_usage_output,
|
||||||
|
cache_read_tokens: message.cache_read_tokens,
|
||||||
|
cache_write_tokens: message.cache_write_tokens,
|
||||||
|
created_at: message.created_at
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp configured_chat_models do
|
||||||
|
[:online, :airplane]
|
||||||
|
|> Enum.flat_map(fn kind ->
|
||||||
|
case AI.get_endpoint(kind) do
|
||||||
|
{:ok, %{model: model, url: url}} when is_binary(model) and model != "" ->
|
||||||
|
[%{id: model, provider: infer_endpoint_provider(kind, url)}]
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_available_chat_model(model_id, endpoint_provider_map, provider_names) do
|
||||||
|
case Catalog.get_catalog_model(model_id) do
|
||||||
|
{:ok, model} ->
|
||||||
|
provider = model.provider || Map.get(endpoint_provider_map, model_id, "other")
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: model.model_id,
|
||||||
|
name: model.name || model.model_id,
|
||||||
|
provider: provider,
|
||||||
|
provider_name: Map.get(provider_names, provider, fallback_provider_name(provider)),
|
||||||
|
context_window: model.context_window,
|
||||||
|
max_output_tokens: model.max_output_tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
{:error, :not_found} ->
|
||||||
|
provider = Map.get(endpoint_provider_map, model_id, "other")
|
||||||
|
|
||||||
|
%{
|
||||||
|
id: model_id,
|
||||||
|
name: model_id,
|
||||||
|
provider: provider,
|
||||||
|
provider_name: Map.get(provider_names, provider, fallback_provider_name(provider)),
|
||||||
|
context_window: nil,
|
||||||
|
max_output_tokens: nil
|
||||||
|
}
|
||||||
|
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
|
||||||
|
Repo.all(from provider in CatalogProvider, select: {provider.id, provider.name})
|
||||||
|
|> Map.new()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp infer_endpoint_provider(:online, _url), do: "generic-openai"
|
||||||
|
|
||||||
|
defp infer_endpoint_provider(:airplane, url) when is_binary(url) do
|
||||||
|
normalized_url = String.downcase(url)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
String.contains?(normalized_url, "11434") or String.contains?(normalized_url, "ollama") ->
|
||||||
|
"ollama"
|
||||||
|
|
||||||
|
String.contains?(normalized_url, "1234") or String.contains?(normalized_url, "lmstudio") ->
|
||||||
|
"lmstudio"
|
||||||
|
|
||||||
|
true ->
|
||||||
|
"generic-openai"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp infer_endpoint_provider(:airplane, _url), do: "generic-openai"
|
||||||
|
|
||||||
|
defp fallback_provider_name("generic-openai"), do: "Generic OpenAI"
|
||||||
|
defp fallback_provider_name("lmstudio"), do: "LM Studio"
|
||||||
|
defp fallback_provider_name("mistral"), do: "Mistral"
|
||||||
|
defp fallback_provider_name("ollama"), do: "Ollama"
|
||||||
|
defp fallback_provider_name("openai"), do: "OpenAI"
|
||||||
|
|
||||||
|
defp fallback_provider_name(provider) when is_binary(provider) and provider != "" do
|
||||||
|
provider
|
||||||
|
|> String.split(["-", "_"], trim: true)
|
||||||
|
|> Enum.map(&String.capitalize/1)
|
||||||
|
|> Enum.join(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fallback_provider_name(_provider), do: "Other"
|
||||||
|
|
||||||
|
defp do_send_chat_message(conversation, user_message, opts) do
|
||||||
|
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
||||||
|
project_id = Keyword.get(opts, :project_id, active_project_id())
|
||||||
|
|
||||||
|
with {:ok, endpoint, model, mode} <-
|
||||||
|
Runtime.resolve_target(
|
||||||
|
:chat,
|
||||||
|
conversation: conversation,
|
||||||
|
secret_backend: Keyword.get(opts, :secret_backend, SecretBackend)
|
||||||
|
),
|
||||||
|
:ok <- Runtime.validate_target(:chat, model, mode),
|
||||||
|
messages <- load_chat_messages(conversation.id),
|
||||||
|
tools <- available_chat_tools(project_id, model),
|
||||||
|
{: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}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_generate_chat_title(conversation_id, user_content, reply, opts) do
|
||||||
|
conversation = Repo.get!(ChatConversation, conversation_id)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
ChatTitleGenerator.chat_user_message_count(conversation_id) < 1 ->
|
||||||
|
Logger.debug("Chat title generation skipped reason=:no_user_messages")
|
||||||
|
{:ok, reply}
|
||||||
|
|
||||||
|
not ChatTitleGenerator.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 ChatTitleGenerator.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 chat_round(
|
||||||
|
_conversation,
|
||||||
|
_messages,
|
||||||
|
_endpoint,
|
||||||
|
_model,
|
||||||
|
_project_id,
|
||||||
|
_tools,
|
||||||
|
_runtime,
|
||||||
|
_opts,
|
||||||
|
0
|
||||||
|
) do
|
||||||
|
{:error, %{kind: :tool_loop_exhausted}}
|
||||||
|
end
|
||||||
|
|
||||||
|
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)
|
||||||
|
generate_opts = put_stream_callback(opts, conversation.id)
|
||||||
|
|
||||||
|
with {:ok, response} <-
|
||||||
|
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, generate_opts),
|
||||||
|
{:ok, assistant_message} <- persist_assistant_response(conversation.id, response),
|
||||||
|
:ok <- touch_conversation(conversation.id) do
|
||||||
|
notify_assistant_content(response, conversation.id, opts)
|
||||||
|
|
||||||
|
response
|
||||||
|
|> Map.get(:tool_calls)
|
||||||
|
|> decode_tool_calls()
|
||||||
|
|> continue_chat_round(
|
||||||
|
conversation.id,
|
||||||
|
assistant_message,
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
project_id,
|
||||||
|
tools,
|
||||||
|
runtime,
|
||||||
|
opts,
|
||||||
|
rounds_left
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp delete_chat_conversation_test_hook(conversation_id) do
|
||||||
|
case Application.get_env(:bds, :chat_delete_conversation_test_hook) do
|
||||||
|
hook when is_function(hook, 1) -> hook.(conversation_id)
|
||||||
|
_other -> :ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp persist_assistant_response(conversation_id, response) do
|
||||||
|
usage = normalize_usage(response.usage)
|
||||||
|
|
||||||
|
content =
|
||||||
|
case Map.get(response, :content) do
|
||||||
|
nil -> encode_nullable(Map.get(response, :json))
|
||||||
|
value -> value
|
||||||
|
end
|
||||||
|
|
||||||
|
persist_chat_message(%{
|
||||||
|
conversation_id: conversation_id,
|
||||||
|
role: :assistant,
|
||||||
|
content: content,
|
||||||
|
tool_calls: encode_nullable(Map.get(response, :tool_calls)),
|
||||||
|
token_usage_input: usage.input_tokens,
|
||||||
|
token_usage_output: usage.output_tokens,
|
||||||
|
cache_read_tokens: usage.cache_read_tokens,
|
||||||
|
cache_write_tokens: usage.cache_write_tokens,
|
||||||
|
created_at: Persistence.now_ms()
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp execute_tool_calls(conversation_id, tool_calls, project_id, opts) do
|
||||||
|
tool_messages =
|
||||||
|
Enum.map(tool_calls, fn tool_call ->
|
||||||
|
result = ChatTools.execute(tool_call.name, tool_call.arguments || %{}, project_id)
|
||||||
|
|
||||||
|
{:ok, message} =
|
||||||
|
persist_chat_message(%{
|
||||||
|
conversation_id: conversation_id,
|
||||||
|
role: :tool,
|
||||||
|
content: Jason.encode!(result),
|
||||||
|
tool_call_id: tool_call.id,
|
||||||
|
created_at: Persistence.now_ms()
|
||||||
|
})
|
||||||
|
|
||||||
|
notify_chat_event(opts, {:chat_tool_result, conversation_id, tool_call.name})
|
||||||
|
|
||||||
|
format_chat_message(message)
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, tool_messages}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp continue_chat_round(
|
||||||
|
tool_calls,
|
||||||
|
conversation_id,
|
||||||
|
assistant_message,
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
project_id,
|
||||||
|
tools,
|
||||||
|
runtime,
|
||||||
|
opts,
|
||||||
|
rounds_left
|
||||||
|
) do
|
||||||
|
Enum.each(tool_calls, fn tool_call ->
|
||||||
|
notify_chat_event(opts, {:chat_tool_call, conversation_id, tool_call})
|
||||||
|
end)
|
||||||
|
|
||||||
|
if tool_calls != [] and tools != [] do
|
||||||
|
with {:ok, tool_messages} <- execute_tool_calls(conversation_id, tool_calls, project_id, opts),
|
||||||
|
updated_messages <- load_chat_messages(conversation_id),
|
||||||
|
{:ok, reply} <-
|
||||||
|
chat_round(
|
||||||
|
Repo.get!(ChatConversation, conversation_id),
|
||||||
|
updated_messages,
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
project_id,
|
||||||
|
tools,
|
||||||
|
runtime,
|
||||||
|
opts,
|
||||||
|
rounds_left - 1
|
||||||
|
) do
|
||||||
|
{:ok, Map.put(reply, :tool_messages, tool_messages)}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
final_chat_reply(conversation_id, assistant_message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp final_chat_reply(conversation_id, assistant_message) do
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
conversation: format_conversation(Repo.get!(ChatConversation, conversation_id)),
|
||||||
|
assistant_message: format_chat_message(assistant_message),
|
||||||
|
tool_messages: []
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp notify_assistant_content(response, conversation_id, opts) do
|
||||||
|
content = Map.get(response, :content)
|
||||||
|
|
||||||
|
if is_binary(content) and String.trim(content) != "" do
|
||||||
|
notify_chat_event(opts, {:chat_streaming_content, conversation_id, content})
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp build_chat_request(conversation, messages, model, project_id, tools) do
|
||||||
|
system_message = %{"role" => "system", "content" => chat_system_prompt(project_id, tools)}
|
||||||
|
|
||||||
|
%{
|
||||||
|
operation: :chat,
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
model: model,
|
||||||
|
max_output_tokens: @default_max_output_tokens,
|
||||||
|
tools: Enum.map(tools, & &1.spec),
|
||||||
|
messages:
|
||||||
|
[system_message | Enum.map(messages, &message_for_runtime/1)]
|
||||||
|
|> truncate_chat_messages(model, tools)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp message_for_runtime(%ChatMessage{} = message) do
|
||||||
|
base = %{"role" => Atom.to_string(message.role)}
|
||||||
|
|
||||||
|
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
|
||||||
|
nil -> base
|
||||||
|
tool_calls -> Map.put(base, "tool_calls", tool_calls_for_runtime(tool_calls))
|
||||||
|
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
|
||||||
|
context_window = model_context_window(model)
|
||||||
|
reserve = min(@default_max_output_tokens, max(div(context_window, 4), 512))
|
||||||
|
tool_budget = length(tools) * 120
|
||||||
|
max_budget = max(context_window - reserve - tool_budget, 512)
|
||||||
|
|
||||||
|
[system | remainder] = messages
|
||||||
|
|
||||||
|
{kept, _tokens} =
|
||||||
|
Enum.reduce(Enum.reverse(remainder), {[], approximate_message_tokens(system)}, fn message,
|
||||||
|
{acc,
|
||||||
|
used} ->
|
||||||
|
message_tokens = approximate_message_tokens(message)
|
||||||
|
|
||||||
|
if used + message_tokens <= max_budget do
|
||||||
|
{[message | acc], used + message_tokens}
|
||||||
|
else
|
||||||
|
{acc, used}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
[system | kept]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp available_chat_tools(project_id, model) do
|
||||||
|
ChatTools.available_specs(project_id, Catalog.model_capabilities(model))
|
||||||
|
end
|
||||||
|
|
||||||
|
# BoundedToolLoop: the tool-calling round count is capped by
|
||||||
|
# config.chat_max_tool_rounds (falling back to the built-in default).
|
||||||
|
defp chat_max_tool_rounds do
|
||||||
|
chat_config(:max_tool_rounds, @chat_max_tool_rounds)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp chat_await_timeout_ms do
|
||||||
|
per_request_timeout_ms = BDS.AI.HttpClient.request_timeout_ms()
|
||||||
|
|
||||||
|
per_request_timeout_ms * (chat_max_tool_rounds() + 1) +
|
||||||
|
chat_config(:await_timeout_margin_ms, @chat_await_timeout_margin_ms)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp chat_config(key, default) do
|
||||||
|
:bds
|
||||||
|
|> Application.get_env(:chat, [])
|
||||||
|
|> Keyword.get(key, default)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp chat_system_prompt(project_id, tools) do
|
||||||
|
base = get_setting("ai.system_prompt") || @default_system_prompt
|
||||||
|
|
||||||
|
with true <- tools != [],
|
||||||
|
summary when is_binary(summary) <- project_stats_summary(project_id) do
|
||||||
|
base <> "\n\nCurrent blog statistics:\n" <> summary <> "\n\n" <> blog_tool_guidance()
|
||||||
|
else
|
||||||
|
_other -> base
|
||||||
|
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 these to show rich interactive elements):",
|
||||||
|
"- render_chart: Show data as a bar, stacked-bar, line, area, pie, donut, or heatmap chart. Use when presenting statistics or comparisons. Use stacked-bar when each bar has multiple segments (e.g., published vs draft posts per year). Use area for cumulative or trend data where the filled region emphasizes volume. Use donut for proportional breakdowns with a total displayed in the center. Use heatmap for grid/matrix visualizations where color intensity shows magnitude — e.g., posts per month across years (each series entry is a row like a year, each segment is a column like a month), or a calendar view where rows are weekdays and columns are week numbers. ALWAYS prefer heatmap over a table with emojis or color indicators when showing intensity grids or calendar-style activity views. IMPORTANT: a heatmap needs structured data — each entry in 'series' is a ROW and must include a 'segments' array whose entries are the COLUMNS (every segment needs a 'label' and a numeric 'value'); the row's own 'value' is ignored. Plan what the rows and columns represent before fetching data (e.g. rows = years, columns = months). A heatmap sent without segments renders empty.",
|
||||||
|
"- render_table: Show data in a structured table. Use for tabular comparisons and listings.",
|
||||||
|
"- render_form: Show an interactive form to collect user input (e.g., metadata edits, settings).",
|
||||||
|
"- render_card: Show an information card with title, body, and action buttons.",
|
||||||
|
"- render_metric: Show a single KPI or statistic prominently.",
|
||||||
|
"- render_list: Show a bulleted list of items.",
|
||||||
|
"- render_tabs: Organize information into switchable tabs. Tab content supports all content types: text, metrics, lists, charts, and tables.",
|
||||||
|
"",
|
||||||
|
"When presenting data, statistics, or comparisons, prefer using render tools (render_chart, render_table, render_metric) to show rich interactive UI instead of plain text. When you need user input for a multi-field operation, use render_form to present a structured form. Use render_card with action buttons when presenting items the user might want to navigate to (e.g., posts, media). When comparing data across multiple dimensions (e.g., statistics per year), use render_tabs with embedded charts or tables in each tab. When building any visualization, render it as soon as you have enough data."
|
||||||
|
],
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp project_stats_summary(nil), do: nil
|
||||||
|
|
||||||
|
defp project_stats_summary(project_id) do
|
||||||
|
post_count =
|
||||||
|
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)
|
||||||
|
category_count = count_distinct_string_list(Post, :categories, project_id)
|
||||||
|
|
||||||
|
Enum.join(
|
||||||
|
[
|
||||||
|
"Posts: #{post_count}",
|
||||||
|
"Media: #{media_count}",
|
||||||
|
"Tags: #{tag_count}",
|
||||||
|
"Categories: #{category_count}"
|
||||||
|
],
|
||||||
|
"\n"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp load_chat_messages(conversation_id) do
|
||||||
|
Repo.all(
|
||||||
|
from message in ChatMessage,
|
||||||
|
where: message.conversation_id == ^conversation_id,
|
||||||
|
order_by: [asc: message.created_at, asc: message.id]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp persist_chat_message(attrs) do
|
||||||
|
%ChatMessage{}
|
||||||
|
|> ChatMessage.changeset(attrs)
|
||||||
|
|> Repo.insert()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp touch_conversation(conversation_id) do
|
||||||
|
now = Persistence.now_ms()
|
||||||
|
|
||||||
|
Repo.update_all(
|
||||||
|
from(conversation in ChatConversation, where: conversation.id == ^conversation_id),
|
||||||
|
set: [updated_at: now]
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp await_chat_task(task, timeout_ms) do
|
||||||
|
ref = task.ref
|
||||||
|
|
||||||
|
receive do
|
||||||
|
{^ref, result} ->
|
||||||
|
Process.demonitor(task.ref, [:flush])
|
||||||
|
result
|
||||||
|
|
||||||
|
{:DOWN, ^ref, :process, _pid, reason} ->
|
||||||
|
case reason do
|
||||||
|
:normal ->
|
||||||
|
receive do
|
||||||
|
{^ref, result} -> result
|
||||||
|
after
|
||||||
|
10 -> {:error, :cancelled}
|
||||||
|
end
|
||||||
|
|
||||||
|
:shutdown ->
|
||||||
|
{:error, :cancelled}
|
||||||
|
|
||||||
|
{:shutdown, _detail} ->
|
||||||
|
{:error, :cancelled}
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
{:error, :cancelled}
|
||||||
|
end
|
||||||
|
after
|
||||||
|
timeout_ms ->
|
||||||
|
_ = Task.shutdown(task, 100)
|
||||||
|
{:error, :chat_timeout}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_tool_calls(nil), do: []
|
||||||
|
|
||||||
|
defp decode_tool_calls(tool_calls) when is_list(tool_calls) do
|
||||||
|
Enum.map(tool_calls, fn tool_call ->
|
||||||
|
%{
|
||||||
|
id: tool_call[:id] || tool_call["id"],
|
||||||
|
name: tool_call[:name] || tool_call["name"],
|
||||||
|
arguments: tool_call[:arguments] || tool_call["arguments"] || %{}
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp approximate_message_tokens(message) when is_map(message) do
|
||||||
|
message
|
||||||
|
|> Map.values()
|
||||||
|
|> Enum.map(&approximate_value_tokens/1)
|
||||||
|
|> Enum.sum()
|
||||||
|
|> Kernel.+(4)
|
||||||
|
end
|
||||||
|
|
||||||
|
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), do: 1
|
||||||
|
|
||||||
|
defp model_context_window(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
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
@default_context_window
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# When someone is listening for chat events, ask the runtime to stream:
|
||||||
|
# it emits cumulative content snapshots, which the editor renders with
|
||||||
|
# replace semantics. The full-content notify after each round stays the
|
||||||
|
# authoritative final state (and the only event for non-streaming runtimes).
|
||||||
|
defp put_stream_callback(opts, conversation_id) do
|
||||||
|
case Keyword.get(opts, :event_target) do
|
||||||
|
nil ->
|
||||||
|
opts
|
||||||
|
|
||||||
|
_target ->
|
||||||
|
Keyword.put(opts, :on_stream, fn %{content: content} ->
|
||||||
|
if is_binary(content) and String.trim(content) != "" do
|
||||||
|
notify_chat_event(opts, {:chat_streaming_content, conversation_id, content})
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp notify_chat_event(opts, event) do
|
||||||
|
case Keyword.get(opts, :event_target) do
|
||||||
|
pid when is_pid(pid) -> send(pid, event)
|
||||||
|
callback when is_function(callback, 1) -> callback.(event)
|
||||||
|
_other -> :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp active_project_id do
|
||||||
|
Repo.one(from project in Project, where: project.is_active == true, select: project.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp encode_nullable(nil), do: nil
|
||||||
|
defp encode_nullable(value), do: Jason.encode!(value)
|
||||||
|
|
||||||
|
defp blank?(value), do: BDS.Values.blank?(value)
|
||||||
|
end
|
||||||
@@ -6,17 +6,32 @@ 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,7 +23,9 @@ defmodule BDS.AI.ChatMessage do
|
|||||||
|
|
||||||
def changeset(message, attrs) do
|
def changeset(message, attrs) do
|
||||||
message
|
message
|
||||||
|> cast(attrs, [
|
|> cast(
|
||||||
|
attrs,
|
||||||
|
[
|
||||||
:conversation_id,
|
:conversation_id,
|
||||||
:role,
|
:role,
|
||||||
:content,
|
:content,
|
||||||
@@ -34,7 +36,9 @@ defmodule BDS.AI.ChatMessage do
|
|||||||
:cache_read_tokens,
|
:cache_read_tokens,
|
||||||
:cache_write_tokens,
|
:cache_write_tokens,
|
||||||
:created_at
|
:created_at
|
||||||
], empty_values: [nil])
|
],
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
defmodule BDS.AI.ChatTitleGenerator do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias BDS.AI.ChatMessage
|
||||||
|
alias BDS.AI.OpenAICompatibleRuntime
|
||||||
|
alias BDS.AI.Runtime
|
||||||
|
alias BDS.Repo
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
@title_max_output_tokens 256
|
||||||
|
@chat_title_max_length 30
|
||||||
|
|
||||||
|
def generate_chat_title(user_content, opts) when is_binary(user_content) do
|
||||||
|
runtime = Keyword.get(opts, :runtime, OpenAICompatibleRuntime)
|
||||||
|
|
||||||
|
with {:ok, endpoint, model, mode} <- Runtime.resolve_target(:chat_title, opts),
|
||||||
|
:ok <- Runtime.validate_target(:chat_title, model, mode),
|
||||||
|
request <- build_chat_title_request(user_content, model),
|
||||||
|
{:ok, response} <-
|
||||||
|
runtime.generate(Runtime.endpoint_with_model(endpoint, model), request, opts) do
|
||||||
|
title = sanitize_chat_title(Map.get(response, :content))
|
||||||
|
|
||||||
|
if title == "" do
|
||||||
|
Logger.warning("Chat title generation returned an empty title",
|
||||||
|
model: model,
|
||||||
|
content: inspect(Map.get(response, :content)),
|
||||||
|
usage: inspect(Map.get(response, :usage))
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, title}
|
||||||
|
else
|
||||||
|
{:error, reason} = error ->
|
||||||
|
Logger.warning("Chat title generation failed", reason: inspect(reason))
|
||||||
|
error
|
||||||
|
|
||||||
|
other ->
|
||||||
|
Logger.warning("Chat title generation failed", reason: inspect(other))
|
||||||
|
other
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_chat_title_request(user_content, model) do
|
||||||
|
%{
|
||||||
|
operation: :chat_title,
|
||||||
|
model: model,
|
||||||
|
max_output_tokens: @title_max_output_tokens,
|
||||||
|
messages: [
|
||||||
|
%{
|
||||||
|
"role" => "system",
|
||||||
|
"content" =>
|
||||||
|
"Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Do not include reasoning. Output ONLY the title text."
|
||||||
|
},
|
||||||
|
%{"role" => "user", "content" => "Topic: #{String.slice(user_content, 0, 100)}"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitize_chat_title(title) when is_binary(title) do
|
||||||
|
title =
|
||||||
|
title
|
||||||
|
|> String.trim()
|
||||||
|
|> String.trim_leading("\"")
|
||||||
|
|> String.trim_leading("'")
|
||||||
|
|> String.trim_trailing("\"")
|
||||||
|
|> String.trim_trailing("'")
|
||||||
|
|> String.trim_trailing(".")
|
||||||
|
|> String.trim_trailing("!")
|
||||||
|
|> String.trim_trailing("?")
|
||||||
|
|
||||||
|
if String.length(title) > @chat_title_max_length do
|
||||||
|
String.slice(title, 0, @chat_title_max_length - 3) <> "..."
|
||||||
|
else
|
||||||
|
title
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitize_chat_title(_title), do: ""
|
||||||
|
|
||||||
|
def chat_user_message_count(conversation_id) do
|
||||||
|
Repo.aggregate(
|
||||||
|
from(message in ChatMessage,
|
||||||
|
where: message.conversation_id == ^conversation_id and message.role == :user
|
||||||
|
),
|
||||||
|
:count,
|
||||||
|
:id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def generated_chat_title?(title, model) do
|
||||||
|
title in [generated_chat_title(nil), generated_chat_title(model)]
|
||||||
|
end
|
||||||
|
|
||||||
|
def generated_chat_title(nil), do: "New Chat"
|
||||||
|
def generated_chat_title(model), do: "Chat with #{model}"
|
||||||
|
end
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
defmodule BDS.AI.ChatToolQueryHelpers do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias BDS.Search
|
||||||
|
|
||||||
|
def normalize_limit(value) when is_integer(value) and value > 0 and value <= 50, do: value
|
||||||
|
def normalize_limit(_value), do: 10
|
||||||
|
|
||||||
|
def normalize_offset(value) when is_integer(value) and value >= 0, do: value
|
||||||
|
def normalize_offset(_value), do: 0
|
||||||
|
|
||||||
|
def search_filters(arguments) do
|
||||||
|
%{}
|
||||||
|
|> BDS.MapUtils.maybe_put(:category, BDS.MapUtils.blank_to_nil(arguments["category"]))
|
||||||
|
|> BDS.MapUtils.maybe_put(:tags, BDS.MapUtils.blank_to_nil(arguments["tags"]))
|
||||||
|
|> BDS.MapUtils.maybe_put(:language, BDS.MapUtils.blank_to_nil(arguments["language"]))
|
||||||
|
|> BDS.MapUtils.maybe_put(
|
||||||
|
:missing_translation_language,
|
||||||
|
BDS.MapUtils.blank_to_nil(arguments["missingTranslationLanguage"])
|
||||||
|
)
|
||||||
|
|> BDS.MapUtils.maybe_put(:year, arguments["year"])
|
||||||
|
|> BDS.MapUtils.maybe_put(:month, arguments["month"])
|
||||||
|
|> BDS.MapUtils.maybe_put(:status, BDS.BoundedAtoms.post_status(arguments["status"]))
|
||||||
|
|> Map.put(:offset, normalize_offset(arguments["offset"]))
|
||||||
|
|> Map.put(:limit, normalize_limit(arguments["limit"]))
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_all_counted_posts(project_id, arguments) do
|
||||||
|
filters = search_filters(arguments) |> Map.put(:offset, 0) |> Map.put(:limit, 1)
|
||||||
|
{:ok, %{total: total}} = Search.search_posts(project_id, "", filters)
|
||||||
|
|
||||||
|
filters = Map.put(filters, :limit, max(total, 1))
|
||||||
|
{:ok, result} = Search.search_posts(project_id, "", filters)
|
||||||
|
|
||||||
|
result
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_result(project_id, query, filters, mapper) do
|
||||||
|
{:ok, result} = Search.search_posts(project_id, query, filters)
|
||||||
|
|
||||||
|
%{
|
||||||
|
posts: Enum.map(result.posts, mapper),
|
||||||
|
total: result.total,
|
||||||
|
offset: result.offset,
|
||||||
|
limit: result.limit,
|
||||||
|
has_more: result.offset + result.limit < result.total
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
defmodule BDS.AI.ChatToolSchemas do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
def tool_spec(name, description, parameters) do
|
||||||
|
%{
|
||||||
|
"type" => "function",
|
||||||
|
"function" => %{
|
||||||
|
"name" => name,
|
||||||
|
"description" => description,
|
||||||
|
"parameters" => parameters
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def limit_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_search_schema(require_query) do
|
||||||
|
schema = %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"query" => %{"type" => "string"},
|
||||||
|
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
|
||||||
|
"category" => %{"type" => "string"},
|
||||||
|
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||||
|
"language" => %{"type" => "string"},
|
||||||
|
"missingTranslationLanguage" => %{"type" => "string"},
|
||||||
|
"year" => %{"type" => "integer"},
|
||||||
|
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
|
||||||
|
"limit" => %{"type" => "integer", "minimum" => 1, "maximum" => 50},
|
||||||
|
"offset" => %{"type" => "integer", "minimum" => 0}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if require_query, do: Map.put(schema, "required", ["query"]), else: schema
|
||||||
|
end
|
||||||
|
|
||||||
|
def count_posts_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"groupBy" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "string",
|
||||||
|
"enum" => ["year", "month", "tag", "category", "status"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"year" => %{"type" => "integer"},
|
||||||
|
"month" => %{"type" => "integer", "minimum" => 1, "maximum" => 12},
|
||||||
|
"status" => %{"type" => "string", "enum" => ["draft", "published", "archived"]},
|
||||||
|
"category" => %{"type" => "string"},
|
||||||
|
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
|
||||||
|
},
|
||||||
|
"required" => ["groupBy"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def post_id_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{"postId" => %{"type" => "string"}},
|
||||||
|
"required" => ["postId"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def media_id_schema(extra_properties \\ %{}) do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => Map.merge(%{"mediaId" => %{"type" => "string"}}, extra_properties),
|
||||||
|
"required" => ["mediaId"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_post_metadata_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"postId" => %{"type" => "string"},
|
||||||
|
"title" => %{"type" => "string"},
|
||||||
|
"excerpt" => %{"type" => "string"},
|
||||||
|
"tags" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||||
|
"categories" => %{"type" => "array", "items" => %{"type" => "string"}}
|
||||||
|
},
|
||||||
|
"required" => ["postId"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_media_metadata_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"mediaId" => %{"type" => "string"},
|
||||||
|
"title" => %{"type" => "string"},
|
||||||
|
"alt" => %{"type" => "string"},
|
||||||
|
"caption" => %{"type" => "string"},
|
||||||
|
"tags" => %{"type" => "array", "items" => %{"type" => "string"}}
|
||||||
|
},
|
||||||
|
"required" => ["mediaId"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_table_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"title" => %{"type" => "string", "description" => "Optional table title"},
|
||||||
|
"columns" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "string"},
|
||||||
|
"description" => "Column header names"
|
||||||
|
},
|
||||||
|
"rows" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||||
|
"description" => "Table rows, each row is an array of cell values"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_chart_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"chartType" => %{
|
||||||
|
"type" => "string",
|
||||||
|
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
|
||||||
|
"description" =>
|
||||||
|
"The type of chart to render. Use stacked-bar for multi-segment bars. Use heatmap for grid/matrix visualizations."
|
||||||
|
},
|
||||||
|
"title" => %{"type" => "string", "description" => "Optional chart title"},
|
||||||
|
"series" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Array of data points.",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string", "description" => "Data point label"},
|
||||||
|
"value" => %{"type" => "number", "description" => "Data point value"},
|
||||||
|
"segments" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" =>
|
||||||
|
"Segments within this data point. Required for stacked-bar and heatmap charts.",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string"},
|
||||||
|
"value" => %{"type" => "number"}
|
||||||
|
},
|
||||||
|
"required" => ["label", "value"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["label"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["chartType", "series"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_form_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"title" => %{"type" => "string", "description" => "Optional form title"},
|
||||||
|
"fields" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Form fields to display",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"key" => %{"type" => "string", "description" => "Field identifier"},
|
||||||
|
"label" => %{"type" => "string", "description" => "Field label shown to user"},
|
||||||
|
"inputType" => %{
|
||||||
|
"type" => "string",
|
||||||
|
"enum" => ["text", "textarea", "select", "checkbox", "date", "number"],
|
||||||
|
"description" => "Type of input control"
|
||||||
|
},
|
||||||
|
"placeholder" => %{"type" => "string", "description" => "Placeholder text"},
|
||||||
|
"defaultValue" => %{"type" => "string", "description" => "Default value"},
|
||||||
|
"options" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Options for select fields",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string"},
|
||||||
|
"value" => %{"type" => "string"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => %{"type" => "boolean", "description" => "Whether the field is required"}
|
||||||
|
},
|
||||||
|
"required" => ["key", "label", "inputType"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submitLabel" => %{"type" => "string", "description" => "Label for the submit button"},
|
||||||
|
"submitAction" => %{"type" => "string", "description" => "Action to dispatch on submit"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_card_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"title" => %{"type" => "string", "description" => "Card title"},
|
||||||
|
"subtitle" => %{"type" => "string", "description" => "Optional subtitle"},
|
||||||
|
"body" => %{"type" => "string", "description" => "Card body text (supports markdown)"},
|
||||||
|
"actions" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Optional action buttons on the card",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string", "description" => "Button label"},
|
||||||
|
"action" => %{"type" => "string", "description" => "Action name to dispatch"},
|
||||||
|
"payload" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"description" => "Optional action payload"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["label", "action"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_metric_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string", "description" => "Metric label"},
|
||||||
|
"value" => %{"type" => "string", "description" => "Metric value (displayed prominently)"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_list_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"title" => %{"type" => "string", "description" => "Optional list title"},
|
||||||
|
"items" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "string"},
|
||||||
|
"description" => "List items"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_tabs_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"title" => %{"type" => "string", "description" => "Optional tabs title"},
|
||||||
|
"tabs" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Array of tabs",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"label" => %{"type" => "string", "description" => "Tab label"},
|
||||||
|
"content" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Content items within the tab",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"type" => %{
|
||||||
|
"type" => "string",
|
||||||
|
"enum" => ["text", "metric", "list", "chart", "table"],
|
||||||
|
"description" => "Content type"
|
||||||
|
},
|
||||||
|
"text" => %{"type" => "string", "description" => "Text content (for type text)"},
|
||||||
|
"label" => %{"type" => "string", "description" => "Label (for type metric)"},
|
||||||
|
"value" => %{"type" => "string", "description" => "Display value (for type metric)"},
|
||||||
|
"title" => %{"type" => "string", "description" => "Title (for type list, chart, or table)"},
|
||||||
|
"items" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "string"},
|
||||||
|
"description" => "Items (for type list)"
|
||||||
|
},
|
||||||
|
"chartType" => %{
|
||||||
|
"type" => "string",
|
||||||
|
"enum" => ["bar", "stacked-bar", "line", "area", "pie", "donut", "heatmap"],
|
||||||
|
"description" => "Chart type (for type chart)"
|
||||||
|
},
|
||||||
|
"series" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Data series (for type chart)"
|
||||||
|
},
|
||||||
|
"columns" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "string"},
|
||||||
|
"description" => "Column headers (for type table)"
|
||||||
|
},
|
||||||
|
"rows" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "array", "items" => %{"type" => "string"}},
|
||||||
|
"description" => "Table rows (for type table)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["type"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["label", "content"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_mindmap_schema do
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"title" => %{"type" => "string", "description" => "Optional mind map title"},
|
||||||
|
"nodes" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"description" => "Flat array of nodes. The first node is the root. Each node references children by ID.",
|
||||||
|
"items" => %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{
|
||||||
|
"id" => %{"type" => "string", "description" => "Unique node identifier"},
|
||||||
|
"label" => %{"type" => "string", "description" => "Node label text"},
|
||||||
|
"children" => %{
|
||||||
|
"type" => "array",
|
||||||
|
"items" => %{"type" => "string"},
|
||||||
|
"description" => "IDs of child nodes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required" => ["id", "label"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,715 @@
|
|||||||
|
defmodule BDS.AI.ChatTools do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.AI.Chat
|
||||||
|
alias BDS.AI.ChatToolQueryHelpers, as: QueryHelpers
|
||||||
|
alias BDS.AI.ChatToolSchemas, as: Schemas
|
||||||
|
alias BDS.Media, as: MediaContext
|
||||||
|
alias BDS.Media.Media
|
||||||
|
alias BDS.MCP.Queries
|
||||||
|
alias BDS.Posts, as: PostsContext
|
||||||
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.Posts.PostMedia
|
||||||
|
alias BDS.Projects.Project
|
||||||
|
alias BDS.Repo
|
||||||
|
|
||||||
|
@spec execute(String.t(), map(), String.t() | nil) :: map()
|
||||||
|
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()
|
||||||
|
|
||||||
|
%{
|
||||||
|
post_count:
|
||||||
|
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),
|
||||||
|
category_count: Chat.count_distinct_string_list(Post, :categories, project_id)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("check_term", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
term = normalize_term(arguments["term"])
|
||||||
|
|
||||||
|
posts = Repo.all(from post in Post, where: post.project_id == ^project_id)
|
||||||
|
|
||||||
|
tag_post_count =
|
||||||
|
Enum.count(posts, fn post ->
|
||||||
|
Enum.any?(post.tags || [], &(normalize_term(&1) == term))
|
||||||
|
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 = QueryHelpers.search_filters(arguments)
|
||||||
|
|
||||||
|
QueryHelpers.search_result(
|
||||||
|
project_id,
|
||||||
|
arguments["query"] || "",
|
||||||
|
filters,
|
||||||
|
&Queries.post_summary/1
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("read_post_by_slug", arguments, project_id) do
|
||||||
|
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()
|
||||||
|
|
||||||
|
with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
|
||||||
|
%{post: Queries.post_detail(post)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("list_posts", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
limit = QueryHelpers.normalize_limit(arguments["limit"])
|
||||||
|
offset = QueryHelpers.normalize_offset(arguments["offset"])
|
||||||
|
filters = QueryHelpers.search_filters(arguments) |> Map.merge(%{limit: limit, offset: offset})
|
||||||
|
|
||||||
|
QueryHelpers.search_result(project_id, "", filters, fn post ->
|
||||||
|
post
|
||||||
|
|> Queries.post_summary()
|
||||||
|
|> Map.put("url", "/posts/#{post.slug}")
|
||||||
|
|> Map.put("updated_at", post.updated_at)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("list_media", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
limit = QueryHelpers.normalize_limit(arguments["limit"])
|
||||||
|
|
||||||
|
Repo.all(
|
||||||
|
from(media in Media,
|
||||||
|
where: media.project_id == ^project_id,
|
||||||
|
order_by: [desc: media.updated_at],
|
||||||
|
limit: ^limit,
|
||||||
|
select: %{
|
||||||
|
id: media.id,
|
||||||
|
title: media.title,
|
||||||
|
mime_type: media.mime_type,
|
||||||
|
filename: media.filename,
|
||||||
|
updated_at: media.updated_at
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("get_media", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
with_media(arguments, project_id, %{success: false, error: "not_found"}, fn media ->
|
||||||
|
%{media: media_summary(media)}
|
||||||
|
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 = QueryHelpers.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()
|
||||||
|
|
||||||
|
with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
|
||||||
|
%{success: true, post_id: post.id, linked_by: Queries.linked_posts(post.id, :incoming)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("get_post_outlinks", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
|
||||||
|
%{success: true, post_id: post.id, links_to: Queries.linked_posts(post.id, :outgoing)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("get_post_media", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
with_post(arguments, project_id, %{success: false, error: "not_found"}, fn post ->
|
||||||
|
%{success: true, post_id: post.id, media: post_media(project_id, post.id)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("get_media_posts", arguments, project_id) do
|
||||||
|
project_id = project_id || active_project_id()
|
||||||
|
|
||||||
|
with_media(arguments, project_id, %{success: false, error: "not_found"}, fn media ->
|
||||||
|
%{success: true, media_id: media.id, posts: media_posts(media.id)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("render_table", arguments, _project_id) do
|
||||||
|
%{
|
||||||
|
type: "table",
|
||||||
|
title: arguments["title"],
|
||||||
|
columns: arguments["columns"] || [],
|
||||||
|
rows: arguments["rows"] || []
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("render_chart", arguments, _project_id) do
|
||||||
|
%{
|
||||||
|
type: "chart",
|
||||||
|
title: arguments["title"],
|
||||||
|
chart_type: arguments["chartType"] || arguments["chart_type"] || "bar",
|
||||||
|
series: arguments["series"] || []
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("render_form", arguments, _project_id) do
|
||||||
|
%{
|
||||||
|
type: "form",
|
||||||
|
title: arguments["title"],
|
||||||
|
fields: arguments["fields"] || [],
|
||||||
|
submit_label: arguments["submit_label"] || arguments["submitLabel"],
|
||||||
|
submit_action: arguments["submit_action"] || arguments["submitAction"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("render_card", arguments, _project_id) do
|
||||||
|
%{
|
||||||
|
type: "card",
|
||||||
|
title: arguments["title"],
|
||||||
|
subtitle: arguments["subtitle"],
|
||||||
|
body: arguments["body"],
|
||||||
|
actions: arguments["actions"] || []
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("render_metric", arguments, _project_id) do
|
||||||
|
%{
|
||||||
|
type: "metric",
|
||||||
|
label: arguments["label"],
|
||||||
|
value: arguments["value"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("render_list", arguments, _project_id) do
|
||||||
|
%{
|
||||||
|
type: "list",
|
||||||
|
title: arguments["title"],
|
||||||
|
items: arguments["items"] || []
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("render_tabs", arguments, _project_id) do
|
||||||
|
%{
|
||||||
|
type: "tabs",
|
||||||
|
title: arguments["title"],
|
||||||
|
tabs: arguments["tabs"] || []
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute("render_mindmap", arguments, _project_id) do
|
||||||
|
%{
|
||||||
|
type: "mindmap",
|
||||||
|
title: arguments["title"],
|
||||||
|
nodes: arguments["nodes"] || []
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def execute(name, _arguments, _project_id) do
|
||||||
|
%{error: "unknown_tool", name: name}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec available_specs(String.t() | nil, map()) :: [map()]
|
||||||
|
def available_specs(project_id, capabilities) do
|
||||||
|
if capabilities.supports_tool_calls do
|
||||||
|
project_tools =
|
||||||
|
if is_binary(project_id) do
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
name: "blog_stats",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec("blog_stats", "Return aggregate blog statistics", %{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_blog_stats",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"get_blog_stats",
|
||||||
|
"Get comprehensive blog statistics: total posts, media count, unique tag count, and unique category count. Use this first when you need to understand the scope of the data.",
|
||||||
|
%{"type" => "object", "properties" => %{}}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "check_term",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"check_term",
|
||||||
|
"Check whether a term exists as a category, tag, or both. Returns post counts for each. Use before search_posts or list_posts when unsure whether a term is a category or tag.",
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{"term" => %{"type" => "string"}},
|
||||||
|
"required" => ["term"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "search_posts",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"search_posts",
|
||||||
|
"Search blog posts using full-text search. Can filter by category, tags, language, missing translation language, year, month, or status. Returns paginated concrete post data with titles, slugs, tags, categories, backlinks, and links_to.",
|
||||||
|
Schemas.post_search_schema(true)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "read_post",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"read_post",
|
||||||
|
"Read full content and metadata of a specific blog post by ID. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{"postId" => %{"type" => "string"}},
|
||||||
|
"required" => ["postId"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "read_post_by_slug",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"read_post_by_slug",
|
||||||
|
"Read full content and metadata of a specific blog post by slug. Includes backlinks, links_to, tags, categories, excerpt, status, language, and available languages.",
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{"slug" => %{"type" => "string"}},
|
||||||
|
"required" => ["slug"]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "list_posts",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"list_posts",
|
||||||
|
"List blog posts with optional filtering by status, category, tags, language, year, or month. Returns paginated concrete post data with titles, slugs, URLs, statuses, tags, categories, backlinks, and links_to. Use for recent, latest, top, or title-list requests.",
|
||||||
|
Schemas.post_search_schema(false)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_media",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"get_media",
|
||||||
|
"Get information about a specific media file by ID, including title, alt text, caption, tags, filename, MIME type, dimensions, and update time.",
|
||||||
|
Schemas.media_id_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "list_media",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"list_media",
|
||||||
|
"List concrete media data in the active project, including titles, filenames, MIME types, and update times.",
|
||||||
|
Schemas.limit_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "view_image",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"view_image",
|
||||||
|
"View an image thumbnail as a local data URL for visual inspection. Only works with image media files.",
|
||||||
|
Schemas.media_id_schema(%{
|
||||||
|
"size" => %{"type" => "string", "enum" => ["small", "medium", "large"]}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "update_post_metadata",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"update_post_metadata",
|
||||||
|
"Update metadata for a blog post: title, excerpt, tags, or categories. Does not update post body content.",
|
||||||
|
Schemas.update_post_metadata_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "update_media_metadata",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"update_media_metadata",
|
||||||
|
"Update metadata for a media file: title, alt text, caption, or tags.",
|
||||||
|
Schemas.update_media_metadata_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "list_tags",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"list_tags",
|
||||||
|
"List all tags used across blog posts with post counts.",
|
||||||
|
%{
|
||||||
|
"type" => "object",
|
||||||
|
"properties" => %{}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "list_categories",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"list_categories",
|
||||||
|
"List all categories used across blog posts with post counts.",
|
||||||
|
%{"type" => "object", "properties" => %{}}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "count_posts",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"count_posts",
|
||||||
|
"Count posts grouped by dimensions such as year, month, tag, category, or status. Use for analytics, distributions, and heat maps without transferring full post content.",
|
||||||
|
Schemas.count_posts_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_post_backlinks",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"get_post_backlinks",
|
||||||
|
"Get all posts that link to a specific post.",
|
||||||
|
Schemas.post_id_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_post_outlinks",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"get_post_outlinks",
|
||||||
|
"Get all posts that a specific post links to.",
|
||||||
|
Schemas.post_id_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_post_media",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"get_post_media",
|
||||||
|
"Get media files linked to a specific post.",
|
||||||
|
Schemas.post_id_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "get_media_posts",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"get_media_posts",
|
||||||
|
"Get posts that use a specific media file.",
|
||||||
|
Schemas.media_id_schema()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
project_tools ++
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
name: "render_card",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"render_card",
|
||||||
|
"Render an information card in the chat UI. Use this for displaying a summary, highlight, or actionable item.",
|
||||||
|
Schemas.render_card_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "render_table",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"render_table",
|
||||||
|
"Render a data table in the chat UI. Use this when the user asks for tabular data, comparisons, or structured information.",
|
||||||
|
Schemas.render_table_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "render_chart",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"render_chart",
|
||||||
|
"Render an interactive chart in the chat UI. Use this when the user asks for a chart, graph, or data visualization. Supports bar, stacked-bar, line, area, pie, donut, and heatmap charts. Use stacked-bar for multi-segment bars and heatmap for grid/matrix visualizations.",
|
||||||
|
Schemas.render_chart_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "render_form",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"render_form",
|
||||||
|
"Render an interactive form in the chat UI. Use this when you need to collect structured input from the user.",
|
||||||
|
Schemas.render_form_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "render_metric",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"render_metric",
|
||||||
|
"Render a single metric/KPI display in the chat UI. Use this for showing a single important value with a label.",
|
||||||
|
Schemas.render_metric_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "render_list",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"render_list",
|
||||||
|
"Render a list of items in the chat UI. Use this for displaying bullet-point style lists, checklists, or simple enumerations.",
|
||||||
|
Schemas.render_list_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "render_tabs",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"render_tabs",
|
||||||
|
"Render a tabbed interface in the chat UI. Use this to organize information into multiple tabs that the user can switch between.",
|
||||||
|
Schemas.render_tabs_schema()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
name: "render_mindmap",
|
||||||
|
spec:
|
||||||
|
Schemas.tool_spec(
|
||||||
|
"render_mindmap",
|
||||||
|
"Render a mind map diagram in the chat UI. Use this when the user asks for a mind map, concept map, topic tree, brainstorming diagram, or hierarchical overview of ideas.",
|
||||||
|
Schemas.render_mindmap_schema()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp with_post(arguments, project_id, not_found_result, success_fun) do
|
||||||
|
case Repo.get_by(Post,
|
||||||
|
id: arguments["postId"] || arguments["post_id"],
|
||||||
|
project_id: project_id
|
||||||
|
) do
|
||||||
|
%Post{} = post -> success_fun.(post)
|
||||||
|
nil -> not_found_result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp with_media(arguments, project_id, not_found_result, success_fun) do
|
||||||
|
case Repo.get_by(Media,
|
||||||
|
id: arguments["mediaId"] || arguments["media_id"],
|
||||||
|
project_id: project_id
|
||||||
|
) do
|
||||||
|
%Media{} = media -> success_fun.(media)
|
||||||
|
nil -> not_found_result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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 ->
|
||||||
|
BDS.MapUtils.maybe_put(
|
||||||
|
acc,
|
||||||
|
BDS.MapUtils.safe_atomize_key(key),
|
||||||
|
BDS.MapUtils.blank_to_nil(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
|
||||||
|
Repo.one(from(project in Project, where: project.is_active == true, select: project.id))
|
||||||
|
end
|
||||||
|
end
|
||||||
+132
-33
@@ -1,51 +1,150 @@
|
|||||||
defmodule BDS.AI.HttpClient do
|
defmodule BDS.AI.HttpClient do
|
||||||
@moduledoc false
|
@moduledoc """
|
||||||
|
Req-based HTTP client for AI endpoints.
|
||||||
|
|
||||||
|
Replaces the previous `:httpc` wrapper with explicit connect/receive
|
||||||
|
timeouts, TLS verification via Req's defaults, and transient retries for
|
||||||
|
idempotent GETs only — POSTs (chat completions) are never retried.
|
||||||
|
|
||||||
|
The response contract is unchanged: `{:ok, %{status, headers, body}}` with
|
||||||
|
downcased single-valued header names and the body as a raw binary (callers
|
||||||
|
decode JSON themselves), or `{:error, reason}` where transport failures
|
||||||
|
surface as plain reason atoms such as `:timeout` or `:econnrefused`.
|
||||||
|
|
||||||
|
Config (`config :bds, BDS.AI.HttpClient`):
|
||||||
|
|
||||||
|
* `:connect_timeout_ms` — TCP/TLS connect budget (default 5_000)
|
||||||
|
* `:receive_timeout_ms` — response budget (default 120_000; generous
|
||||||
|
because local LLM completions are slow)
|
||||||
|
* `:get_max_retries` — transient retries for GETs (default 2)
|
||||||
|
* `:retry_delay_ms` — constant delay between retries (default 500)
|
||||||
|
"""
|
||||||
|
|
||||||
|
@default_connect_timeout_ms 5_000
|
||||||
|
@default_receive_timeout_ms 120_000
|
||||||
|
@default_get_max_retries 2
|
||||||
|
@default_retry_delay_ms 500
|
||||||
|
|
||||||
|
@spec request_timeout_ms() :: pos_integer()
|
||||||
|
def request_timeout_ms do
|
||||||
|
max(
|
||||||
|
config(:connect_timeout_ms, @default_connect_timeout_ms),
|
||||||
|
config(:receive_timeout_ms, @default_receive_timeout_ms)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec get(String.t(), %{String.t() => String.t()}) ::
|
||||||
|
{:ok, %{status: non_neg_integer(), headers: map(), body: binary()}} | {:error, term()}
|
||||||
def get(url, headers) when is_binary(url) and is_map(headers) do
|
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)}
|
[
|
||||||
|
method: :get,
|
||||||
:inets.start()
|
url: url,
|
||||||
:ssl.start()
|
headers: headers,
|
||||||
|
retry: :transient,
|
||||||
case :httpc.request(:get, request, [], body_format: :binary) do
|
max_retries: config(:get_max_retries, @default_get_max_retries),
|
||||||
{:ok, {{_version, status, _reason}, response_headers, body}} ->
|
retry_delay: fn _retry_count -> config(:retry_delay_ms, @default_retry_delay_ms) end,
|
||||||
{:ok,
|
retry_log_level: false
|
||||||
%{
|
]
|
||||||
status: status,
|
|> Keyword.merge(base_options())
|
||||||
headers: normalize_headers(response_headers),
|
|> Req.request()
|
||||||
body: body
|
|> normalize_result()
|
||||||
}}
|
|
||||||
|
|
||||||
{:error, reason} ->
|
|
||||||
{:error, reason}
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec post(String.t(), %{String.t() => String.t()}, binary()) ::
|
||||||
|
{:ok, %{status: non_neg_integer(), headers: map(), body: binary()}} | {:error, term()}
|
||||||
def post(url, headers, body)
|
def post(url, headers, body)
|
||||||
when is_binary(url) and is_map(headers) and is_binary(body) do
|
when is_binary(url) and is_map(headers) and is_binary(body) do
|
||||||
request =
|
[
|
||||||
{String.to_charlist(url), Enum.map(headers, fn {key, value} -> {String.to_charlist(key), String.to_charlist(value)} end), ~c"application/json", body}
|
method: :post,
|
||||||
|
url: url,
|
||||||
|
headers: headers,
|
||||||
|
body: body,
|
||||||
|
# Completions are not idempotent; a retry could bill or generate twice.
|
||||||
|
retry: false
|
||||||
|
]
|
||||||
|
|> Keyword.merge(base_options())
|
||||||
|
|> Req.request()
|
||||||
|
|> normalize_result()
|
||||||
|
end
|
||||||
|
|
||||||
:inets.start()
|
@doc """
|
||||||
:ssl.start()
|
Streaming POST: body chunks of a 200 response are folded into `acc` via
|
||||||
|
`reducer.(chunk, acc)` as they arrive; non-200 bodies are collected whole
|
||||||
|
for error reporting. Returns the final accumulator alongside the response.
|
||||||
|
|
||||||
case :httpc.request(:post, request, [], body_format: :binary) do
|
Never retried (same reasoning as `post/3`), and `accept-encoding` is
|
||||||
{:ok, {{_version, status, _reason}, response_headers, response_body}} ->
|
disabled so event-stream chunks arrive uncompressed. The request runs in
|
||||||
{:ok,
|
the calling process — killing that process aborts the underlying
|
||||||
%{
|
connection, which is what makes mid-stream chat cancellation work.
|
||||||
status: status,
|
"""
|
||||||
headers: normalize_headers(response_headers),
|
@spec post_stream(String.t(), %{String.t() => String.t()}, binary(), acc, (binary(), acc ->
|
||||||
body: response_body
|
acc)) ::
|
||||||
}}
|
{:ok, %{status: non_neg_integer(), headers: map(), body: binary()}, acc}
|
||||||
|
| {:error, term()}
|
||||||
|
when acc: term()
|
||||||
|
def post_stream(url, headers, body, acc, reducer)
|
||||||
|
when is_binary(url) and is_map(headers) and is_binary(body) and is_function(reducer, 2) do
|
||||||
|
into = fn {:data, data}, {req, resp} ->
|
||||||
|
resp =
|
||||||
|
if resp.status == 200 do
|
||||||
|
next_acc = reducer.(data, Req.Response.get_private(resp, :bds_stream_acc, acc))
|
||||||
|
Req.Response.put_private(resp, :bds_stream_acc, next_acc)
|
||||||
|
else
|
||||||
|
%{resp | body: collected_body(resp.body) <> data}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:cont, {req, resp}}
|
||||||
|
end
|
||||||
|
|
||||||
|
[
|
||||||
|
method: :post,
|
||||||
|
url: url,
|
||||||
|
headers: headers,
|
||||||
|
body: body,
|
||||||
|
retry: false,
|
||||||
|
compressed: false,
|
||||||
|
into: into
|
||||||
|
]
|
||||||
|
|> Keyword.merge(base_options())
|
||||||
|
|> Req.request()
|
||||||
|
|> case do
|
||||||
|
{:ok, %Req.Response{} = resp} ->
|
||||||
|
{:ok, %{status: resp.status, headers: normalize_headers(resp.headers), body: collected_body(resp.body)},
|
||||||
|
Req.Response.get_private(resp, :bds_stream_acc, acc)}
|
||||||
|
|
||||||
|
{:error, %Req.TransportError{reason: reason}} ->
|
||||||
|
{:error, reason}
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
{:error, reason}
|
{:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp collected_body(body) when is_binary(body), do: body
|
||||||
|
defp collected_body(_body), do: ""
|
||||||
|
|
||||||
|
defp base_options do
|
||||||
|
[
|
||||||
|
connect_options: [timeout: config(:connect_timeout_ms, @default_connect_timeout_ms)],
|
||||||
|
receive_timeout: config(:receive_timeout_ms, @default_receive_timeout_ms),
|
||||||
|
# Callers parse the body themselves; keep it a raw binary.
|
||||||
|
decode_body: false
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_result({:ok, %Req.Response{status: status, headers: headers, body: body}}) do
|
||||||
|
{:ok, %{status: status, headers: normalize_headers(headers), body: body}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_result({:error, %Req.TransportError{reason: reason}}), do: {:error, reason}
|
||||||
|
defp normalize_result({:error, reason}), do: {:error, reason}
|
||||||
|
|
||||||
|
# Req header names are already downcased; values arrive as lists.
|
||||||
defp normalize_headers(headers) do
|
defp normalize_headers(headers) do
|
||||||
Enum.into(headers, %{}, fn {key, value} ->
|
Map.new(headers, fn {name, values} -> {name, Enum.join(List.wrap(values), ", ")} end)
|
||||||
{key |> to_string() |> String.downcase(), to_string(value)}
|
end
|
||||||
end)
|
|
||||||
|
defp config(key, default) do
|
||||||
|
Application.get_env(:bds, __MODULE__, []) |> Keyword.get(key, default)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
+19
-10
@@ -1,29 +1,38 @@
|
|||||||
defmodule BDS.AI.InFlight do
|
defmodule BDS.AI.InFlight do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
# Registry of in-flight chat tasks keyed by conversation id. The named ETS
|
||||||
|
# table is owned by this supervised GenServer (started from the application
|
||||||
|
# supervision tree), so registrations survive the exit of the registering
|
||||||
|
# process and there is no creation race between concurrent first callers.
|
||||||
|
use GenServer
|
||||||
|
|
||||||
@table :bds_ai_in_flight
|
@table :bds_ai_in_flight
|
||||||
|
|
||||||
|
def start_link(opts) do
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(_opts) do
|
||||||
|
table = :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
|
||||||
|
{:ok, table}
|
||||||
|
end
|
||||||
|
|
||||||
def register(conversation_id, pid) when is_binary(conversation_id) and is_pid(pid) do
|
def register(conversation_id, pid) when is_binary(conversation_id) and is_pid(pid) do
|
||||||
:ets.insert(table(), {conversation_id, pid})
|
:ets.insert(@table, {conversation_id, pid})
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
def unregister(conversation_id) when is_binary(conversation_id) do
|
def unregister(conversation_id) when is_binary(conversation_id) do
|
||||||
:ets.delete(table(), conversation_id)
|
:ets.delete(@table, conversation_id)
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
def lookup(conversation_id) when is_binary(conversation_id) do
|
def lookup(conversation_id) when is_binary(conversation_id) do
|
||||||
case :ets.lookup(table(), conversation_id) do
|
case :ets.lookup(@table, conversation_id) do
|
||||||
[{^conversation_id, pid}] -> pid
|
[{^conversation_id, pid}] -> pid
|
||||||
_other -> nil
|
_other -> nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp table do
|
|
||||||
case :ets.whereis(@table) do
|
|
||||||
:undefined -> :ets.new(@table, [:named_table, :public, :set, read_concurrency: true])
|
|
||||||
table -> table
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
defmodule BDS.AI.JsonContent do
|
||||||
|
@moduledoc """
|
||||||
|
Decodes JSON object payloads from model responses, tolerating the markdown
|
||||||
|
code fences and surrounding prose that smaller (local) models often emit
|
||||||
|
instead of bare JSON.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@fence_pattern ~r/```(?:json)?\s*\n?(.*?)```/is
|
||||||
|
|
||||||
|
@spec decode(term()) :: map() | nil
|
||||||
|
def decode(content) when is_binary(content) do
|
||||||
|
decode_strict(content) || decode_fenced(content) || decode_embedded_object(content)
|
||||||
|
end
|
||||||
|
|
||||||
|
def decode(_content), do: nil
|
||||||
|
|
||||||
|
defp decode_strict(content) do
|
||||||
|
case Jason.decode(content) do
|
||||||
|
{:ok, decoded} when is_map(decoded) -> decoded
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_fenced(content) do
|
||||||
|
case Regex.run(@fence_pattern, content, capture: :all_but_first) do
|
||||||
|
[inner] -> decode_strict(String.trim(inner)) || decode_embedded_object(inner)
|
||||||
|
_no_fence -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_embedded_object(content) do
|
||||||
|
with {start, _length} <- :binary.match(content, "{"),
|
||||||
|
[{last, _} | _] <- content |> :binary.matches("}") |> Enum.take(-1),
|
||||||
|
true <- last > start do
|
||||||
|
content
|
||||||
|
|> binary_part(start, last - start + 1)
|
||||||
|
|> decode_strict()
|
||||||
|
else
|
||||||
|
_no_object -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
+15
-3
@@ -34,7 +34,9 @@ defmodule BDS.AI.Model do
|
|||||||
|
|
||||||
def changeset(model, attrs) do
|
def changeset(model, attrs) do
|
||||||
model
|
model
|
||||||
|> cast(attrs, [
|
|> cast(
|
||||||
|
attrs,
|
||||||
|
[
|
||||||
:provider,
|
:provider,
|
||||||
:model_id,
|
:model_id,
|
||||||
:name,
|
:name,
|
||||||
@@ -58,7 +60,17 @@ defmodule BDS.AI.Model do
|
|||||||
:interleaved,
|
:interleaved,
|
||||||
:status,
|
:status,
|
||||||
:updated_at
|
:updated_at
|
||||||
], empty_values: [nil])
|
],
|
||||||
|> validate_required([:provider, :model_id, :name, :context_window, :max_input_tokens, :max_output_tokens, :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
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
defmodule BDS.AI.OneShot do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias BDS.AI.Chat
|
||||||
|
alias BDS.AI.JsonContent
|
||||||
|
alias BDS.AI.OpenAICompatibleRuntime
|
||||||
|
alias BDS.AI.Runtime
|
||||||
|
alias BDS.Media.Media
|
||||||
|
alias BDS.MapUtils
|
||||||
|
alias BDS.Posts
|
||||||
|
alias BDS.Posts.Post
|
||||||
|
alias BDS.Projects
|
||||||
|
alias BDS.Repo
|
||||||
|
|
||||||
|
@default_max_output_tokens 16_384
|
||||||
|
|
||||||
|
@spec detect_language(String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
|
def detect_language(text, opts \\ []) when is_binary(text) and is_list(opts) do
|
||||||
|
run_one_shot(
|
||||||
|
:detect_language,
|
||||||
|
%{text: text},
|
||||||
|
opts,
|
||||||
|
fn json, usage ->
|
||||||
|
{:ok, %{language_code: json["language_code"], usage: usage}}
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec analyze_taxonomy(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
|
def analyze_taxonomy(post_input, opts \\ []) when is_list(opts) do
|
||||||
|
with {:ok, post} <- normalize_post_input(post_input) do
|
||||||
|
run_one_shot(
|
||||||
|
:analyze_taxonomy,
|
||||||
|
post,
|
||||||
|
opts,
|
||||||
|
fn json, usage ->
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
tags: json["tags"] || [],
|
||||||
|
categories: json["categories"] || [],
|
||||||
|
usage: usage
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec analyze_import_taxonomy(map(), map(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
|
def analyze_import_taxonomy(import_terms, existing_terms, opts \\ [])
|
||||||
|
when is_map(import_terms) and is_map(existing_terms) and is_list(opts) do
|
||||||
|
payload = %{
|
||||||
|
import_categories: normalize_string_list(MapUtils.attr(import_terms, :categories)),
|
||||||
|
import_tags: normalize_string_list(MapUtils.attr(import_terms, :tags)),
|
||||||
|
existing_categories: normalize_string_list(MapUtils.attr(existing_terms, :categories)),
|
||||||
|
existing_tags: normalize_string_list(MapUtils.attr(existing_terms, :tags))
|
||||||
|
}
|
||||||
|
|
||||||
|
run_one_shot(
|
||||||
|
:import_taxonomy_mapping,
|
||||||
|
payload,
|
||||||
|
opts,
|
||||||
|
fn json, usage ->
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
category_mappings:
|
||||||
|
filter_taxonomy_mapping_response(
|
||||||
|
json["categoryMappings"] || json["category_mappings"],
|
||||||
|
payload.import_categories,
|
||||||
|
payload.existing_categories
|
||||||
|
),
|
||||||
|
tag_mappings:
|
||||||
|
filter_taxonomy_mapping_response(
|
||||||
|
json["tagMappings"] || json["tag_mappings"],
|
||||||
|
payload.import_tags,
|
||||||
|
payload.existing_tags
|
||||||
|
),
|
||||||
|
usage: usage
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec analyze_post(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
|
def analyze_post(post_input, opts \\ []) when is_list(opts) do
|
||||||
|
with {:ok, post} <- normalize_post_input(post_input) do
|
||||||
|
run_one_shot(
|
||||||
|
:analyze_post,
|
||||||
|
post,
|
||||||
|
opts,
|
||||||
|
fn json, usage ->
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
title: json["title"],
|
||||||
|
excerpt: json["excerpt"],
|
||||||
|
slug: json["slug"],
|
||||||
|
usage: usage
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec translate_post(map() | String.t(), String.t(), keyword()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
|
def translate_post(post_input, target_language, opts \\ [])
|
||||||
|
when is_binary(target_language) and is_list(opts) do
|
||||||
|
with {:ok, post} <- normalize_post_input(post_input) do
|
||||||
|
run_one_shot(
|
||||||
|
:translate_post,
|
||||||
|
Map.put(post, :target_language, target_language),
|
||||||
|
opts,
|
||||||
|
fn json, usage ->
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
title: json["title"],
|
||||||
|
excerpt: json["excerpt"],
|
||||||
|
content: json["content"],
|
||||||
|
usage: usage
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec analyze_image(map() | String.t(), keyword()) :: {:ok, map()} | {:error, term()}
|
||||||
|
def analyze_image(media_input, opts \\ []) when is_list(opts) do
|
||||||
|
with {:ok, media} <- normalize_media_input(media_input),
|
||||||
|
:ok <- ensure_image_media(media) do
|
||||||
|
run_one_shot(
|
||||||
|
:analyze_image,
|
||||||
|
media,
|
||||||
|
opts,
|
||||||
|
fn json, usage ->
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
title: json["title"],
|
||||||
|
alt: json["alt"],
|
||||||
|
caption: json["caption"],
|
||||||
|
usage: usage
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec translate_media(map() | String.t(), String.t(), keyword()) ::
|
||||||
|
{:ok, map()} | {:error, term()}
|
||||||
|
def translate_media(media_input, target_language, opts \\ [])
|
||||||
|
when is_binary(target_language) and is_list(opts) do
|
||||||
|
with {:ok, media} <- normalize_media_input(media_input) do
|
||||||
|
run_one_shot(
|
||||||
|
:translate_media,
|
||||||
|
Map.put(media, :target_language, target_language),
|
||||||
|
opts,
|
||||||
|
fn json, usage ->
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
title: json["title"],
|
||||||
|
alt: json["alt"],
|
||||||
|
caption: json["caption"],
|
||||||
|
usage: usage
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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),
|
||||||
|
{:ok, prepared_payload} <- prepare_one_shot_payload(operation, payload),
|
||||||
|
request <- build_one_shot_request(operation, prepared_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 prepare_one_shot_payload(:analyze_image, payload), do: resolve_image_data_url(payload)
|
||||||
|
defp prepare_one_shot_payload(_operation, payload), do: {:ok, payload}
|
||||||
|
|
||||||
|
defp build_one_shot_request(operation, payload, model, opts) do
|
||||||
|
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,
|
||||||
|
model: model,
|
||||||
|
max_output_tokens: @default_max_output_tokens,
|
||||||
|
messages: [
|
||||||
|
%{
|
||||||
|
"role" => "system",
|
||||||
|
"content" =>
|
||||||
|
one_shot_system_prompt(operation, language, source_language) <>
|
||||||
|
" Output raw JSON only, without markdown code fences."
|
||||||
|
},
|
||||||
|
%{
|
||||||
|
"role" => "user",
|
||||||
|
"content" => one_shot_user_content(operation, payload, language, source_language)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp one_shot_system_prompt(:detect_language, _language, _source_language) do
|
||||||
|
"Return JSON with exactly one key: language_code."
|
||||||
|
end
|
||||||
|
|
||||||
|
defp one_shot_system_prompt(:analyze_taxonomy, _language, _source_language) do
|
||||||
|
"Return JSON with keys tags and categories, each an array of short strings."
|
||||||
|
end
|
||||||
|
|
||||||
|
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."
|
||||||
|
end
|
||||||
|
|
||||||
|
defp one_shot_system_prompt(:analyze_post, nil, _source_language) do
|
||||||
|
"Return JSON with keys title, excerpt, and slug. Each value must be a single string (not an array or object)."
|
||||||
|
end
|
||||||
|
|
||||||
|
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."
|
||||||
|
end
|
||||||
|
|
||||||
|
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."
|
||||||
|
end
|
||||||
|
|
||||||
|
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."
|
||||||
|
end
|
||||||
|
|
||||||
|
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}"
|
||||||
|
end
|
||||||
|
|
||||||
|
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)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
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.",
|
||||||
|
"",
|
||||||
|
"Imported categories:",
|
||||||
|
Enum.join(payload.import_categories, ", "),
|
||||||
|
"",
|
||||||
|
"Imported tags:",
|
||||||
|
Enum.join(payload.import_tags, ", "),
|
||||||
|
"",
|
||||||
|
"Existing project categories:",
|
||||||
|
Enum.join(payload.existing_categories, ", "),
|
||||||
|
"",
|
||||||
|
"Existing project tags:",
|
||||||
|
Enum.join(payload.existing_tags, ", "),
|
||||||
|
"",
|
||||||
|
"Return JSON only."
|
||||||
|
]
|
||||||
|
|> Enum.join("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
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)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp one_shot_user_content(:analyze_post, post, language, _source_language) do
|
||||||
|
"Suggest an improved title, excerpt, and slug in #{language_name(language)}.\nTitle: #{post.title}\nExcerpt: #{post.excerpt}\nContent: #{truncate_text(post.content, 2000)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
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" => "image_url", "image_url" => %{"url" => media.image_url}}
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp one_shot_user_content(:analyze_image, media, language, _source_language) do
|
||||||
|
[
|
||||||
|
%{
|
||||||
|
"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
|
||||||
|
|
||||||
|
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(%{content: content}) when is_binary(content) do
|
||||||
|
case JsonContent.decode(content) do
|
||||||
|
json when is_map(json) ->
|
||||||
|
{:ok, json}
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
title: post.title || "",
|
||||||
|
excerpt: post.excerpt || "",
|
||||||
|
content: Posts.editor_body(post),
|
||||||
|
language: post.language || ""
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_post_input(post_id) when is_binary(post_id) do
|
||||||
|
case Repo.get(Post, post_id) do
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
post -> normalize_post_input(post)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_post_input(attrs) when is_map(attrs) do
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
title: MapUtils.attr(attrs, :title) || "",
|
||||||
|
excerpt: MapUtils.attr(attrs, :excerpt) || "",
|
||||||
|
content: MapUtils.attr(attrs, :content) || "",
|
||||||
|
language: MapUtils.attr(attrs, :language) || ""
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_media_input(%Media{} = media) do
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
mime_type: media.mime_type,
|
||||||
|
title: media.title || "",
|
||||||
|
alt: media.alt || "",
|
||||||
|
caption: media.caption || "",
|
||||||
|
# A stored media row has no remote URL; resolve_image_data_url/1 fills
|
||||||
|
# this from file_path before an :analyze_image request is built.
|
||||||
|
image_url: nil,
|
||||||
|
file_path: media.file_path,
|
||||||
|
project_id: media.project_id,
|
||||||
|
language: media.language || ""
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_media_input(media_id) when is_binary(media_id) do
|
||||||
|
case Repo.get(Media, media_id) do
|
||||||
|
nil -> {:error, :not_found}
|
||||||
|
media -> normalize_media_input(media)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_media_input(attrs) when is_map(attrs) do
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
mime_type: MapUtils.attr(attrs, :mime_type),
|
||||||
|
title: MapUtils.attr(attrs, :title) || "",
|
||||||
|
alt: MapUtils.attr(attrs, :alt) || "",
|
||||||
|
caption: MapUtils.attr(attrs, :caption) || "",
|
||||||
|
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
|
||||||
|
|
||||||
|
defp ensure_image_media(%{mime_type: "image/" <> _rest}), do: :ok
|
||||||
|
defp ensure_image_media(_media), do: {:error, %{kind: :invalid_media_type}}
|
||||||
|
|
||||||
|
defp resolve_image_data_url(%{image_url: "data:" <> _} = media) do
|
||||||
|
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
|
||||||
|
values
|
||||||
|
|> List.wrap()
|
||||||
|
|> Enum.map(&to_string/1)
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
|> Enum.uniq()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_taxonomy_mapping_response(mappings, import_terms, existing_terms)
|
||||||
|
when is_map(mappings) do
|
||||||
|
import_lookup = canonical_term_lookup(import_terms)
|
||||||
|
existing_lookup = canonical_term_lookup(existing_terms)
|
||||||
|
|
||||||
|
Enum.reduce(mappings, %{}, fn {source, target}, acc ->
|
||||||
|
with {:ok, canonical_source} <- resolve_canonical_term(source, import_lookup),
|
||||||
|
{:ok, canonical_target} <- resolve_canonical_term(target, existing_lookup) do
|
||||||
|
Map.put(acc, canonical_source, canonical_target)
|
||||||
|
else
|
||||||
|
_other -> acc
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp filter_taxonomy_mapping_response(_mappings, _import_terms, _existing_terms), do: %{}
|
||||||
|
|
||||||
|
defp canonical_term_lookup(terms) do
|
||||||
|
Map.new(terms, fn term -> {normalize_term(term), term} end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_canonical_term(term, lookup) do
|
||||||
|
case Map.get(lookup, normalize_term(term)) do
|
||||||
|
nil -> :error
|
||||||
|
canonical -> {:ok, canonical}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_term(term) do
|
||||||
|
term
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.downcase()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp truncate_text(nil, _max_length), do: ""
|
||||||
|
|
||||||
|
defp truncate_text(text, max_length) when is_binary(text) do
|
||||||
|
if String.length(text) <= max_length do
|
||||||
|
text
|
||||||
|
else
|
||||||
|
String.slice(text, 0, max_length)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
defmodule BDS.AI.OpenAICompatibleRuntime do
|
defmodule BDS.AI.OpenAICompatibleRuntime do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
alias BDS.AI.HttpClient
|
alias BDS.AI.HttpClient
|
||||||
|
alias BDS.AI.SSE
|
||||||
|
|
||||||
def list_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
|
def list_models(endpoint, opts \\ []) when is_map(endpoint) and is_list(opts) do
|
||||||
http_client = Keyword.get(opts, :http_client, HttpClient)
|
http_client = Keyword.get(opts, :http_client, HttpClient)
|
||||||
url = models_url(endpoint.url)
|
url = models_url(endpoint.url)
|
||||||
|
|
||||||
headers =
|
headers = request_headers(endpoint.api_key, false)
|
||||||
%{"accept" => "application/json"}
|
|
||||||
|> maybe_put_auth(endpoint.api_key)
|
|
||||||
|
|
||||||
with {:ok, response} <- http_client.get(url, headers),
|
with {:ok, response} <- http_client.get(url, headers),
|
||||||
200 <- response.status do
|
200 <- response.status do
|
||||||
@@ -20,51 +21,143 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def generate(endpoint, request, _opts) when is_map(endpoint) and is_map(request) do
|
def generate(endpoint, request, opts) when is_map(endpoint) and is_map(request) do
|
||||||
url = completions_url(endpoint.url)
|
url = completions_url(endpoint.url)
|
||||||
|
|
||||||
headers =
|
headers = request_headers(endpoint.api_key, true)
|
||||||
%{
|
payload = base_payload(request)
|
||||||
"content-type" => "application/json",
|
|
||||||
"accept" => "application/json"
|
|
||||||
}
|
|
||||||
|> maybe_put_auth(endpoint.api_key)
|
|
||||||
|
|
||||||
payload = %{
|
if stream?(request, opts) do
|
||||||
"model" => request.model,
|
generate_streaming(url, headers, payload, request, Keyword.fetch!(opts, :on_stream))
|
||||||
"messages" => request.messages,
|
|
||||||
"max_tokens" => request.max_output_tokens
|
|
||||||
}
|
|
||||||
|> maybe_put_tools(request.tools)
|
|
||||||
|
|
||||||
with {:ok, response} <- HttpClient.post(url, headers, Jason.encode!(payload)),
|
|
||||||
200 <- response.status do
|
|
||||||
normalize_response(response.body)
|
|
||||||
else
|
else
|
||||||
status when is_integer(status) -> {:error, %{kind: :http_error, status: status}}
|
generate_blocking(url, headers, payload, request)
|
||||||
{:error, reason} -> {:error, %{kind: :http_error, reason: reason}}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp generate_blocking(url, headers, payload, request) do
|
||||||
|
payload_json = Jason.encode!(payload)
|
||||||
|
|
||||||
|
Logger.debug(
|
||||||
|
"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)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
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}} ->
|
||||||
|
log_http_status_error("AI OpenAI-compatible", status, body)
|
||||||
|
http_status_error(status, body)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
log_http_request_error("AI OpenAI-compatible", reason)
|
||||||
|
http_request_error(reason)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Streaming variant: same request payload plus stream flags; SSE chunks are
|
||||||
|
# folded into a BDS.AI.SSE assembler that emits cumulative content
|
||||||
|
# snapshots to `on_stream` as they arrive. The assembled message goes
|
||||||
|
# through the same normalization as the blocking path.
|
||||||
|
defp generate_streaming(url, headers, payload, request, on_stream) do
|
||||||
|
payload_json = payload |> streaming_payload() |> Jason.encode!()
|
||||||
|
|
||||||
|
Logger.debug(
|
||||||
|
"AI OpenAI-compatible streaming request operation=#{inspect(Map.get(request, :operation))} model=#{inspect(request.model)} url=#{url} payload_size=#{byte_size(payload_json)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
sse = SSE.new(on_stream, emit_interval_ms: stream_emit_interval_ms())
|
||||||
|
|
||||||
|
case HttpClient.post_stream(url, headers, payload_json, sse, fn chunk, acc ->
|
||||||
|
SSE.feed(acc, chunk)
|
||||||
|
end) do
|
||||||
|
{:ok, %{status: 200, headers: response_headers}, sse} ->
|
||||||
|
if event_stream?(response_headers) do
|
||||||
|
assembled = SSE.finish(sse)
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
content: assembled.content,
|
||||||
|
json: decode_json_content(assembled.content),
|
||||||
|
tool_calls: normalize_tool_calls(assembled.tool_calls),
|
||||||
|
usage: normalize_usage(assembled.usage || %{})
|
||||||
|
}}
|
||||||
|
else
|
||||||
|
# The provider ignored the stream flag and sent a plain completion.
|
||||||
|
normalize_response(SSE.raw_body(sse))
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, %{status: status, body: body}, _sse} ->
|
||||||
|
log_http_status_error("AI OpenAI-compatible streaming", status, body)
|
||||||
|
http_status_error(status, body)
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
log_http_request_error("AI OpenAI-compatible streaming", reason)
|
||||||
|
http_request_error(reason)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Streaming is opt-in per request (the caller passes :on_stream), limited
|
||||||
|
# to interactive chat, and can be disabled globally for providers that do
|
||||||
|
# not support SSE (config :bds, :chat, streaming: false).
|
||||||
|
defp stream?(request, opts) do
|
||||||
|
Map.get(request, :operation) == :chat and
|
||||||
|
is_function(Keyword.get(opts, :on_stream), 1) and
|
||||||
|
chat_config(:streaming, true)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp stream_emit_interval_ms, do: chat_config(:stream_emit_interval_ms, 100)
|
||||||
|
|
||||||
|
defp event_stream?(headers) do
|
||||||
|
case headers["content-type"] do
|
||||||
|
content_type when is_binary(content_type) ->
|
||||||
|
String.contains?(content_type, "text/event-stream")
|
||||||
|
|
||||||
|
_missing ->
|
||||||
|
# No content type: trust the request we made and parse as SSE.
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp chat_config(key, default) do
|
||||||
|
:bds |> Application.get_env(:chat, []) |> Keyword.get(key, default)
|
||||||
|
end
|
||||||
|
|
||||||
defp normalize_response(body) do
|
defp normalize_response(body) do
|
||||||
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 =
|
{:ok,
|
||||||
case content do
|
%{
|
||||||
nil -> nil
|
content: content,
|
||||||
value when is_binary(value) ->
|
json: decode_json_content(content),
|
||||||
case Jason.decode(value) do
|
tool_calls: tool_calls,
|
||||||
{:ok, decoded} when is_map(decoded) -> decoded
|
usage: usage
|
||||||
_other -> nil
|
}}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
{:ok, %{content: content, json: json, tool_calls: tool_calls, usage: usage}}
|
defp decode_json_content(content), do: BDS.AI.JsonContent.decode(content)
|
||||||
end
|
|
||||||
|
|
||||||
defp completions_url(url) do
|
defp completions_url(url) do
|
||||||
cond do
|
cond do
|
||||||
@@ -76,16 +169,22 @@ 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 =
|
models =
|
||||||
payload
|
payload
|
||||||
|> Map.get("data", [])
|
|> Map.get("data", [])
|
||||||
@@ -103,10 +202,58 @@ defmodule BDS.AI.OpenAICompatibleRuntime do
|
|||||||
|
|
||||||
{:ok, models}
|
{:ok, models}
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
defp request_headers(api_key, include_content_type?) do
|
||||||
|
%{"accept" => "application/json"}
|
||||||
|
|> maybe_put_content_type(include_content_type?)
|
||||||
|
|> maybe_put_auth(api_key)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_put_content_type(headers, true),
|
||||||
|
do: Map.put(headers, "content-type", "application/json")
|
||||||
|
|
||||||
|
defp maybe_put_content_type(headers, false), do: headers
|
||||||
|
|
||||||
|
defp base_payload(request) do
|
||||||
|
%{
|
||||||
|
"model" => request.model,
|
||||||
|
"messages" => request.messages,
|
||||||
|
"max_tokens" => request.max_output_tokens
|
||||||
|
}
|
||||||
|
|> maybe_disable_thinking(request.model)
|
||||||
|
|> maybe_put_tools(Map.get(request, :tools, []))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp streaming_payload(payload) do
|
||||||
|
payload
|
||||||
|
|> Map.put("stream", true)
|
||||||
|
|> Map.put("stream_options", %{"include_usage" => true})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp log_http_status_error(prefix, status, body) do
|
||||||
|
Logger.error("#{prefix} HTTP error status=#{status} body=#{String.slice(body, 0, 2000)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp log_http_request_error(prefix, reason) do
|
||||||
|
Logger.error("#{prefix} request failed: #{inspect(reason)}")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp http_status_error(status, body), do: {:error, %{kind: :http_error, status: status, body: body}}
|
||||||
|
defp http_request_error(reason), do: {:error, %{kind: :http_error, reason: reason}}
|
||||||
|
|
||||||
defp maybe_put_auth(headers, nil), do: headers
|
defp maybe_put_auth(headers, nil), do: headers
|
||||||
defp maybe_put_auth(headers, ""), do: headers
|
defp maybe_put_auth(headers, ""), do: headers
|
||||||
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 +263,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 ->
|
||||||
%{
|
%{
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
defmodule BDS.AI.Runtime do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
alias BDS.AI
|
||||||
|
alias BDS.AI.Catalog
|
||||||
|
alias BDS.AI.SecretBackend
|
||||||
|
|
||||||
|
@model_preference_keys %{
|
||||||
|
default: "ai.model.default",
|
||||||
|
chat: "ai.model.chat",
|
||||||
|
title: "ai.model.title",
|
||||||
|
image_analysis: "ai.model.image_analysis",
|
||||||
|
airplane_chat: "ai.airplane.model.chat",
|
||||||
|
airplane_title: "ai.airplane.model.title",
|
||||||
|
airplane_image_analysis: "ai.airplane.model.image_analysis"
|
||||||
|
}
|
||||||
|
|
||||||
|
@spec model_preference_keys() :: %{atom() => String.t()}
|
||||||
|
def model_preference_keys, do: @model_preference_keys
|
||||||
|
|
||||||
|
@spec resolve_target(atom(), keyword()) ::
|
||||||
|
{:ok, map(), String.t(), :airplane | :online} | {:error, term()}
|
||||||
|
def resolve_target(operation, extra) do
|
||||||
|
mode = if AI.airplane_mode?(), do: :airplane, else: :online
|
||||||
|
secret_backend = Keyword.get(extra, :secret_backend, SecretBackend)
|
||||||
|
|
||||||
|
with {:ok, endpoint} <- fetch_endpoint_for_mode(mode, secret_backend),
|
||||||
|
{:ok, model} <- resolve_model_for_operation(operation, mode, endpoint, extra) do
|
||||||
|
{:ok, endpoint, model, mode}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec validate_target(atom(), String.t(), :airplane | :online) :: :ok | {:error, term()}
|
||||||
|
def validate_target(:analyze_image, model, _mode) do
|
||||||
|
capabilities = Catalog.model_capabilities(model)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
capabilities.supports_attachment ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
capabilities.supports_attachment == false ->
|
||||||
|
{:error,
|
||||||
|
%{kind: :model_capability_missing, capability: :supports_attachment, model: model}}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_target(_operation, _model, _mode), do: :ok
|
||||||
|
|
||||||
|
@spec endpoint_with_model(map(), String.t()) :: map()
|
||||||
|
def endpoint_with_model(endpoint, model), do: Map.put(endpoint, :model, model)
|
||||||
|
|
||||||
|
@spec model_preference_value(atom()) :: String.t() | nil
|
||||||
|
def model_preference_value(key) do
|
||||||
|
case AI.get_model_preference(key) do
|
||||||
|
{:ok, value} -> value
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_model_for_operation(:chat, :airplane, endpoint, _extra) do
|
||||||
|
{:ok, model_preference_value(:airplane_chat) || endpoint.model}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_model_for_operation(:chat, :online, endpoint, conversation: conversation) do
|
||||||
|
{:ok, conversation.model || model_preference_value(:chat) || endpoint.model}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_model_for_operation(:chat, :online, endpoint, _extra) do
|
||||||
|
{:ok, model_preference_value(:chat) || endpoint.model}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_model_for_operation(:analyze_image, :airplane, endpoint, extra) do
|
||||||
|
{:ok,
|
||||||
|
Keyword.get(extra, :model) || model_preference_value(:airplane_image_analysis) ||
|
||||||
|
endpoint.model}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_model_for_operation(:analyze_image, :online, endpoint, extra) do
|
||||||
|
{:ok, Keyword.get(extra, :model) || model_preference_value(:image_analysis) || endpoint.model}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_model_for_operation(_operation, :airplane, endpoint, extra) do
|
||||||
|
{:ok, Keyword.get(extra, :model) || model_preference_value(:airplane_title) || endpoint.model}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_model_for_operation(_operation, :online, endpoint, extra) do
|
||||||
|
{:ok, Keyword.get(extra, :model) || model_preference_value(:title) || endpoint.model}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_endpoint_for_mode(mode, secret_backend) do
|
||||||
|
with {:ok, endpoint} <- AI.get_endpoint(mode, secret_backend: secret_backend) do
|
||||||
|
case endpoint do
|
||||||
|
%{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
|
||||||
|
{:error, %{kind: :endpoint_not_configured, endpoint: mode}}
|
||||||
|
else
|
||||||
|
{:ok, loaded}
|
||||||
|
end
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
{:error, %{kind: :endpoint_not_configured, endpoint: mode}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp blank?(value), do: BDS.Values.blank?(value)
|
||||||
|
end
|
||||||
@@ -1,10 +1,34 @@
|
|||||||
defmodule BDS.AI.SecretBackend do
|
defmodule BDS.AI.SecretBackend do
|
||||||
@moduledoc false
|
@moduledoc """
|
||||||
|
Encrypts and decrypts AI provider secrets (AES-256-GCM) with the
|
||||||
|
machine-local master key resolved by `BDS.AI.SecretKey`.
|
||||||
|
|
||||||
|
Values written by earlier releases — encrypted with key material that
|
||||||
|
shipped in the repository, or with the deterministic node-name fallback —
|
||||||
|
are still readable: `decrypt/1` falls back to the legacy keys, and
|
||||||
|
`BDS.AI.SecretMigration` re-encrypts such rows at boot. When no master key
|
||||||
|
can be obtained, both operations return `{:error, :secret_key_unavailable}`
|
||||||
|
instead of degrading to a weaker key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias BDS.AI.SecretKey
|
||||||
|
|
||||||
@aad "bds-ai-secret"
|
@aad "bds-ai-secret"
|
||||||
|
|
||||||
|
# Key material shipped in the repository before TD-01. Retained only so
|
||||||
|
# existing user databases can be read and re-encrypted by
|
||||||
|
# BDS.AI.SecretMigration; remove both together in a future release.
|
||||||
|
@legacy_repo_key binary_part(
|
||||||
|
"bds_desktop_shell_secret_key_base_64_chars_minimum_seed_value_001",
|
||||||
|
0,
|
||||||
|
32
|
||||||
|
)
|
||||||
|
|
||||||
|
@spec encrypt(String.t()) :: {:ok, String.t()} | {:error, term()}
|
||||||
def encrypt(value) when is_binary(value) do
|
def encrypt(value) when is_binary(value) do
|
||||||
key = secret_key()
|
with {:ok, key} <- secret_key() do
|
||||||
iv = :crypto.strong_rand_bytes(12)
|
iv = :crypto.strong_rand_bytes(12)
|
||||||
|
|
||||||
{ciphertext, tag} =
|
{ciphertext, tag} =
|
||||||
@@ -12,12 +36,66 @@ defmodule BDS.AI.SecretBackend do
|
|||||||
|
|
||||||
{:ok, Base.encode64(iv <> tag <> ciphertext)}
|
{:ok, Base.encode64(iv <> tag <> ciphertext)}
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec decrypt(String.t()) :: {:ok, String.t()} | {:error, term()}
|
||||||
def decrypt(encoded) when is_binary(encoded) do
|
def decrypt(encoded) when is_binary(encoded) do
|
||||||
|
with {:ok, key} <- secret_key() do
|
||||||
|
decrypt_with_fallback(encoded, key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Decrypts strictly with the current master key — no legacy fallback. Used by
|
||||||
|
`BDS.AI.SecretMigration` to detect rows that still need re-encryption.
|
||||||
|
"""
|
||||||
|
@spec decrypt_with_current_key(String.t()) :: {:ok, String.t()} | {:error, term()}
|
||||||
|
def decrypt_with_current_key(encoded) when is_binary(encoded) do
|
||||||
|
with {:ok, key} <- secret_key() do
|
||||||
|
decrypt_with(encoded, key)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Attempts decryption with the legacy keys used by earlier releases.
|
||||||
|
"""
|
||||||
|
@spec decrypt_legacy(String.t()) :: {:ok, String.t()} | {:error, :invalid_ciphertext}
|
||||||
|
def decrypt_legacy(encoded) when is_binary(encoded) do
|
||||||
|
Enum.find_value(legacy_keys(), {:error, :invalid_ciphertext}, fn key ->
|
||||||
|
case decrypt_with(encoded, key) do
|
||||||
|
{:ok, plaintext} -> {:ok, plaintext}
|
||||||
|
{:error, :invalid_ciphertext} -> nil
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp legacy_keys do
|
||||||
|
[
|
||||||
|
@legacy_repo_key,
|
||||||
|
:crypto.hash(:sha256, Atom.to_string(node()) <> ":bds:ai")
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decrypt_with_fallback(encoded, key) do
|
||||||
|
case decrypt_with(encoded, key) do
|
||||||
|
{:ok, _plaintext} = ok -> ok
|
||||||
|
{:error, :invalid_ciphertext} -> decrypt_legacy(encoded)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decrypt_with(encoded, key) do
|
||||||
with {:ok, binary} <- Base.decode64(encoded),
|
with {:ok, binary} <- Base.decode64(encoded),
|
||||||
<<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary,
|
<<iv::binary-size(12), tag::binary-size(16), ciphertext::binary>> <- binary,
|
||||||
plaintext when is_binary(plaintext) <-
|
plaintext when is_binary(plaintext) <-
|
||||||
:crypto.crypto_one_time_aead(:aes_256_gcm, secret_key(), iv, ciphertext, @aad, tag, false) do
|
:crypto.crypto_one_time_aead(
|
||||||
|
:aes_256_gcm,
|
||||||
|
key,
|
||||||
|
iv,
|
||||||
|
ciphertext,
|
||||||
|
@aad,
|
||||||
|
tag,
|
||||||
|
false
|
||||||
|
) do
|
||||||
{:ok, plaintext}
|
{:ok, plaintext}
|
||||||
else
|
else
|
||||||
_other -> {:error, :invalid_ciphertext}
|
_other -> {:error, :invalid_ciphertext}
|
||||||
@@ -25,9 +103,13 @@ defmodule BDS.AI.SecretBackend do
|
|||||||
end
|
end
|
||||||
|
|
||||||
defp secret_key do
|
defp secret_key do
|
||||||
case Application.get_env(:bds, :ai_secret_key) do
|
case SecretKey.fetch() do
|
||||||
key when is_binary(key) and byte_size(key) >= 32 -> binary_part(key, 0, 32)
|
{:ok, key} ->
|
||||||
_other -> :crypto.hash(:sha256, Atom.to_string(node()) <> ":bds:ai")
|
{:ok, key}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.error("AI secret key unavailable: #{inspect(reason)}")
|
||||||
|
{:error, :secret_key_unavailable}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
defmodule BDS.AI.SecretKey do
|
||||||
|
@moduledoc """
|
||||||
|
Resolves the 32-byte machine-local master key that encrypts AI provider
|
||||||
|
secrets at rest (the `SecureKeyStore` entity in `specs/ai.allium`).
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
|
||||||
|
1. `config :bds, :ai_secret_key` — explicit override. Used by the test
|
||||||
|
suite for determinism; must be at least 32 bytes (the first 32 are
|
||||||
|
used). An invalid value is an error, never a silent fallback.
|
||||||
|
2. A previously resolved key cached in `:persistent_term`.
|
||||||
|
3. The OS keyring: on macOS the login Keychain via the `security` CLI; on
|
||||||
|
other platforms — or when the Keychain is unavailable — a random key
|
||||||
|
file under the private app dir, written with `0600` permissions.
|
||||||
|
|
||||||
|
A fresh random key is generated and stored on first use. There is no
|
||||||
|
deterministic fallback: when no key can be obtained or persisted, `fetch/0`
|
||||||
|
returns `{:error, reason}` and secret encryption/decryption fails loudly
|
||||||
|
instead of degrading to obfuscation.
|
||||||
|
|
||||||
|
Config (`config :bds, BDS.AI.SecretKey`):
|
||||||
|
|
||||||
|
* `:strategy` — `:auto` (default; Keychain on macOS, key file elsewhere),
|
||||||
|
`:keychain`, or `:file`
|
||||||
|
* `:key_file_path` — overrides the key file location
|
||||||
|
* `:command_runner` — 3-arity replacement for `System.cmd/3` (tests)
|
||||||
|
"""
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
@key_bytes 32
|
||||||
|
@cache_key {__MODULE__, :key}
|
||||||
|
@keychain_service "bDS2"
|
||||||
|
@keychain_account "ai-secret-key"
|
||||||
|
@keychain_not_found_status 44
|
||||||
|
@key_file_name "ai_secret.key"
|
||||||
|
|
||||||
|
@spec fetch() :: {:ok, binary()} | {:error, term()}
|
||||||
|
def fetch do
|
||||||
|
case configured_key() do
|
||||||
|
{:ok, key} -> {:ok, key}
|
||||||
|
{:error, _detail} = error -> error
|
||||||
|
:unset -> cached_or_resolve()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Clears the cached key so the next fetch re-resolves it (test helper)."
|
||||||
|
@spec reset_cache() :: :ok
|
||||||
|
def reset_cache do
|
||||||
|
:persistent_term.erase(@cache_key)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp configured_key do
|
||||||
|
case Application.get_env(:bds, :ai_secret_key) do
|
||||||
|
nil ->
|
||||||
|
:unset
|
||||||
|
|
||||||
|
key when is_binary(key) and byte_size(key) >= @key_bytes ->
|
||||||
|
{:ok, binary_part(key, 0, @key_bytes)}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
{:error, {:invalid_configured_key, "expected a binary of at least #{@key_bytes} bytes, got: #{inspect(other)}"}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp cached_or_resolve do
|
||||||
|
case :persistent_term.get(@cache_key, nil) do
|
||||||
|
key when is_binary(key) ->
|
||||||
|
{:ok, key}
|
||||||
|
|
||||||
|
nil ->
|
||||||
|
with {:ok, key} <- resolve() do
|
||||||
|
:persistent_term.put(@cache_key, key)
|
||||||
|
{:ok, key}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve do
|
||||||
|
case strategy() do
|
||||||
|
:keychain -> resolve_keychain()
|
||||||
|
:file -> resolve_file()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp strategy do
|
||||||
|
case config(:strategy, :auto) do
|
||||||
|
:auto ->
|
||||||
|
if match?({:unix, :darwin}, :os.type()), do: :keychain, else: :file
|
||||||
|
|
||||||
|
explicit when explicit in [:keychain, :file] ->
|
||||||
|
explicit
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ─── macOS Keychain ─────────────────────────────────────────
|
||||||
|
|
||||||
|
defp resolve_keychain do
|
||||||
|
case keychain_find() do
|
||||||
|
{:ok, key} ->
|
||||||
|
{:ok, key}
|
||||||
|
|
||||||
|
:not_found ->
|
||||||
|
keychain_create()
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
fallback_to_key_file("AI secret key: macOS Keychain unavailable", inspect(reason))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp keychain_find do
|
||||||
|
case run_security([
|
||||||
|
"find-generic-password",
|
||||||
|
"-s",
|
||||||
|
@keychain_service,
|
||||||
|
"-a",
|
||||||
|
@keychain_account,
|
||||||
|
"-w"
|
||||||
|
]) do
|
||||||
|
{output, 0} ->
|
||||||
|
case output |> String.trim() |> Base.decode64() do
|
||||||
|
{:ok, key} when byte_size(key) == @key_bytes -> {:ok, key}
|
||||||
|
_other -> {:error, :corrupt_keychain_item}
|
||||||
|
end
|
||||||
|
|
||||||
|
{_output, @keychain_not_found_status} ->
|
||||||
|
:not_found
|
||||||
|
|
||||||
|
{output, status} ->
|
||||||
|
{:error, {:security_failed, status, String.trim(output)}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The generated key passes through `security`'s argv, which is briefly
|
||||||
|
# visible to other local processes of the same user. Accepted trade-off for
|
||||||
|
# a single-user desktop app; `security` offers no non-interactive way to
|
||||||
|
# take the password on stdin in one shot.
|
||||||
|
defp keychain_create do
|
||||||
|
key = :crypto.strong_rand_bytes(@key_bytes)
|
||||||
|
|
||||||
|
case run_security([
|
||||||
|
"add-generic-password",
|
||||||
|
"-U",
|
||||||
|
"-s",
|
||||||
|
@keychain_service,
|
||||||
|
"-a",
|
||||||
|
@keychain_account,
|
||||||
|
"-w",
|
||||||
|
Base.encode64(key)
|
||||||
|
]) do
|
||||||
|
{_output, 0} ->
|
||||||
|
{:ok, key}
|
||||||
|
|
||||||
|
{output, status} ->
|
||||||
|
fallback_to_key_file(
|
||||||
|
"AI secret key: could not store the key in the Keychain",
|
||||||
|
"status #{status}: #{String.trim(output)}"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp run_security(args) do
|
||||||
|
runner = config(:command_runner, &default_runner/3)
|
||||||
|
runner.("security", args, stderr_to_stdout: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp default_runner(command, args, opts) do
|
||||||
|
System.cmd(command, args, opts)
|
||||||
|
rescue
|
||||||
|
error in ErlangError -> {"#{command} unavailable: #{inspect(error.original)}", 127}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ─── Key file ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp resolve_file do
|
||||||
|
path = key_file_path()
|
||||||
|
|
||||||
|
case File.read(path) do
|
||||||
|
{:ok, contents} -> decode_key_file(contents, path)
|
||||||
|
{:error, :enoent} -> create_key_file(path)
|
||||||
|
{:error, reason} -> {:error, {:key_file_unreadable, path, reason}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp decode_key_file(contents, path) do
|
||||||
|
case contents |> String.trim() |> Base.decode64() do
|
||||||
|
{:ok, key} when byte_size(key) == @key_bytes -> {:ok, key}
|
||||||
|
_other -> {:error, {:key_file_corrupt, path}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_key_file(path) do
|
||||||
|
key = :crypto.strong_rand_bytes(@key_bytes)
|
||||||
|
temp_path = path <> ".tmp." <> Integer.to_string(System.unique_integer([:positive]))
|
||||||
|
|
||||||
|
with :ok <- File.mkdir_p(Path.dirname(path)),
|
||||||
|
:ok <- File.write(temp_path, Base.encode64(key) <> "\n"),
|
||||||
|
:ok <- File.chmod(temp_path, 0o600),
|
||||||
|
:ok <- File.rename(temp_path, path) do
|
||||||
|
{:ok, key}
|
||||||
|
else
|
||||||
|
{:error, reason} ->
|
||||||
|
_ = File.rm(temp_path)
|
||||||
|
{:error, {:key_file_write_failed, path, reason}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp key_file_path do
|
||||||
|
config(:key_file_path, nil) || Path.join(private_app_dir(), @key_file_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Same private app dir as BDS.Projects.private_app_dir/0 — on macOS
|
||||||
|
# ~/Library/Application Support/BDS2. Duplicated to keep this module free of
|
||||||
|
# project/DB dependencies.
|
||||||
|
defp private_app_dir do
|
||||||
|
case :filename.basedir(:user_config, "BDS2") do
|
||||||
|
path when is_list(path) -> List.to_string(path)
|
||||||
|
path -> path
|
||||||
|
end
|
||||||
|
|> Path.expand()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp config(key, default) do
|
||||||
|
Application.get_env(:bds, __MODULE__, []) |> Keyword.get(key, default)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fallback_to_key_file(prefix, detail) do
|
||||||
|
Logger.warning(prefix <> " (" <> detail <> "); falling back to the key file")
|
||||||
|
resolve_file()
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
defmodule BDS.AI.SecretMigration do
|
||||||
|
@moduledoc """
|
||||||
|
Idempotent boot-time re-encryption of stored AI secrets.
|
||||||
|
|
||||||
|
Earlier releases encrypted secrets with key material shipped in the
|
||||||
|
repository (or a deterministic node-name fallback). This pass finds the
|
||||||
|
`__encrypted_*` rows in `settings`, decrypts them with the legacy keys, and
|
||||||
|
re-encrypts them with the machine-local key from `BDS.AI.SecretKey`. Rows
|
||||||
|
already encrypted with the current key are left untouched; rows no known
|
||||||
|
key can decrypt are left in place and reported, so the user can re-enter
|
||||||
|
the secret. Runs from `BDS.RepoBootstrap` on every boot; on a migrated
|
||||||
|
database it is a cheap no-op.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
require Logger
|
||||||
|
|
||||||
|
alias BDS.AI.SecretBackend
|
||||||
|
alias BDS.Persistence
|
||||||
|
alias BDS.Repo
|
||||||
|
alias BDS.Settings.Setting
|
||||||
|
|
||||||
|
@encrypted_prefix "__encrypted_"
|
||||||
|
|
||||||
|
@spec migrate_legacy_secrets(module()) ::
|
||||||
|
{:ok, %{migrated: non_neg_integer(), failed: non_neg_integer()}}
|
||||||
|
def migrate_legacy_secrets(repo \\ Repo) do
|
||||||
|
summary =
|
||||||
|
from(setting in Setting, where: like(setting.key, ^"#{@encrypted_prefix}%"))
|
||||||
|
|> repo.all()
|
||||||
|
|> Enum.reduce(%{migrated: 0, failed: 0}, fn setting, acc ->
|
||||||
|
case migrate_row(repo, setting) do
|
||||||
|
:current -> acc
|
||||||
|
:migrated -> %{acc | migrated: acc.migrated + 1}
|
||||||
|
:failed -> %{acc | failed: acc.failed + 1}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, summary}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp migrate_row(repo, setting) do
|
||||||
|
with {:error, _no_current_key_match} <- SecretBackend.decrypt_with_current_key(setting.value),
|
||||||
|
{:ok, plaintext} <- SecretBackend.decrypt_legacy(setting.value),
|
||||||
|
{:ok, reencrypted} <- SecretBackend.encrypt(plaintext) do
|
||||||
|
repo.update_all(
|
||||||
|
from(s in Setting, where: s.key == ^setting.key),
|
||||||
|
set: [value: reencrypted, updated_at: Persistence.now_ms()]
|
||||||
|
)
|
||||||
|
|
||||||
|
Logger.info("AI secret #{setting.key} re-encrypted with the machine-local key")
|
||||||
|
:migrated
|
||||||
|
else
|
||||||
|
{:ok, _already_current} ->
|
||||||
|
:current
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
Logger.warning(
|
||||||
|
"AI secret #{setting.key} could not be re-encrypted (#{inspect(reason)}); " <>
|
||||||
|
"leaving it unchanged — the secret may need to be entered again"
|
||||||
|
)
|
||||||
|
|
||||||
|
:failed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
defmodule BDS.AI.SettingsStore do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.AI.CatalogMeta
|
||||||
|
alias BDS.AI.SecretBackend
|
||||||
|
alias BDS.Persistence
|
||||||
|
alias BDS.Repo
|
||||||
|
alias BDS.Settings.Setting
|
||||||
|
|
||||||
|
@spec get_setting(String.t()) :: String.t() | nil
|
||||||
|
def get_setting(key) do
|
||||||
|
case Repo.get(Setting, key) do
|
||||||
|
nil -> nil
|
||||||
|
setting -> setting.value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec put_setting(String.t(), String.t()) :: :ok
|
||||||
|
def put_setting(key, value) when is_binary(key) and is_binary(value) do
|
||||||
|
now = Persistence.now_ms()
|
||||||
|
|
||||||
|
%Setting{}
|
||||||
|
|> Setting.changeset(%{key: key, value: value, updated_at: now})
|
||||||
|
|> Repo.insert(
|
||||||
|
on_conflict: [set: [value: value, updated_at: now]],
|
||||||
|
conflict_target: [:key]
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec delete_setting(String.t()) :: :ok
|
||||||
|
def delete_setting(key) do
|
||||||
|
Repo.delete_all(from(setting in Setting, where: setting.key == ^key))
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec put_secret(String.t(), String.t() | nil, module()) :: :ok | {:error, term()}
|
||||||
|
def put_secret(_key, nil, _backend), do: :ok
|
||||||
|
|
||||||
|
def put_secret(key, value, backend) do
|
||||||
|
with {:ok, encrypted_value} <- backend.encrypt(value) do
|
||||||
|
put_setting(encrypted_key(key), encrypted_value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec get_secret(String.t() | nil, module()) :: {:ok, String.t() | nil} | {:error, term()}
|
||||||
|
def get_secret(nil, _backend), do: {:ok, nil}
|
||||||
|
def get_secret(value, backend), do: backend.decrypt(value)
|
||||||
|
|
||||||
|
@spec encrypted_key(String.t()) :: String.t()
|
||||||
|
def encrypted_key(key), do: "__encrypted_#{key}"
|
||||||
|
|
||||||
|
@spec secret_backend() :: module()
|
||||||
|
def secret_backend, do: SecretBackend
|
||||||
|
|
||||||
|
@spec get_catalog_meta_value(String.t()) :: String.t() | nil
|
||||||
|
def get_catalog_meta_value(key) do
|
||||||
|
case Repo.get(CatalogMeta, key) do
|
||||||
|
nil -> get_setting("ai.catalog.#{key}")
|
||||||
|
meta -> meta.value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec put_catalog_meta(String.t(), String.t()) :: :ok
|
||||||
|
def put_catalog_meta(key, value) do
|
||||||
|
%CatalogMeta{}
|
||||||
|
|> CatalogMeta.changeset(%{key: key, value: value})
|
||||||
|
|> Repo.insert(
|
||||||
|
on_conflict: [set: [value: value]],
|
||||||
|
conflict_target: [:key]
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
defmodule BDS.AI.SSE do
|
||||||
|
@moduledoc """
|
||||||
|
Incremental assembler for OpenAI-compatible `text/event-stream` chat
|
||||||
|
completions.
|
||||||
|
|
||||||
|
Fed raw transport chunks via `feed/2`, it buffers partial events, decodes
|
||||||
|
`data:` payloads, and accumulates content deltas, tool-call fragments, and
|
||||||
|
usage. Content is reported to the optional `on_event` callback as
|
||||||
|
**cumulative snapshots** (`%{content: binary}`) — replace semantics, which
|
||||||
|
matches how the chat editor renders streaming state and resets naturally
|
||||||
|
between tool rounds. Emissions are throttled to `:emit_interval_ms`
|
||||||
|
(the first delta always emits immediately for perceived latency).
|
||||||
|
|
||||||
|
`finish/1` returns the assembled message in OpenAI wire shape so the
|
||||||
|
runtime can reuse its non-streaming normalization:
|
||||||
|
`%{content: binary | nil, tool_calls: [%{"id" => _, "function" => %{"name" => _, "arguments" => json_string}}], usage: map | nil}`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
defstruct buffer: "",
|
||||||
|
raw: [],
|
||||||
|
content: [],
|
||||||
|
content?: false,
|
||||||
|
tool_calls: %{},
|
||||||
|
usage: nil,
|
||||||
|
done?: false,
|
||||||
|
on_event: nil,
|
||||||
|
emit_interval_ms: 100,
|
||||||
|
last_emit_at: nil
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{}
|
||||||
|
|
||||||
|
@spec new((map() -> any()) | nil, keyword()) :: t()
|
||||||
|
def new(on_event \\ nil, opts \\ []) when is_list(opts) do
|
||||||
|
%__MODULE__{
|
||||||
|
on_event: on_event,
|
||||||
|
emit_interval_ms: Keyword.get(opts, :emit_interval_ms, 100)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec feed(t(), binary()) :: t()
|
||||||
|
def feed(%__MODULE__{done?: true} = sse, _chunk), do: sse
|
||||||
|
|
||||||
|
def feed(%__MODULE__{} = sse, chunk) when is_binary(chunk) do
|
||||||
|
sse = %{sse | raw: [chunk | sse.raw]}
|
||||||
|
parts = String.split(sse.buffer <> chunk, ~r/\r?\n\r?\n/)
|
||||||
|
{complete_events, [rest]} = Enum.split(parts, -1)
|
||||||
|
|
||||||
|
Enum.reduce(complete_events, %{sse | buffer: rest}, &process_event(&2, &1))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The unparsed transport bytes, for callers that discover after the fact
|
||||||
|
that the response was not an event stream (e.g. a provider that ignored
|
||||||
|
the `stream` flag and answered with plain JSON).
|
||||||
|
"""
|
||||||
|
@spec raw_body(t()) :: binary()
|
||||||
|
def raw_body(%__MODULE__{} = sse) do
|
||||||
|
sse.raw |> Enum.reverse() |> IO.iodata_to_binary()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec finish(t()) :: %{content: binary() | nil, tool_calls: [map()], usage: map() | nil}
|
||||||
|
def finish(%__MODULE__{} = sse) do
|
||||||
|
# A final event may arrive without its trailing blank line.
|
||||||
|
sse =
|
||||||
|
case String.trim(sse.buffer) do
|
||||||
|
"" -> sse
|
||||||
|
remnant -> process_event(%{sse | buffer: ""}, remnant)
|
||||||
|
end
|
||||||
|
|
||||||
|
%{
|
||||||
|
content: assembled_content(sse),
|
||||||
|
tool_calls: assembled_tool_calls(sse),
|
||||||
|
usage: sse.usage
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp process_event(%{done?: true} = sse, _event), do: sse
|
||||||
|
|
||||||
|
defp process_event(sse, event) do
|
||||||
|
data =
|
||||||
|
event
|
||||||
|
|> String.split(~r/\r?\n/)
|
||||||
|
|> Enum.flat_map(&data_line/1)
|
||||||
|
|> Enum.join("\n")
|
||||||
|
|
||||||
|
cond do
|
||||||
|
data == "" ->
|
||||||
|
sse
|
||||||
|
|
||||||
|
String.trim(data) == "[DONE]" ->
|
||||||
|
%{sse | done?: true}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
case Jason.decode(data) do
|
||||||
|
{:ok, payload} when is_map(payload) -> apply_payload(sse, payload)
|
||||||
|
_other -> sse
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp data_line("data: " <> rest), do: [rest]
|
||||||
|
defp data_line("data:" <> rest), do: [rest]
|
||||||
|
defp data_line(_line), do: []
|
||||||
|
|
||||||
|
defp apply_payload(sse, payload) do
|
||||||
|
delta = get_in(payload, ["choices", Access.at(0), "delta"]) || %{}
|
||||||
|
|
||||||
|
sse
|
||||||
|
|> apply_content(delta["content"])
|
||||||
|
|> apply_tool_calls(delta["tool_calls"])
|
||||||
|
|> apply_usage(payload["usage"])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_content(sse, content) when is_binary(content) and content != "" do
|
||||||
|
%{sse | content: [content | sse.content], content?: true}
|
||||||
|
|> maybe_emit()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_content(sse, _content), do: sse
|
||||||
|
|
||||||
|
defp apply_tool_calls(sse, [_ | _] = fragments) do
|
||||||
|
Enum.reduce(fragments, sse, fn fragment, acc ->
|
||||||
|
index = fragment["index"] || 0
|
||||||
|
existing = Map.get(acc.tool_calls, index, %{id: nil, name: nil, arguments: []})
|
||||||
|
function_part = fragment["function"] || %{}
|
||||||
|
|
||||||
|
merged = %{
|
||||||
|
id: existing.id || fragment["id"],
|
||||||
|
name: existing.name || function_part["name"],
|
||||||
|
arguments: [existing.arguments, function_part["arguments"] || ""]
|
||||||
|
}
|
||||||
|
|
||||||
|
%{acc | tool_calls: Map.put(acc.tool_calls, index, merged)}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_tool_calls(sse, _fragments), do: sse
|
||||||
|
|
||||||
|
defp apply_usage(sse, usage) when is_map(usage) and map_size(usage) > 0,
|
||||||
|
do: %{sse | usage: usage}
|
||||||
|
|
||||||
|
defp apply_usage(sse, _usage), do: sse
|
||||||
|
|
||||||
|
defp maybe_emit(%{on_event: nil} = sse), do: sse
|
||||||
|
|
||||||
|
defp maybe_emit(sse) do
|
||||||
|
now = System.monotonic_time(:millisecond)
|
||||||
|
|
||||||
|
if is_nil(sse.last_emit_at) or now - sse.last_emit_at >= sse.emit_interval_ms do
|
||||||
|
sse.on_event.(%{content: assembled_content(sse) || ""})
|
||||||
|
%{sse | last_emit_at: now}
|
||||||
|
else
|
||||||
|
sse
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assembled_content(%{content?: false}), do: nil
|
||||||
|
|
||||||
|
defp assembled_content(sse) do
|
||||||
|
sse.content |> Enum.reverse() |> IO.iodata_to_binary()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp assembled_tool_calls(sse) do
|
||||||
|
sse.tool_calls
|
||||||
|
|> Enum.sort_by(fn {index, _tool_call} -> index end)
|
||||||
|
|> Enum.map(fn {_index, tool_call} ->
|
||||||
|
%{
|
||||||
|
"id" => tool_call.id,
|
||||||
|
"function" => %{
|
||||||
|
"name" => tool_call.name,
|
||||||
|
"arguments" => IO.iodata_to_binary(tool_call.arguments)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
+73
-7
@@ -5,6 +5,27 @@ defmodule BDS.Application do
|
|||||||
|
|
||||||
@compiled_env Application.compile_env(:bds, :current_env, Mix.env())
|
@compiled_env Application.compile_env(:bds, :current_env, Mix.env())
|
||||||
|
|
||||||
|
# Children that depend on the boot mode (issue #26): desktop mode runs the
|
||||||
|
# wx window shell, server mode runs headless behind the SSH daemon. Both
|
||||||
|
# start the loopback HTTP endpoint and the CLI sync watcher.
|
||||||
|
def mode_children(:server, _env) do
|
||||||
|
[{BDS.Desktop.Server, []}, BDS.CliSync.Watcher, BDS.Server]
|
||||||
|
end
|
||||||
|
|
||||||
|
# CLI mode (issue #25): a one-shot command run against the shared database.
|
||||||
|
# No HTTP listener, SSH daemon, window, or sync watcher — the CLI is the
|
||||||
|
# external writer the watcher in a concurrently running app listens for.
|
||||||
|
def mode_children(:cli, _env), do: []
|
||||||
|
|
||||||
|
# Local TUI: the headless server plus a TUI on the launching terminal, so
|
||||||
|
# a GUI (via tunnel) and the local terminal can work in parallel. Quitting
|
||||||
|
# this TUI stops the whole VM — the terminal is the app in this mode.
|
||||||
|
# SSH-served TUI sessions never get this flag and stay session-local.
|
||||||
|
def mode_children(:tui, env),
|
||||||
|
do: mode_children(:server, env) ++ [{BDS.TUI, [stop_vm_on_exit: true]}]
|
||||||
|
|
||||||
|
def mode_children(:desktop, env), do: desktop_children(env)
|
||||||
|
|
||||||
def desktop_children(env \\ nil)
|
def desktop_children(env \\ nil)
|
||||||
|
|
||||||
def desktop_children(:test) do
|
def desktop_children(:test) do
|
||||||
@@ -17,7 +38,7 @@ defmodule BDS.Application do
|
|||||||
|
|
||||||
def desktop_children(_env) do
|
def desktop_children(_env) do
|
||||||
if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do
|
if Application.get_env(:bds, BDS.Application)[:desktop_adapter] == :desktop do
|
||||||
[{BDS.Desktop.Server, []} | desktop_window_children()]
|
[{BDS.Desktop.Server, []}, BDS.CliSync.Watcher | desktop_window_children()]
|
||||||
else
|
else
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
@@ -25,33 +46,62 @@ defmodule BDS.Application do
|
|||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def start(_type, _args) do
|
def start(_type, _args) do
|
||||||
children = [
|
children =
|
||||||
|
[
|
||||||
{Phoenix.PubSub, name: BDS.PubSub},
|
{Phoenix.PubSub, name: BDS.PubSub},
|
||||||
{BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()},
|
{BDS.Desktop.Endpoint, secret_key_base: desktop_secret_key_base()},
|
||||||
BDS.Repo,
|
BDS.Repo,
|
||||||
BDS.RepoBootstrap,
|
BDS.RepoBootstrap,
|
||||||
BDS.Tasks,
|
BDS.Tasks,
|
||||||
|
BDS.AI.InFlight,
|
||||||
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,
|
||||||
| desktop_children(current_env())
|
BDS.Embeddings.Index
|
||||||
]
|
] ++ embedding_children() ++ mode_children(boot_mode(), current_env())
|
||||||
|
|
||||||
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
opts = [strategy: :one_for_one, name: BDS.Supervisor]
|
||||||
Supervisor.start_link(children, opts)
|
Supervisor.start_link(children, opts)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The neural embedding backend runs as a supervised, lazily-initialised
|
||||||
|
# GenServer (it loads the model only on the first embedding request). Only
|
||||||
|
# start it when it is the configured backend.
|
||||||
|
defp embedding_children do
|
||||||
|
case Application.get_env(:bds, :embeddings, [])[:backend] do
|
||||||
|
BDS.Embeddings.Backends.Neural -> [BDS.Embeddings.Backends.Neural]
|
||||||
|
_other -> []
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp current_env do
|
defp current_env do
|
||||||
Application.get_env(:bds, :current_env_override) || @compiled_env
|
Application.get_env(:bds, :current_env_override) || @compiled_env
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Desktop mode without a graphical display boots the TUI instead
|
||||||
|
# (issue #33); the log line explains the surprise mode switch.
|
||||||
|
defp boot_mode do
|
||||||
|
resolved = BDS.Server.mode()
|
||||||
|
effective = BDS.Server.effective_mode(resolved)
|
||||||
|
|
||||||
|
if effective != resolved do
|
||||||
|
require Logger
|
||||||
|
Logger.info("No graphical display available, starting the terminal UI instead")
|
||||||
|
end
|
||||||
|
|
||||||
|
effective
|
||||||
|
end
|
||||||
|
|
||||||
defp desktop_window_children do
|
defp desktop_window_children do
|
||||||
if desktop_automation?() do
|
if desktop_automation?() do
|
||||||
[]
|
[]
|
||||||
else
|
else
|
||||||
|
disable_auto_window_menu()
|
||||||
|
|
||||||
window_opts =
|
window_opts =
|
||||||
BDS.Desktop.MainWindow.window_options(
|
BDS.Desktop.MainWindow.window_options(
|
||||||
menubar: BDS.Desktop.MenuBar,
|
menubar: BDS.Desktop.MenuBar,
|
||||||
@@ -61,17 +111,33 @@ defmodule BDS.Application do
|
|||||||
|
|
||||||
[
|
[
|
||||||
{Desktop.Window, window_opts},
|
{Desktop.Window, window_opts},
|
||||||
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher)
|
Supervisor.child_spec({BDS.Desktop.MainWindow, []}, id: BDS.Desktop.MainWindow.Watcher),
|
||||||
|
{BDS.Desktop.DeepLink, []},
|
||||||
|
{BDS.Desktop.RemoteConnection, []}
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# wxOSX injects an untracked "Window" menu into the menubar at install time,
|
||||||
|
# which races elixir-desktop's async menu populate and ends up at an
|
||||||
|
# unstable position. We disable it and ship our own :window menu group
|
||||||
|
# (BDS.UI.MenuBar) placed between Blog and Help.
|
||||||
|
defp disable_auto_window_menu do
|
||||||
|
if :os.type() == {:unix, :darwin} do
|
||||||
|
:wx.set_env(Desktop.Env.wx_env())
|
||||||
|
:wxMenuBar.setAutoWindowMenu(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp desktop_automation? do
|
defp desktop_automation? do
|
||||||
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
|
System.get_env("BDS_DESKTOP_AUTOMATION") in ["1", "true", "TRUE"]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The desktop endpoint binds to loopback only and its sessions do not need
|
||||||
|
# to survive restarts, so without explicit config the signing secret is a
|
||||||
|
# random per-boot value rather than a static one shipped in the repo.
|
||||||
defp desktop_secret_key_base do
|
defp desktop_secret_key_base do
|
||||||
Application.get_env(:bds, :desktop)[:secret_key_base] ||
|
Application.get_env(:bds, :desktop)[:secret_key_base] ||
|
||||||
raise "missing :desktop secret_key_base configuration"
|
Base.encode64(:crypto.strong_rand_bytes(48))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
defmodule BDS.Blogmark do
|
||||||
|
@moduledoc """
|
||||||
|
Receives `bds2://new-post` blogmark deep links and turns them into draft posts
|
||||||
|
(spec: script.allium `BlogmarkReceived`/`ExecuteTransform`,
|
||||||
|
editor_settings.allium `BookmarkletCopy`).
|
||||||
|
|
||||||
|
The browser bookmarklet (`BDS.Scripting.Capabilities.AppShell`) navigates to a
|
||||||
|
`bds2://new-post?title=&url=` URL. The desktop layer hands that URL here, where
|
||||||
|
it is parsed into a post candidate, run through the enabled transform pipeline
|
||||||
|
(`BDS.Scripts.Transforms`), and finally persisted as a draft post — defaulting
|
||||||
|
the category from the project's `blogmark_category` setting when neither the
|
||||||
|
link nor a transform supplied one.
|
||||||
|
|
||||||
|
The `bds2://` scheme deliberately differs from the legacy app's `bds://` scheme
|
||||||
|
so the two installs do not fight over the same registration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias BDS.Metadata
|
||||||
|
alias BDS.Posts
|
||||||
|
alias BDS.Scripts.Transforms
|
||||||
|
|
||||||
|
@scheme "bds2"
|
||||||
|
@new_post_action "new-post"
|
||||||
|
|
||||||
|
# Input-hardening limits mirrored from the legacy bDS app (shared/blogmark.ts).
|
||||||
|
@max_title_length 200
|
||||||
|
@max_url_length 2048
|
||||||
|
@control_chars ~r/[\x00-\x1F\x7F]/
|
||||||
|
|
||||||
|
@type candidate :: %{required(String.t()) => term()}
|
||||||
|
|
||||||
|
@type receive_result :: %{
|
||||||
|
post: Posts.Post.t(),
|
||||||
|
toasts: [String.t()],
|
||||||
|
errors: [%{slug: String.t() | nil, reason: term()}]
|
||||||
|
}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Parses a `bds2://new-post` deep link into a post candidate map.
|
||||||
|
|
||||||
|
Returns `{:ok, candidate}` with string-keyed `title`, `url`, `content`,
|
||||||
|
`tags`, `categories` and optional `project_id`, or an error when the scheme
|
||||||
|
or action is unsupported.
|
||||||
|
"""
|
||||||
|
@spec parse_deep_link(String.t()) ::
|
||||||
|
{:ok, candidate()} | {:error, :unsupported_scheme | :unsupported_action | :invalid_url}
|
||||||
|
def parse_deep_link(url) when is_binary(url) do
|
||||||
|
case URI.parse(url) do
|
||||||
|
%URI{scheme: @scheme, host: @new_post_action, query: query} ->
|
||||||
|
{:ok, candidate_from_query(query)}
|
||||||
|
|
||||||
|
%URI{scheme: @scheme} ->
|
||||||
|
{:error, :unsupported_action}
|
||||||
|
|
||||||
|
%URI{scheme: nil} ->
|
||||||
|
{:error, :invalid_url}
|
||||||
|
|
||||||
|
%URI{} ->
|
||||||
|
{:error, :unsupported_scheme}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns the `project_id` a deep link targets, or `nil` when the link carries
|
||||||
|
none or cannot be parsed. Used by the shell to switch to the link's project
|
||||||
|
before importing.
|
||||||
|
"""
|
||||||
|
@spec deep_link_project_id(String.t()) :: String.t() | nil
|
||||||
|
def deep_link_project_id(url) when is_binary(url) do
|
||||||
|
case parse_deep_link(url) do
|
||||||
|
{:ok, %{"project_id" => project_id}} -> project_id
|
||||||
|
_other -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Receives a blogmark deep link for `project_id`: parses it, runs the transform
|
||||||
|
pipeline, and creates a draft post from the resulting candidate.
|
||||||
|
|
||||||
|
When the deep link URL carries a `project_id` query parameter, it takes
|
||||||
|
precedence over the passed `project_id` so the bookmarklet can target a
|
||||||
|
specific project regardless of which project is currently active.
|
||||||
|
|
||||||
|
Returns `{:ok, %{post:, toasts:, errors:}}` where `toasts` are the
|
||||||
|
budget-enforced transform messages and `errors` records any failed transforms.
|
||||||
|
"""
|
||||||
|
@spec receive_deep_link(String.t(), String.t(), keyword()) ::
|
||||||
|
{:ok, receive_result()} | {:error, term()}
|
||||||
|
def receive_deep_link(project_id, url, opts \\ [])
|
||||||
|
when is_binary(project_id) and is_binary(url) and is_list(opts) do
|
||||||
|
with {:ok, candidate} <- parse_deep_link(url),
|
||||||
|
target_project <- resolve_target_project(project_id, candidate),
|
||||||
|
candidate <- apply_default_content(candidate),
|
||||||
|
{:ok, %{data: data, toasts: toasts, errors: errors}} <-
|
||||||
|
Transforms.run(target_project, candidate, opts),
|
||||||
|
data <- apply_default_category(target_project, data),
|
||||||
|
{:ok, post} <- create_draft(target_project, data) do
|
||||||
|
{:ok, %{post: post, toasts: toasts, errors: errors}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp candidate_from_query(query) do
|
||||||
|
params = URI.decode_query(query || "")
|
||||||
|
|
||||||
|
project_id =
|
||||||
|
case Map.get(params, "project_id") do
|
||||||
|
nil -> nil
|
||||||
|
"" -> nil
|
||||||
|
id -> id
|
||||||
|
end
|
||||||
|
|
||||||
|
url = sanitize_http_url(Map.get(params, "url"))
|
||||||
|
|
||||||
|
%{
|
||||||
|
"title" => sanitize_title(Map.get(params, "title"), url_host(url)),
|
||||||
|
"url" => url,
|
||||||
|
"content" => optional(params, "content"),
|
||||||
|
"tags" => list_param(params, "tags"),
|
||||||
|
"categories" => list_param(params, "categories"),
|
||||||
|
"project_id" => project_id
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Strip control characters, trim, cap length; fall back to the URL host when
|
||||||
|
# blank — mirrors bDS sanitizeTitle. Title/url are single-line fields, so
|
||||||
|
# control-character stripping is safe here (unlike the multi-line body).
|
||||||
|
defp sanitize_title(raw, fallback_host) do
|
||||||
|
title =
|
||||||
|
raw
|
||||||
|
|> to_string()
|
||||||
|
|> strip_control()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.slice(0, @max_title_length)
|
||||||
|
|
||||||
|
if title == "", do: fallback_host || "", else: title
|
||||||
|
end
|
||||||
|
|
||||||
|
# Accept only http(s) URLs, strip credentials and fragment, enforce a length
|
||||||
|
# cap; anything else collapses to nil so it can never reach the post body.
|
||||||
|
defp sanitize_http_url(raw) when is_binary(raw) do
|
||||||
|
trimmed = raw |> strip_control() |> String.trim()
|
||||||
|
|
||||||
|
case URI.parse(trimmed) do
|
||||||
|
%URI{scheme: scheme, host: host} = uri
|
||||||
|
when scheme in ["http", "https"] and is_binary(host) and host != "" ->
|
||||||
|
normalized = URI.to_string(%URI{uri | userinfo: nil, fragment: nil})
|
||||||
|
if String.length(normalized) <= @max_url_length, do: normalized, else: nil
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_http_url(_other), do: nil
|
||||||
|
|
||||||
|
defp url_host(url) when is_binary(url), do: URI.parse(url).host
|
||||||
|
defp url_host(_other), do: nil
|
||||||
|
|
||||||
|
defp strip_control(value), do: String.replace(value, @control_chars, "")
|
||||||
|
|
||||||
|
# If the deep link carries a project_id, route to that project; otherwise
|
||||||
|
# use the currently active project passed by the caller. This lets the
|
||||||
|
# bookmarklet target a specific project even when a different one is open.
|
||||||
|
defp resolve_target_project(_caller_project_id, %{"project_id" => pid}) when is_binary(pid) do
|
||||||
|
pid
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_target_project(project_id, _candidate), do: project_id
|
||||||
|
|
||||||
|
# Match the legacy bDS app: when the bookmarklet supplies no body, seed the
|
||||||
|
# post content with a markdown link to the bookmarked page so transforms run
|
||||||
|
# against the same input. Explicit content always wins.
|
||||||
|
defp apply_default_content(candidate) do
|
||||||
|
with nil <- optional_string(Map.get(candidate, "content")),
|
||||||
|
url when is_binary(url) <- optional_string(Map.get(candidate, "url")) do
|
||||||
|
title = Map.get(candidate, "title", "")
|
||||||
|
Map.put(candidate, "content", blogmark_markdown_link(title, url))
|
||||||
|
else
|
||||||
|
_ -> candidate
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp blogmark_markdown_link(title, url) do
|
||||||
|
"[" <> escape_markdown_link_text(title) <> "](" <> url <> ")"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mirrors bDS escapeMarkdownLinkText: backslash first, then brackets/parens,
|
||||||
|
# then collapse newlines to spaces.
|
||||||
|
defp escape_markdown_link_text(value) do
|
||||||
|
value
|
||||||
|
|> to_string()
|
||||||
|
|> String.trim()
|
||||||
|
|> String.replace("\\", "\\\\")
|
||||||
|
|> String.replace("[", "\\[")
|
||||||
|
|> String.replace("]", "\\]")
|
||||||
|
|> String.replace("(", "\\(")
|
||||||
|
|> String.replace(")", "\\)")
|
||||||
|
|> String.replace(~r/\r?\n/, " ")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp optional(params, key) do
|
||||||
|
case Map.get(params, key) do
|
||||||
|
nil -> nil
|
||||||
|
"" -> nil
|
||||||
|
value -> value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp list_param(params, key) do
|
||||||
|
case Map.get(params, key) do
|
||||||
|
value when is_binary(value) ->
|
||||||
|
value
|
||||||
|
|> String.split(",")
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apply the project default category only when neither the deep link nor a
|
||||||
|
# transform produced one, so explicit categories always win.
|
||||||
|
defp apply_default_category(project_id, data) do
|
||||||
|
case Map.get(data, "categories") do
|
||||||
|
categories when is_list(categories) and categories != [] ->
|
||||||
|
data
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
case default_category(project_id) do
|
||||||
|
nil -> data
|
||||||
|
category -> Map.put(data, "categories", [category])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp default_category(project_id) do
|
||||||
|
case Metadata.get_project_metadata(project_id) do
|
||||||
|
{:ok, %{blogmark_category: category}} when is_binary(category) and category != "" ->
|
||||||
|
category
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_draft(project_id, data) do
|
||||||
|
Posts.create_post(%{
|
||||||
|
project_id: project_id,
|
||||||
|
title: Map.get(data, "title", ""),
|
||||||
|
content: optional_string(Map.get(data, "content")),
|
||||||
|
tags: string_list(Map.get(data, "tags")),
|
||||||
|
categories: string_list(Map.get(data, "categories"))
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
defp optional_string(value) when is_binary(value), do: value
|
||||||
|
defp optional_string(_value), do: nil
|
||||||
|
|
||||||
|
defp string_list(list) when is_list(list), do: Enum.filter(list, &is_binary/1)
|
||||||
|
defp string_list(_other), do: []
|
||||||
|
end
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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,
|
||||||
|
:force_render_site,
|
||||||
|
:validate_site,
|
||||||
|
:upload_site,
|
||||||
|
:documentation,
|
||||||
|
:api_documentation,
|
||||||
|
:close_tab
|
||||||
|
]
|
||||||
|
@menu_actions MenuBar.default_groups(dev_mode?: true, window_menu?: true)
|
||||||
|
|> Enum.flat_map(fn group ->
|
||||||
|
Enum.flat_map(group.items, fn
|
||||||
|
%{separator: true} -> []
|
||||||
|
%{id: id} -> [id]
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
@sidebar_view_ids Enum.map(Registry.sidebar_views(), & &1.id)
|
||||||
|
@editor_route_ids Enum.map(Registry.editor_routes(), & &1.id)
|
||||||
|
|
||||||
|
def atom(value, allowed, fallback \\ nil)
|
||||||
|
|
||||||
|
def atom(value, allowed, fallback) 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: @sidebar_view_ids
|
||||||
|
|
||||||
|
defp editor_routes, do: @editor_route_ids
|
||||||
|
|
||||||
|
defp normalize_menu_kind("category-archive"), do: "category_archive"
|
||||||
|
defp normalize_menu_kind(value), do: value
|
||||||
|
end
|
||||||
+335
@@ -0,0 +1,335 @@
|
|||||||
|
defmodule BDS.CLI do
|
||||||
|
@moduledoc """
|
||||||
|
Command-line interface for managing content in a bDS2 workspace (issue #25).
|
||||||
|
|
||||||
|
Runs inside the release VM (`BDS_MODE=cli`) against the same settings and
|
||||||
|
cache database as the GUI/TUI application; mutations write
|
||||||
|
`BDS.CliSync.Notification` rows so a concurrently running app picks them
|
||||||
|
up through `BDS.CliSync.Watcher`.
|
||||||
|
|
||||||
|
The launcher script (`cli/bin/bds-cli` in the release, installable to
|
||||||
|
`~/.local/bin` via `BDS.CLI.Install`) passes the argv via `BDS_CLI_ARGV`
|
||||||
|
(unit-separator joined) because `bin/bds eval` cannot forward arguments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
alias BDS.CLI.Commands
|
||||||
|
|
||||||
|
@argv_env "BDS_CLI_ARGV"
|
||||||
|
@argv_separator <<0x1F>>
|
||||||
|
|
||||||
|
@doc "Release entry point: run the command from `BDS_CLI_ARGV` and halt."
|
||||||
|
@spec main() :: no_return()
|
||||||
|
def main do
|
||||||
|
System.halt(run(env_argv()))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Decodes the unit-separator-joined argv from the launcher script."
|
||||||
|
@spec env_argv(String.t() | nil) :: [String.t()]
|
||||||
|
def env_argv(value \\ System.get_env(@argv_env))
|
||||||
|
def env_argv(nil), do: []
|
||||||
|
def env_argv(value), do: String.split(value, @argv_separator, trim: true)
|
||||||
|
|
||||||
|
@doc "Parses and executes an argv, returning the process exit code."
|
||||||
|
@spec run([String.t()]) :: non_neg_integer()
|
||||||
|
def run(argv) when is_list(argv) do
|
||||||
|
optimus = optimus()
|
||||||
|
|
||||||
|
case Optimus.parse(optimus, argv) do
|
||||||
|
{:ok, %Optimus.ParseResult{}} ->
|
||||||
|
print_help(optimus, [])
|
||||||
|
0
|
||||||
|
|
||||||
|
{:ok, subcommand_path, parse_result} ->
|
||||||
|
execute(subcommand_path, parse_result)
|
||||||
|
|
||||||
|
:help ->
|
||||||
|
print_help(optimus, [])
|
||||||
|
0
|
||||||
|
|
||||||
|
{:help, subcommand_path} ->
|
||||||
|
print_help(optimus, subcommand_path)
|
||||||
|
0
|
||||||
|
|
||||||
|
:version ->
|
||||||
|
IO.puts(version())
|
||||||
|
0
|
||||||
|
|
||||||
|
{:error, errors} ->
|
||||||
|
print_errors(optimus, [], errors)
|
||||||
|
1
|
||||||
|
|
||||||
|
{:error, subcommand_path, errors} ->
|
||||||
|
print_errors(optimus, subcommand_path, errors)
|
||||||
|
1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Executes a parsed subcommand, returning the process exit code."
|
||||||
|
@spec execute([atom()], Optimus.ParseResult.t()) :: non_neg_integer()
|
||||||
|
def execute(subcommand_path, parse_result) do
|
||||||
|
{:ok, _apps} = Application.ensure_all_started(:bds)
|
||||||
|
|
||||||
|
subcommand_path
|
||||||
|
|> Commands.dispatch(parse_result)
|
||||||
|
|> report()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp report(:ok), do: 0
|
||||||
|
|
||||||
|
defp report({:ok, message}) when is_binary(message) do
|
||||||
|
IO.puts(message)
|
||||||
|
0
|
||||||
|
end
|
||||||
|
|
||||||
|
defp report({:error, message}) do
|
||||||
|
IO.puts(:stderr, "Error: " <> format_error(message))
|
||||||
|
1
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_error(message) when is_binary(message), do: message
|
||||||
|
defp format_error(%{message: message}) when is_binary(message), do: message
|
||||||
|
defp format_error(other), do: inspect(other)
|
||||||
|
|
||||||
|
@repair_parts ~w(post-links media-links thumbnails embeddings search)
|
||||||
|
|
||||||
|
@doc "The Optimus command definition (public for tests and help rendering)."
|
||||||
|
def optimus do
|
||||||
|
Optimus.new!(
|
||||||
|
name: "bds-cli",
|
||||||
|
description: "bDS2 workspace CLI",
|
||||||
|
about:
|
||||||
|
"Manages content in a bDS2 workspace using the same settings and " <>
|
||||||
|
"cache database as the desktop application.",
|
||||||
|
version: version(),
|
||||||
|
allow_unknown_args: false,
|
||||||
|
parse_double_dash: true,
|
||||||
|
subcommands: [
|
||||||
|
rebuild: [
|
||||||
|
name: "rebuild",
|
||||||
|
about: "Rebuild the caching database from the workspace files",
|
||||||
|
flags: [
|
||||||
|
incremental: [
|
||||||
|
long: "--incremental",
|
||||||
|
help:
|
||||||
|
"Run a metadata diff and auto-apply every difference from " <>
|
||||||
|
"file to database instead of a full rebuild"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
repair: [
|
||||||
|
name: "repair",
|
||||||
|
about: "Run one of the standard repair tasks outside the full rebuild",
|
||||||
|
args: [
|
||||||
|
part: [
|
||||||
|
value_name: "PART",
|
||||||
|
help: "One of: " <> Enum.join(@repair_parts, ", "),
|
||||||
|
required: true,
|
||||||
|
parser: &parse_repair_part/1
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
render: [
|
||||||
|
name: "render",
|
||||||
|
about: "Render the blog from the current content",
|
||||||
|
flags: [
|
||||||
|
incremental: [
|
||||||
|
long: "--incremental",
|
||||||
|
help: "Validate the generated output and apply only the differences"
|
||||||
|
],
|
||||||
|
force: [
|
||||||
|
long: "--force",
|
||||||
|
help: "Full re-render ignoring (but updating) stored content hashes"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
upload: [
|
||||||
|
name: "upload",
|
||||||
|
about: "Upload the rendered site to the configured host (rsync/scp)"
|
||||||
|
],
|
||||||
|
push: [
|
||||||
|
name: "push",
|
||||||
|
about: "Push the project repository to its origin"
|
||||||
|
],
|
||||||
|
pull: [
|
||||||
|
name: "pull",
|
||||||
|
about: "Pull the project repository and update the cache database"
|
||||||
|
],
|
||||||
|
post: [
|
||||||
|
name: "post",
|
||||||
|
about:
|
||||||
|
"Create a post from parameters or from JSON on stdin " <>
|
||||||
|
"(keys: title, content, excerpt, author, language, tags, categories, template)",
|
||||||
|
flags: [
|
||||||
|
stdin: [long: "--stdin", help: "Read post data as JSON from stdin"],
|
||||||
|
no_translate: [
|
||||||
|
long: "--no-translate",
|
||||||
|
help: "Skip automatic translation of the created post"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
options: post_content_options()
|
||||||
|
],
|
||||||
|
media: [
|
||||||
|
name: "media",
|
||||||
|
about:
|
||||||
|
"Import an image with automatically generated title, alt text, " <>
|
||||||
|
"caption and translations",
|
||||||
|
args: [
|
||||||
|
file: [
|
||||||
|
value_name: "FILE",
|
||||||
|
help: "Path of the image file to import",
|
||||||
|
required: true
|
||||||
|
]
|
||||||
|
],
|
||||||
|
options: [
|
||||||
|
language: [
|
||||||
|
long: "--language",
|
||||||
|
value_name: "LANG",
|
||||||
|
help: "Language for the generated texts (defaults to the project main language)"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
gallery: [
|
||||||
|
name: "gallery",
|
||||||
|
about:
|
||||||
|
"Create a gallery post and import all referenced images as new " <>
|
||||||
|
"media with generated texts and translations",
|
||||||
|
allow_unknown_args: true,
|
||||||
|
flags: [
|
||||||
|
stdin: [
|
||||||
|
long: "--stdin",
|
||||||
|
help: "Read gallery data as JSON from stdin (adds an \"images\" key to post keys)"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
options: post_content_options()
|
||||||
|
],
|
||||||
|
config: [
|
||||||
|
name: "config",
|
||||||
|
about: "Read and write global preference values",
|
||||||
|
subcommands: [
|
||||||
|
get: [
|
||||||
|
name: "get",
|
||||||
|
about: "Print a preference value",
|
||||||
|
args: [key: [value_name: "KEY", help: "Preference key", required: true]]
|
||||||
|
],
|
||||||
|
set: [
|
||||||
|
name: "set",
|
||||||
|
about: "Set a preference value",
|
||||||
|
args: [
|
||||||
|
key: [value_name: "KEY", help: "Preference key", required: true],
|
||||||
|
value: [value_name: "VALUE", help: "New value", required: true]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
list: [
|
||||||
|
name: "list",
|
||||||
|
about: "List all preference keys and values"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
project: [
|
||||||
|
name: "project",
|
||||||
|
about: "Manage the projects registered in the cache database",
|
||||||
|
subcommands: [
|
||||||
|
list: [
|
||||||
|
name: "list",
|
||||||
|
about: "List all projects"
|
||||||
|
],
|
||||||
|
add: [
|
||||||
|
name: "add",
|
||||||
|
about: "Open a project folder and add it to the cache database",
|
||||||
|
args: [
|
||||||
|
path: [value_name: "PATH", help: "Project data folder", required: true]
|
||||||
|
],
|
||||||
|
options: [
|
||||||
|
name: [
|
||||||
|
long: "--name",
|
||||||
|
value_name: "NAME",
|
||||||
|
help: "Project name (defaults to the folder name)"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
switch: [
|
||||||
|
name: "switch",
|
||||||
|
about: "Switch the active project",
|
||||||
|
args: [
|
||||||
|
project: [
|
||||||
|
value_name: "PROJECT",
|
||||||
|
help: "Project id, slug, or name",
|
||||||
|
required: true
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
tui: [
|
||||||
|
name: "tui",
|
||||||
|
about: "Open the app in TUI mode for interactive use"
|
||||||
|
],
|
||||||
|
lua: [
|
||||||
|
name: "lua",
|
||||||
|
about: "Run a utility (long-running task) Lua script from the database",
|
||||||
|
allow_unknown_args: true,
|
||||||
|
args: [
|
||||||
|
script: [
|
||||||
|
value_name: "SCRIPT",
|
||||||
|
help: "Script slug in the active project",
|
||||||
|
required: true
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp post_content_options do
|
||||||
|
[
|
||||||
|
title: [long: "--title", value_name: "TITLE", help: "Post title"],
|
||||||
|
content: [long: "--content", value_name: "MARKDOWN", help: "Post body (markdown)"],
|
||||||
|
excerpt: [long: "--excerpt", value_name: "TEXT", help: "Post excerpt"],
|
||||||
|
author: [long: "--author", value_name: "NAME", help: "Post author"],
|
||||||
|
language: [
|
||||||
|
long: "--language",
|
||||||
|
value_name: "LANG",
|
||||||
|
help: "Post language (auto-detected from the content when omitted)"
|
||||||
|
],
|
||||||
|
template: [long: "--template", value_name: "SLUG", help: "Template slug"],
|
||||||
|
tags: [long: "--tags", value_name: "TAGS", help: "Comma-separated tags"],
|
||||||
|
categories: [
|
||||||
|
long: "--categories",
|
||||||
|
value_name: "CATEGORIES",
|
||||||
|
help: "Comma-separated categories"
|
||||||
|
]
|
||||||
|
]
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_repair_part(value) when value in @repair_parts, do: {:ok, value}
|
||||||
|
|
||||||
|
defp parse_repair_part(value),
|
||||||
|
do: {:error, "unknown repair part #{inspect(value)}; expected one of: #{Enum.join(@repair_parts, ", ")}"}
|
||||||
|
|
||||||
|
defp print_help(optimus, subcommand_path) do
|
||||||
|
optimus
|
||||||
|
|> Optimus.Help.help(subcommand_path, terminal_width())
|
||||||
|
|> Enum.each(&IO.puts/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp print_errors(optimus, [], errors) do
|
||||||
|
optimus |> Optimus.Errors.format(errors) |> Enum.each(&IO.puts(:stderr, &1))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp print_errors(optimus, subcommand_path, errors) do
|
||||||
|
optimus
|
||||||
|
|> Optimus.Errors.format(subcommand_path, errors)
|
||||||
|
|> Enum.each(&IO.puts(:stderr, &1))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp terminal_width do
|
||||||
|
case Optimus.Term.width() do
|
||||||
|
{:ok, width} -> width
|
||||||
|
_other -> 80
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp version do
|
||||||
|
to_string(Application.spec(:bds, :vsn) || "dev")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,696 @@
|
|||||||
|
defmodule BDS.CLI.Commands do
|
||||||
|
@moduledoc """
|
||||||
|
Implementations of the `bds-cli` subcommands (issue #25).
|
||||||
|
|
||||||
|
Every command runs synchronously against the engine modules the GUI uses
|
||||||
|
and returns `:ok`, `{:ok, message}`, or `{:error, message}`. Mutations
|
||||||
|
write `BDS.CliSync` notification rows so a concurrently running app
|
||||||
|
refreshes its state (see `specs/cli_sync.allium`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
alias BDS.AI
|
||||||
|
alias BDS.CliSync
|
||||||
|
alias BDS.Desktop.ShellLive.GalleryImport
|
||||||
|
alias BDS.Generation
|
||||||
|
alias BDS.Git
|
||||||
|
alias BDS.Maintenance
|
||||||
|
alias BDS.Media
|
||||||
|
alias BDS.Metadata
|
||||||
|
alias BDS.Posts
|
||||||
|
alias BDS.Posts.AutoTranslation
|
||||||
|
alias BDS.Projects
|
||||||
|
alias BDS.Publishing
|
||||||
|
alias BDS.Repo
|
||||||
|
alias BDS.Scripting
|
||||||
|
alias BDS.Scripts
|
||||||
|
alias BDS.Scripts.Script
|
||||||
|
alias BDS.Search
|
||||||
|
alias BDS.Settings
|
||||||
|
alias BDS.Tasks
|
||||||
|
|
||||||
|
@site_sections [:core, :single, :category, :tag, :date]
|
||||||
|
@bulk_entity_types ~w(post media script template)
|
||||||
|
@gallery_concurrency 2
|
||||||
|
@poll_interval_ms 250
|
||||||
|
|
||||||
|
@doc "Dispatches a parsed subcommand path to its implementation."
|
||||||
|
@spec dispatch([atom()], Optimus.ParseResult.t()) :: :ok | {:ok, String.t()} | {:error, term()}
|
||||||
|
def dispatch([:rebuild], %{flags: %{incremental: true}}),
|
||||||
|
do: with_project(&incremental_rebuild/1)
|
||||||
|
|
||||||
|
def dispatch([:rebuild], _parse_result), do: with_project(&full_rebuild/1)
|
||||||
|
|
||||||
|
def dispatch([:repair], %{args: %{part: part}}),
|
||||||
|
do: with_project(&repair(part, &1))
|
||||||
|
|
||||||
|
def dispatch([:render], %{flags: %{incremental: true, force: true}}),
|
||||||
|
do: {:error, "--incremental and --force are mutually exclusive"}
|
||||||
|
|
||||||
|
def dispatch([:render], %{flags: %{incremental: true}}),
|
||||||
|
do: with_project(&incremental_render/1)
|
||||||
|
|
||||||
|
def dispatch([:render], %{flags: flags}),
|
||||||
|
do: with_project(&full_render(&1, flags[:force] == true))
|
||||||
|
|
||||||
|
def dispatch([:upload], _parse_result), do: with_project(&upload/1)
|
||||||
|
|
||||||
|
def dispatch([:push], _parse_result) do
|
||||||
|
with_project(fn project ->
|
||||||
|
with {:ok, result} <- Git.push(project.id) do
|
||||||
|
print_git_output(result)
|
||||||
|
{:ok, "Pushed"}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:pull], _parse_result) do
|
||||||
|
with_project(fn project ->
|
||||||
|
with {:ok, result} <- Git.pull(project.id) do
|
||||||
|
print_git_output(result)
|
||||||
|
# Update the cache database from the pulled files, like the app's
|
||||||
|
# incremental rebuild: file→db for every difference plus orphans.
|
||||||
|
incremental_rebuild(project)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:post], %{flags: flags, options: options}) do
|
||||||
|
with_project(fn project ->
|
||||||
|
with {:ok, attrs} <- post_attrs(flags, options),
|
||||||
|
{:ok, attrs} <- ensure_language(attrs),
|
||||||
|
{:ok, post} <- create_post(project, attrs) do
|
||||||
|
unless flags[:no_translate], do: translate_post(post)
|
||||||
|
{:ok, "Created post #{post.id} (#{post.slug}, #{post.language})"}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:media], %{args: %{file: file}, options: options}) do
|
||||||
|
with_project(fn project ->
|
||||||
|
with {:ok, metadata} <- Metadata.get_project_metadata(project.id),
|
||||||
|
:ok <- ensure_files_exist([file]) do
|
||||||
|
language = present(options[:language]) || metadata.main_language
|
||||||
|
targets = GalleryImport.translate_targets(metadata, language)
|
||||||
|
|
||||||
|
case GalleryImport.import_and_enrich(file, project.id, language, targets) do
|
||||||
|
{:ok, media, title} ->
|
||||||
|
:ok = notify("media", media.id, :created)
|
||||||
|
{:ok, "Imported media #{media.id} (#{title})"}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, format_reason(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:gallery], %{flags: flags, options: options, unknown: unknown}) do
|
||||||
|
with_project(fn project ->
|
||||||
|
with {:ok, attrs, images} <- gallery_attrs(flags, options, unknown),
|
||||||
|
:ok <- ensure_files_exist(images),
|
||||||
|
{:ok, attrs} <- ensure_language(attrs),
|
||||||
|
{:ok, post} <- create_post(project, attrs) do
|
||||||
|
import_gallery_images(project, post, images)
|
||||||
|
:ok = notify("media", "*", :created)
|
||||||
|
:ok = notify("post", post.id, :updated)
|
||||||
|
unless flags[:no_translate], do: translate_post(post)
|
||||||
|
{:ok, "Created gallery post #{post.id} (#{post.slug}) with #{length(images)} images"}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:config, :get], %{args: %{key: key}}) do
|
||||||
|
case Settings.get_global_setting(key) do
|
||||||
|
nil -> {:error, "#{key} is not set"}
|
||||||
|
value -> {:ok, value}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:config, :set], %{args: %{key: key, value: value}}) do
|
||||||
|
with :ok <- Settings.put_global_setting(key, value) do
|
||||||
|
:ok = notify("setting", key, :updated)
|
||||||
|
{:ok, "#{key} = #{value}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:config, :list], _parse_result) do
|
||||||
|
Settings.list_global_settings()
|
||||||
|
|> Enum.each(fn {key, value} -> IO.puts("#{key}=#{value}") end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:project, :list], _parse_result) do
|
||||||
|
Enum.each(Projects.list_projects(), fn project ->
|
||||||
|
marker = if project.is_active, do: "* ", else: " "
|
||||||
|
IO.puts("#{marker}#{project.id} #{project.slug} #{project.name}")
|
||||||
|
end)
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:project, :add], %{args: %{path: path}, options: options}) do
|
||||||
|
expanded = Path.expand(path)
|
||||||
|
|
||||||
|
if File.dir?(expanded) do
|
||||||
|
name = present(options[:name]) || Path.basename(expanded)
|
||||||
|
|
||||||
|
with {:ok, project} <- Projects.create_project(%{name: name, data_path: expanded}) do
|
||||||
|
:ok = notify("project", project.id, :created)
|
||||||
|
{:ok, "Added project #{project.id} (#{project.slug}); switch with: project switch #{project.slug}"}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:error, "#{expanded} is not a directory"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:project, :switch], %{args: %{project: reference}}) do
|
||||||
|
with {:ok, project} <- resolve_project(reference),
|
||||||
|
{:ok, project} <- Projects.set_active_project(project.id) do
|
||||||
|
:ok = notify("project", project.id, :updated)
|
||||||
|
{:ok, "Active project: #{project.name}"}
|
||||||
|
else
|
||||||
|
{:error, :not_found} -> {:error, "No project matches #{inspect(reference)}"}
|
||||||
|
{:error, reason} -> {:error, format_reason(reason)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:tui], _parse_result) do
|
||||||
|
{:error,
|
||||||
|
"The TUI is started by the bds-cli launcher script (BDS_MODE=tui); " <>
|
||||||
|
"it is not available through a direct BDS.CLI invocation"}
|
||||||
|
end
|
||||||
|
|
||||||
|
def dispatch([:lua], %{args: %{script: slug}, unknown: script_args}) do
|
||||||
|
with_project(fn project ->
|
||||||
|
case Repo.one(
|
||||||
|
from script in Script,
|
||||||
|
where: script.project_id == ^project.id and script.slug == ^slug
|
||||||
|
) do
|
||||||
|
nil ->
|
||||||
|
{:error, "No script with slug #{inspect(slug)} in the active project"}
|
||||||
|
|
||||||
|
%Script{kind: kind} when kind != :utility ->
|
||||||
|
{:error, "Script #{inspect(slug)} is a #{kind} script; only utility (task) scripts can be run"}
|
||||||
|
|
||||||
|
%Script{enabled: false} ->
|
||||||
|
{:error, "Script #{inspect(slug)} is disabled"}
|
||||||
|
|
||||||
|
script ->
|
||||||
|
run_lua_job(project, script, script_args)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── rebuild ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp full_rebuild(project) do
|
||||||
|
project.id
|
||||||
|
|> Maintenance.full_rebuild_steps()
|
||||||
|
|> Enum.reduce_while(:ok, fn step, :ok ->
|
||||||
|
IO.puts("==> #{step.name}")
|
||||||
|
|
||||||
|
case step.work.(progress_printer()) do
|
||||||
|
{:error, message} -> {:halt, {:error, message}}
|
||||||
|
_result -> {:cont, :ok}
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> case do
|
||||||
|
:ok ->
|
||||||
|
notify_bulk_change()
|
||||||
|
{:ok, "Rebuild complete"}
|
||||||
|
|
||||||
|
error ->
|
||||||
|
error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp incremental_rebuild(project) do
|
||||||
|
reporter = progress_printer()
|
||||||
|
{:ok, diff} = Maintenance.metadata_diff(project.id, on_progress: reporter)
|
||||||
|
|
||||||
|
items =
|
||||||
|
Enum.map(diff.diff_reports, &%{entity_type: &1.entity_type, entity_id: &1.entity_id})
|
||||||
|
|
||||||
|
orphans = Enum.map(diff.orphan_reports, &%{file_path: &1.file_path})
|
||||||
|
|
||||||
|
if items != [] do
|
||||||
|
{:ok, _repair} =
|
||||||
|
Maintenance.repair_metadata_diff(project.id, :file_to_db, items, on_progress: reporter)
|
||||||
|
end
|
||||||
|
|
||||||
|
if orphans != [] do
|
||||||
|
{:ok, _import} =
|
||||||
|
Maintenance.import_metadata_diff_orphans(project.id, orphans, on_progress: reporter)
|
||||||
|
end
|
||||||
|
|
||||||
|
if items != [] or orphans != [], do: notify_bulk_change()
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
"Applied #{length(items)} differences and imported #{length(orphans)} orphan files from the filesystem"}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── repair ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp repair("post-links", project) do
|
||||||
|
:ok = Posts.rebuild_post_links(project.id, on_progress: progress_printer())
|
||||||
|
:ok = notify("post", "*", :updated)
|
||||||
|
{:ok, "Post links rebuilt"}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp repair("media-links", project) do
|
||||||
|
{:ok, result} = Media.rebuild_media_links(project.id, on_progress: progress_printer())
|
||||||
|
:ok = notify("media", "*", :updated)
|
||||||
|
{:ok, "Media links rebuilt (#{result.links} links)"}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp repair("thumbnails", project) do
|
||||||
|
result = Media.regenerate_missing_thumbnails(project.id, on_progress: progress_printer())
|
||||||
|
:ok = notify("media", "*", :updated)
|
||||||
|
{:ok, "Missing thumbnails regenerated (#{inspect(Map.get(result, :generated, 0))} generated)"}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp repair("embeddings", project) do
|
||||||
|
case Maintenance.rebuild_embedding_index(project.id, on_progress: progress_printer()) do
|
||||||
|
{:error, message} -> {:error, message}
|
||||||
|
result -> {:ok, "Embedding index rebuilt (#{result.rebuilt_count} posts)"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp repair("search", project) do
|
||||||
|
:ok = Search.reindex_posts(project.id, on_progress: progress_printer())
|
||||||
|
:ok = Search.reindex_media(project.id, on_progress: progress_printer())
|
||||||
|
{:ok, "Search text reindexed"}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── render ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp full_render(project, force?) do
|
||||||
|
render_opts = if force?, do: [force: true], else: []
|
||||||
|
|
||||||
|
Enum.each(@site_sections, fn section ->
|
||||||
|
IO.puts("==> Rendering #{section}")
|
||||||
|
|
||||||
|
{:ok, _result} =
|
||||||
|
Generation.render_site_section(
|
||||||
|
project.id,
|
||||||
|
section,
|
||||||
|
[on_progress: progress_printer()] ++ render_opts
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
IO.puts("==> Building search index")
|
||||||
|
|
||||||
|
{:ok, _index} =
|
||||||
|
Generation.build_site_search_index(
|
||||||
|
project.id,
|
||||||
|
[on_progress: progress_printer()] ++ render_opts
|
||||||
|
)
|
||||||
|
|
||||||
|
{:ok, "Site rendered"}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp incremental_render(project) do
|
||||||
|
{:ok, report} =
|
||||||
|
Generation.validate_site(project.id, @site_sections, on_progress: progress_printer())
|
||||||
|
|
||||||
|
{:ok, applied} =
|
||||||
|
Generation.apply_validation(project.id, report, on_progress: progress_printer())
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
"Validation applied (#{applied.rendered_url_count} rendered, " <>
|
||||||
|
"#{applied.deleted_url_count} deleted)"}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── upload ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp upload(project) do
|
||||||
|
with {:ok, metadata} <- Metadata.get_project_metadata(project.id),
|
||||||
|
{:ok, credentials} <- upload_credentials(metadata.publishing_preferences),
|
||||||
|
{:ok, job} <- Publishing.upload_site(project.id, credentials) do
|
||||||
|
await_task(job.task_id, "Upload complete")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp upload_credentials(prefs) when is_map(prefs) do
|
||||||
|
credentials = %{
|
||||||
|
ssh_host: Map.get(prefs, "ssh_host"),
|
||||||
|
ssh_user: Map.get(prefs, "ssh_user"),
|
||||||
|
ssh_remote_path: Map.get(prefs, "ssh_remote_path"),
|
||||||
|
ssh_mode: Map.get(prefs, "ssh_mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
if Enum.all?(
|
||||||
|
[credentials.ssh_host, credentials.ssh_user, credentials.ssh_remote_path],
|
||||||
|
&is_binary/1
|
||||||
|
) do
|
||||||
|
{:ok, credentials}
|
||||||
|
else
|
||||||
|
{:error, "Publishing preferences are incomplete; configure the upload host in Settings"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp upload_credentials(_prefs),
|
||||||
|
do: {:error, "Publishing preferences are incomplete; configure the upload host in Settings"}
|
||||||
|
|
||||||
|
# ── post / gallery ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp post_attrs(%{stdin: true}, _options) do
|
||||||
|
with {:ok, data} <- read_stdin_json() do
|
||||||
|
json_post_attrs(data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp post_attrs(_flags, options) do
|
||||||
|
case present(options[:title]) do
|
||||||
|
nil ->
|
||||||
|
{:error, "--title is required (or pass --stdin with JSON post data)"}
|
||||||
|
|
||||||
|
title ->
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
title: title,
|
||||||
|
content: options[:content] || "",
|
||||||
|
excerpt: present(options[:excerpt]),
|
||||||
|
author: present(options[:author]),
|
||||||
|
language: present(options[:language]),
|
||||||
|
template_slug: present(options[:template]),
|
||||||
|
tags: split_list(options[:tags]),
|
||||||
|
categories: split_list(options[:categories])
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp gallery_attrs(%{stdin: true}, _options, _unknown) do
|
||||||
|
with {:ok, data} <- read_stdin_json(),
|
||||||
|
{:ok, attrs} <- json_post_attrs(data) do
|
||||||
|
case Map.get(data, "images") do
|
||||||
|
images when is_list(images) and images != [] -> {:ok, attrs, images}
|
||||||
|
_other -> {:error, "JSON gallery data needs a non-empty \"images\" array"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp gallery_attrs(flags, options, unknown) do
|
||||||
|
case unknown do
|
||||||
|
[] ->
|
||||||
|
{:error, "Pass the image files as arguments (or use --stdin with JSON gallery data)"}
|
||||||
|
|
||||||
|
images ->
|
||||||
|
with {:ok, attrs} <- post_attrs(flags, options) do
|
||||||
|
{:ok, attrs, images}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp json_post_attrs(data) when is_map(data) do
|
||||||
|
case present(Map.get(data, "title")) do
|
||||||
|
nil ->
|
||||||
|
{:error, "JSON post data needs a \"title\""}
|
||||||
|
|
||||||
|
title ->
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
title: title,
|
||||||
|
content: Map.get(data, "content") || "",
|
||||||
|
excerpt: present(Map.get(data, "excerpt")),
|
||||||
|
author: present(Map.get(data, "author")),
|
||||||
|
language: present(Map.get(data, "language")),
|
||||||
|
template_slug: present(Map.get(data, "template")),
|
||||||
|
tags: json_list(Map.get(data, "tags")),
|
||||||
|
categories: json_list(Map.get(data, "categories"))
|
||||||
|
}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp json_post_attrs(_data), do: {:error, "JSON post data must be an object"}
|
||||||
|
|
||||||
|
defp read_stdin_json do
|
||||||
|
case IO.read(:stdio, :eof) do
|
||||||
|
:eof ->
|
||||||
|
{:error, "No JSON data on stdin"}
|
||||||
|
|
||||||
|
{:error, reason} ->
|
||||||
|
{:error, "Could not read stdin: #{inspect(reason)}"}
|
||||||
|
|
||||||
|
data ->
|
||||||
|
case Jason.decode(data) do
|
||||||
|
{:ok, decoded} -> {:ok, decoded}
|
||||||
|
{:error, error} -> {:error, "Invalid JSON on stdin: #{Exception.message(error)}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Language auto-detection (issue #25): ask the configured AI endpoint —
|
||||||
|
# BDS.AI internally routes to the local model in airplane mode — and fall
|
||||||
|
# back to the offline search heuristic, informing the user (the CLI
|
||||||
|
# equivalent of the airplane-mode toast).
|
||||||
|
defp ensure_language(%{language: language} = attrs) when is_binary(language), do: {:ok, attrs}
|
||||||
|
|
||||||
|
defp ensure_language(attrs) do
|
||||||
|
text = String.trim("#{attrs.title}\n#{attrs.content}")
|
||||||
|
|
||||||
|
language =
|
||||||
|
case AI.detect_language(text) do
|
||||||
|
{:ok, %{language_code: language}} when is_binary(language) ->
|
||||||
|
language
|
||||||
|
|
||||||
|
_other ->
|
||||||
|
IO.puts("AI language detection unavailable; using the offline heuristic")
|
||||||
|
Search.detect_language(text)
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, %{attrs | language: language}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp create_post(project, attrs) do
|
||||||
|
case Posts.create_post(Map.put(attrs, :project_id, project.id)) do
|
||||||
|
{:ok, post} ->
|
||||||
|
:ok = notify("post", post.id, :created)
|
||||||
|
{:ok, post}
|
||||||
|
|
||||||
|
{:error, %Ecto.Changeset{} = changeset} ->
|
||||||
|
{:error, changeset_message(changeset)}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Automatic translation mirrors the GUI: schedule the background tasks and
|
||||||
|
# wait for them, so the CLI exits only when the work is done. When nothing
|
||||||
|
# gets scheduled (airplane mode without a local model, or a single-language
|
||||||
|
# blog) the user is told instead of a silent no-op.
|
||||||
|
defp translate_post(post) do
|
||||||
|
before_count = length(active_tasks())
|
||||||
|
:ok = AutoTranslation.maybe_schedule(post)
|
||||||
|
|
||||||
|
case length(active_tasks()) - before_count do
|
||||||
|
queued when queued > 0 ->
|
||||||
|
IO.puts("Waiting for #{queued} translation task(s)...")
|
||||||
|
await_active_tasks()
|
||||||
|
:ok = notify("post", post.id, :updated)
|
||||||
|
|
||||||
|
_none ->
|
||||||
|
IO.puts("Automatic translation was not scheduled (offline, unconfigured AI, or nothing to translate)")
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp import_gallery_images(project, post, images) do
|
||||||
|
{:ok, metadata} = Metadata.get_project_metadata(project.id)
|
||||||
|
language = post.language || metadata.main_language
|
||||||
|
parent = self()
|
||||||
|
|
||||||
|
runner =
|
||||||
|
Task.async(fn ->
|
||||||
|
GalleryImport.start(
|
||||||
|
images,
|
||||||
|
project.id,
|
||||||
|
post.id,
|
||||||
|
language,
|
||||||
|
@gallery_concurrency,
|
||||||
|
parent
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
print_gallery_progress()
|
||||||
|
Task.await(runner, :infinity)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp print_gallery_progress do
|
||||||
|
receive do
|
||||||
|
{:add_image_processed, title} ->
|
||||||
|
IO.puts("Imported #{title}")
|
||||||
|
print_gallery_progress()
|
||||||
|
|
||||||
|
{:add_image_error, path, reason} ->
|
||||||
|
IO.puts(:stderr, "Failed to import #{path}: #{format_reason(reason)}")
|
||||||
|
print_gallery_progress()
|
||||||
|
|
||||||
|
{:add_images_complete, count} ->
|
||||||
|
IO.puts("Processed #{count} image(s)")
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp ensure_files_exist(paths) do
|
||||||
|
case Enum.reject(paths, &File.regular?/1) do
|
||||||
|
[] -> :ok
|
||||||
|
missing -> {:error, "No such file: #{Enum.join(missing, ", ")}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── lua ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Utility ("task") scripts run with the managed-job execution budget
|
||||||
|
# (unlimited time/reductions) but synchronously — the CLI is the one
|
||||||
|
# waiting for the long-running activity.
|
||||||
|
defp run_lua_job(project, script, args) do
|
||||||
|
scripting_config = Application.fetch_env!(:bds, :scripting)
|
||||||
|
|
||||||
|
case Scripting.execute_project_script(
|
||||||
|
project.id,
|
||||||
|
Scripts.resolved_content(script),
|
||||||
|
script.entrypoint || "main",
|
||||||
|
args,
|
||||||
|
timeout: Keyword.get(scripting_config, :job_timeout, :infinity),
|
||||||
|
max_reductions: Keyword.get(scripting_config, :job_max_reductions, :none),
|
||||||
|
on_output: fn text -> IO.puts(text) end
|
||||||
|
) do
|
||||||
|
{:ok, nil} -> {:ok, "Script finished"}
|
||||||
|
{:ok, result} -> {:ok, "Script finished: #{format_reason(result)}"}
|
||||||
|
{:error, reason} -> {:error, "Script failed: #{format_reason(reason)}"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ── shared helpers ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
defp with_project(fun) do
|
||||||
|
case Projects.get_active_project() do
|
||||||
|
nil -> {:error, "No active project selected; use: project switch <project>"}
|
||||||
|
project -> fun.(project)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp resolve_project(reference) do
|
||||||
|
projects = Projects.list_projects()
|
||||||
|
|
||||||
|
found =
|
||||||
|
Enum.find(projects, fn project ->
|
||||||
|
reference in [project.id, project.slug, project.name]
|
||||||
|
end)
|
||||||
|
|
||||||
|
if found, do: {:ok, found}, else: {:error, :not_found}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp await_task(task_id, success_message) do
|
||||||
|
case Tasks.get_task(task_id) do
|
||||||
|
nil ->
|
||||||
|
{:error, "Task disappeared before completion"}
|
||||||
|
|
||||||
|
%{status: :completed} ->
|
||||||
|
{:ok, success_message}
|
||||||
|
|
||||||
|
%{status: :failed} = task ->
|
||||||
|
{:error, format_reason(task.error || "task failed")}
|
||||||
|
|
||||||
|
%{message: message} = task ->
|
||||||
|
print_progress(task.progress || 0.0, message)
|
||||||
|
Process.sleep(@poll_interval_ms)
|
||||||
|
await_task(task_id, success_message)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp active_tasks do
|
||||||
|
Enum.filter(Tasks.list_tasks(), &(&1.status in [:pending, :running]))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp await_active_tasks do
|
||||||
|
case active_tasks() do
|
||||||
|
[] ->
|
||||||
|
:ok
|
||||||
|
|
||||||
|
_tasks ->
|
||||||
|
Process.sleep(@poll_interval_ms)
|
||||||
|
await_active_tasks()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
A 2-arity progress callback that prints deduplicated `[ 42%] message`
|
||||||
|
lines to stdout.
|
||||||
|
"""
|
||||||
|
def progress_printer do
|
||||||
|
&print_progress/2
|
||||||
|
end
|
||||||
|
|
||||||
|
defp print_progress(value, message) do
|
||||||
|
percent = value |> Kernel.*(100) |> round() |> min(100) |> max(0)
|
||||||
|
line = {percent, message}
|
||||||
|
|
||||||
|
if Process.get({__MODULE__, :last_progress}) != line do
|
||||||
|
Process.put({__MODULE__, :last_progress}, line)
|
||||||
|
|
||||||
|
if is_binary(message) and message != "" do
|
||||||
|
IO.puts("[#{String.pad_leading(Integer.to_string(percent), 3)}%] #{message}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
defp print_git_output(%{output: output}) when is_binary(output) and output != "" do
|
||||||
|
IO.puts(String.trim_trailing(output))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp print_git_output(_result), do: :ok
|
||||||
|
|
||||||
|
defp notify(entity_type, entity_id, action) do
|
||||||
|
{:ok, _notification} = CliSync.cli_mutation_performed(entity_type, entity_id, action)
|
||||||
|
:ok
|
||||||
|
end
|
||||||
|
|
||||||
|
# Bulk maintenance touched an unknown set of rows; a wildcard notification
|
||||||
|
# per entity type makes a running app reload its lists.
|
||||||
|
defp notify_bulk_change do
|
||||||
|
Enum.each(@bulk_entity_types, ¬ify(&1, "*", :updated))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp split_list(nil), do: []
|
||||||
|
|
||||||
|
defp split_list(value) when is_binary(value) do
|
||||||
|
value
|
||||||
|
|> String.split(",")
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.reject(&(&1 == ""))
|
||||||
|
end
|
||||||
|
|
||||||
|
defp json_list(value) when is_list(value), do: Enum.map(value, &to_string/1)
|
||||||
|
defp json_list(value) when is_binary(value), do: split_list(value)
|
||||||
|
defp json_list(_value), do: []
|
||||||
|
|
||||||
|
defp present(nil), do: nil
|
||||||
|
|
||||||
|
defp present(value) when is_binary(value) do
|
||||||
|
case String.trim(value) do
|
||||||
|
"" -> nil
|
||||||
|
trimmed -> trimmed
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp changeset_message(%Ecto.Changeset{} = changeset) do
|
||||||
|
changeset
|
||||||
|
|> Ecto.Changeset.traverse_errors(fn {message, opts} ->
|
||||||
|
Enum.reduce(opts, message, fn {key, value}, acc ->
|
||||||
|
String.replace(acc, "%{#{key}}", to_string(value))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|> Enum.map_join("; ", fn {field, messages} -> "#{field}: #{Enum.join(messages, ", ")}" end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_reason(reason) when is_binary(reason), do: reason
|
||||||
|
defp format_reason(%{message: message}) when is_binary(message), do: message
|
||||||
|
defp format_reason(%{guidance: guidance}) when is_binary(guidance), do: guidance
|
||||||
|
defp format_reason(reason), do: inspect(reason)
|
||||||
|
end
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
defmodule BDS.CLI.Install do
|
||||||
|
@moduledoc """
|
||||||
|
Installs the `bds-cli` launcher into `~/.local/bin` (issue #25), used by
|
||||||
|
the install buttons in the GUI and TUI settings.
|
||||||
|
|
||||||
|
The installed file is a two-line shim exec'ing the release's
|
||||||
|
`cli/bin/bds-cli` launcher (shipped via `rel/overlays`), so the CLI always
|
||||||
|
runs the same release — and therefore the same settings and cache
|
||||||
|
database — as the installed application.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@launcher_name "bds-cli"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Writes the `~/.local/bin/bds-cli` shim. Returns `{:ok, installed_path}`,
|
||||||
|
or `{:error, :no_release}` when not running from a packaged release (dev
|
||||||
|
runs have no launcher to point at).
|
||||||
|
"""
|
||||||
|
@spec install(keyword()) :: {:ok, Path.t()} | {:error, :no_release | File.posix()}
|
||||||
|
def install(opts \\ []) do
|
||||||
|
bin_dir = Keyword.get(opts, :bin_dir, Path.expand("~/.local/bin"))
|
||||||
|
|
||||||
|
with {:ok, launcher} <- launcher_path(Keyword.get(opts, :release_root)) do
|
||||||
|
installed = Path.join(bin_dir, @launcher_name)
|
||||||
|
|
||||||
|
with :ok <- File.mkdir_p(bin_dir),
|
||||||
|
:ok <- File.write(installed, shim_script(launcher)),
|
||||||
|
:ok <- File.chmod(installed, 0o755) do
|
||||||
|
{:ok, installed}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
The release's CLI launcher script, resolved from `RELEASE_ROOT` (set by
|
||||||
|
release scripts) or the code root dir (the release root when running a
|
||||||
|
release). `{:error, :no_release}` when neither contains the launcher.
|
||||||
|
"""
|
||||||
|
@spec launcher_path(Path.t() | nil) :: {:ok, Path.t()} | {:error, :no_release}
|
||||||
|
def launcher_path(release_root \\ nil) do
|
||||||
|
[release_root, System.get_env("RELEASE_ROOT"), to_string(:code.root_dir())]
|
||||||
|
|> Enum.reject(&(&1 in [nil, ""]))
|
||||||
|
|> Enum.map(&Path.join([&1, "cli", "bin", @launcher_name]))
|
||||||
|
|> Enum.find(&File.regular?/1)
|
||||||
|
|> case do
|
||||||
|
nil -> {:error, :no_release}
|
||||||
|
path -> {:ok, path}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp shim_script(launcher) do
|
||||||
|
"""
|
||||||
|
#!/bin/sh
|
||||||
|
exec "#{launcher}" "$@"
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
end
|
||||||
+19
-4
@@ -39,26 +39,41 @@ 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
|
||||||
|
|
||||||
|
def data_version do
|
||||||
|
%{rows: [[version]]} = Repo.query!("PRAGMA data_version", [])
|
||||||
|
version
|
||||||
|
end
|
||||||
|
|
||||||
def prune_notifications(now \\ Persistence.now_ms()) when is_integer(now) do
|
def prune_notifications(now \\ Persistence.now_ms()) when is_integer(now) do
|
||||||
{processed_count, _} =
|
{processed_count, _} =
|
||||||
Repo.delete_all(
|
Repo.delete_all(
|
||||||
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
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
defmodule BDS.CliSync.Watcher do
|
||||||
|
@moduledoc false
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
alias BDS.CliSync
|
||||||
|
|
||||||
|
@topic "entity:changed"
|
||||||
|
@default_poll_interval_ms 100
|
||||||
|
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
case Keyword.pop(opts, :name, __MODULE__) do
|
||||||
|
{nil, init_opts} -> GenServer.start_link(__MODULE__, init_opts)
|
||||||
|
{name, init_opts} -> GenServer.start_link(__MODULE__, init_opts, name: name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def topic, do: @topic
|
||||||
|
|
||||||
|
def poll_now(server \\ __MODULE__) do
|
||||||
|
GenServer.call(server, :poll_now)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(opts) do
|
||||||
|
state = %{
|
||||||
|
poll_interval_ms:
|
||||||
|
normalize_positive_integer(
|
||||||
|
Keyword.get(opts, :poll_interval_ms),
|
||||||
|
@default_poll_interval_ms
|
||||||
|
),
|
||||||
|
pubsub: Keyword.get(opts, :pubsub, BDS.PubSub),
|
||||||
|
data_version_reader: Keyword.get(opts, :data_version_reader, &CliSync.data_version/0),
|
||||||
|
notification_fetcher:
|
||||||
|
Keyword.get(opts, :notification_fetcher, &CliSync.db_file_change_detected/0),
|
||||||
|
pruner: Keyword.get(opts, :pruner, &CliSync.prune_notifications/0),
|
||||||
|
last_data_version: nil
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, schedule_poll(state)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:poll_now, _from, state) do
|
||||||
|
{:reply, :ok, process_notifications(state)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:poll, state) do
|
||||||
|
{:noreply,
|
||||||
|
state
|
||||||
|
|> process_notifications()
|
||||||
|
|> schedule_poll()}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp process_notifications(state) do
|
||||||
|
current_data_version = state.data_version_reader.()
|
||||||
|
|
||||||
|
if state.last_data_version == current_data_version do
|
||||||
|
%{state | last_data_version: current_data_version}
|
||||||
|
else
|
||||||
|
{:ok, notifications} = state.notification_fetcher.()
|
||||||
|
{:ok, _pruned} = state.pruner.()
|
||||||
|
|
||||||
|
Enum.each(notifications, fn notification ->
|
||||||
|
Phoenix.PubSub.broadcast(
|
||||||
|
state.pubsub,
|
||||||
|
topic(),
|
||||||
|
{:entity_changed, notification_payload(notification)}
|
||||||
|
)
|
||||||
|
end)
|
||||||
|
|
||||||
|
%{state | last_data_version: current_data_version}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp notification_payload(notification) do
|
||||||
|
%{
|
||||||
|
entity: notification.entity_type,
|
||||||
|
entity_id: notification.entity_id,
|
||||||
|
action: notification.action
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp schedule_poll(state) do
|
||||||
|
Process.send_after(self(), :poll, state.poll_interval_ms)
|
||||||
|
state
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_positive_integer(value, _default) when is_integer(value) and value > 0, do: value
|
||||||
|
defp normalize_positive_integer(_value, default), do: default
|
||||||
|
end
|
||||||
@@ -2,6 +2,7 @@ defmodule BDS.Desktop.Automation do
|
|||||||
@moduledoc false
|
@moduledoc false
|
||||||
|
|
||||||
use GenServer
|
use GenServer
|
||||||
|
require Logger
|
||||||
|
|
||||||
@ready_timeout 60_000
|
@ready_timeout 60_000
|
||||||
@request_timeout 30_000
|
@request_timeout 30_000
|
||||||
@@ -13,7 +14,9 @@ defmodule BDS.Desktop.Automation do
|
|||||||
def stop_session(session) do
|
def stop_session(session) do
|
||||||
GenServer.stop(session, :normal, @request_timeout)
|
GenServer.stop(session, :normal, @request_timeout)
|
||||||
catch
|
catch
|
||||||
:exit, _reason -> :ok
|
:exit, reason ->
|
||||||
|
Logger.debug("swallowed desktop automation stop_session exit: #{inspect(reason)}")
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
def snapshot(session) do
|
def snapshot(session) do
|
||||||
@@ -58,7 +61,6 @@ defmodule BDS.Desktop.Automation do
|
|||||||
base_url = BDS.Desktop.url(port)
|
base_url = BDS.Desktop.url(port)
|
||||||
|
|
||||||
File.mkdir_p!(screenshot_dir)
|
File.mkdir_p!(screenshot_dir)
|
||||||
ensure_http_client_started()
|
|
||||||
|
|
||||||
app_port = start_app_process(project_root, port)
|
app_port = start_app_process(project_root, port)
|
||||||
:ok = wait_for_server(base_url)
|
:ok = wait_for_server(base_url)
|
||||||
@@ -107,7 +109,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 +208,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}"
|
||||||
|
|
||||||
@@ -217,7 +223,12 @@ defmodule BDS.Desktop.Automation do
|
|||||||
defp safe_driver_request(state, payload) do
|
defp safe_driver_request(state, payload) do
|
||||||
driver_request(state, payload)
|
driver_request(state, payload)
|
||||||
rescue
|
rescue
|
||||||
_error -> :ok
|
error ->
|
||||||
|
Logger.debug(
|
||||||
|
"swallowed desktop automation safe_driver_request error: #{inspect(error)} payload=#{inspect(payload)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
defp shutdown_driver(state) do
|
defp shutdown_driver(state) do
|
||||||
@@ -242,20 +253,29 @@ 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,
|
||||||
decoded = Jason.decode!(message)
|
{acc, _} ->
|
||||||
|
case decode_driver_message(message) do
|
||||||
|
:skip ->
|
||||||
|
{:cont, {acc, nil}}
|
||||||
|
|
||||||
|
{:ok, decoded} ->
|
||||||
case matcher.(decoded) do
|
case matcher.(decoded) do
|
||||||
{:ok, reply} -> {:halt, {acc, reply}}
|
{:ok, reply} -> {:halt, {acc, reply}}
|
||||||
:continue -> {:cont, {acc, nil}}
|
:continue -> {:cont, {acc, nil}}
|
||||||
end
|
end
|
||||||
|
end
|
||||||
end) do
|
end) do
|
||||||
{state, nil} ->
|
{state, nil} ->
|
||||||
remaining = max(deadline - System.monotonic_time(:millisecond), 0)
|
remaining = max(deadline - System.monotonic_time(:millisecond), 0)
|
||||||
|
|
||||||
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}"
|
||||||
@@ -282,14 +302,40 @@ defmodule BDS.Desktop.Automation do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp decode_driver_message(message) do
|
||||||
|
trimmed = String.trim(message)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
trimmed == "" ->
|
||||||
|
:skip
|
||||||
|
|
||||||
|
not String.starts_with?(trimmed, "{") ->
|
||||||
|
:skip
|
||||||
|
|
||||||
|
true ->
|
||||||
|
case Jason.decode(trimmed) do
|
||||||
|
{:ok, decoded} -> {:ok, decoded}
|
||||||
|
{:error, _reason} -> :skip
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp wait_for_server(base_url) do
|
defp wait_for_server(base_url) do
|
||||||
deadline = System.monotonic_time(:millisecond) + @ready_timeout
|
deadline = System.monotonic_time(:millisecond) + @ready_timeout
|
||||||
do_wait_for_server(base_url, deadline)
|
do_wait_for_server(base_url, deadline)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp do_wait_for_server(base_url, deadline) do
|
defp do_wait_for_server(base_url, deadline) do
|
||||||
case :httpc.request(:get, {String.to_charlist(base_url <> "health"), []}, [], []) do
|
case Req.request(
|
||||||
{:ok, {{_, 200, _}, _headers, _body}} -> :ok
|
method: :get,
|
||||||
|
url: base_url <> "health",
|
||||||
|
retry: false,
|
||||||
|
connect_options: [timeout: 1_000],
|
||||||
|
receive_timeout: 1_000
|
||||||
|
) do
|
||||||
|
{:ok, %Req.Response{status: 200}} ->
|
||||||
|
:ok
|
||||||
|
|
||||||
_other ->
|
_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"
|
||||||
@@ -307,12 +353,6 @@ defmodule BDS.Desktop.Automation do
|
|||||||
port
|
port
|
||||||
end
|
end
|
||||||
|
|
||||||
defp ensure_http_client_started do
|
|
||||||
_ = Application.ensure_all_started(:inets)
|
|
||||||
_ = Application.ensure_all_started(:ssl)
|
|
||||||
:ok
|
|
||||||
end
|
|
||||||
|
|
||||||
defp await_port_exit(nil, _timeout), do: :ok
|
defp await_port_exit(nil, _timeout), do: :ok
|
||||||
|
|
||||||
defp await_port_exit(port, timeout) do
|
defp await_port_exit(port, timeout) do
|
||||||
@@ -336,19 +376,15 @@ defmodule BDS.Desktop.Automation do
|
|||||||
defp safe_close_port(port) do
|
defp safe_close_port(port) do
|
||||||
Port.close(port)
|
Port.close(port)
|
||||||
catch
|
catch
|
||||||
:error, _reason -> :ok
|
:error, reason ->
|
||||||
|
Logger.debug("swallowed desktop automation safe_close_port error: #{inspect(reason)}")
|
||||||
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_simple_reply("ok"), do: :ok
|
defp normalize_simple_reply("ok"), do: :ok
|
||||||
defp normalize_simple_reply(reply), do: reply
|
defp normalize_simple_reply(reply), do: reply
|
||||||
|
|
||||||
defp atomize_map(map) when is_map(map) do
|
defp atomize_map(map) when is_map(map), do: BDS.MapUtils.safe_atomize_keys(map)
|
||||||
Enum.into(map, %{}, fn {key, value} ->
|
|
||||||
normalized_key = if is_binary(key), do: String.to_atom(key), else: key
|
|
||||||
normalized_value = if is_map(value), do: atomize_map(value), else: value
|
|
||||||
{normalized_key, normalized_value}
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp project_root do
|
defp project_root do
|
||||||
Path.expand("../../..", __DIR__)
|
Path.expand("../../..", __DIR__)
|
||||||
|
|||||||
Loaded 100 of 585 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user