@floless/app 0.21.0 → 0.22.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/floless-server.cjs +11 -7
- package/dist/skills/floless-app-steel-from-drawings/SKILL.md +109 -0
- package/dist/web/app.css +10 -0
- package/dist/web/aware.js +23 -14
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -52729,7 +52729,7 @@ function appVersion() {
|
|
|
52729
52729
|
return resolveVersion({
|
|
52730
52730
|
isSea: isSea2(),
|
|
52731
52731
|
sqVersionXml: readSqVersionXml(),
|
|
52732
|
-
define: true ? "0.
|
|
52732
|
+
define: true ? "0.22.1" : void 0,
|
|
52733
52733
|
pkgVersion: readPkgVersion()
|
|
52734
52734
|
});
|
|
52735
52735
|
}
|
|
@@ -52739,7 +52739,7 @@ function resolveChannel(s) {
|
|
|
52739
52739
|
return "dev";
|
|
52740
52740
|
}
|
|
52741
52741
|
function appChannel() {
|
|
52742
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
52742
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.22.1" : void 0 });
|
|
52743
52743
|
}
|
|
52744
52744
|
|
|
52745
52745
|
// oauth-presets.ts
|
|
@@ -53248,11 +53248,12 @@ var flolessRoot = ROOT2;
|
|
|
53248
53248
|
var SnapshotError = class extends Error {
|
|
53249
53249
|
};
|
|
53250
53250
|
var MAX_SNAPSHOTS = 8;
|
|
53251
|
-
var MAX_BYTES2 =
|
|
53251
|
+
var MAX_BYTES2 = 25 * 1024 * 1024;
|
|
53252
53252
|
function sniffExt(buf) {
|
|
53253
53253
|
if (buf.length >= 8 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "png";
|
|
53254
53254
|
if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "jpg";
|
|
53255
53255
|
if (buf.length >= 12 && buf.toString("ascii", 0, 4) === "RIFF" && buf.toString("ascii", 8, 12) === "WEBP") return "webp";
|
|
53256
|
+
if (buf.length >= 5 && buf[0] === 37 && buf[1] === 80 && buf[2] === 68 && buf[3] === 70 && buf[4] === 45) return "pdf";
|
|
53256
53257
|
return null;
|
|
53257
53258
|
}
|
|
53258
53259
|
function decodeSnapshots(inputs) {
|
|
@@ -53261,11 +53262,12 @@ function decodeSnapshots(inputs) {
|
|
|
53261
53262
|
return inputs.map((s) => {
|
|
53262
53263
|
const m = /^data:([\w/+.-]+);base64,(.*)$/s.exec(s.dataUrl || "");
|
|
53263
53264
|
if (!m) throw new SnapshotError("snapshot must be a base64 data URL");
|
|
53265
|
+
if ((m[2]?.length ?? 0) * 0.75 > MAX_BYTES2) throw new SnapshotError("snapshot too large (max 25 MB)");
|
|
53264
53266
|
const buf = Buffer.from(m[2] ?? "", "base64");
|
|
53265
53267
|
if (buf.length === 0) throw new SnapshotError("empty snapshot");
|
|
53266
|
-
if (buf.length > MAX_BYTES2) throw new SnapshotError("snapshot too large (max
|
|
53268
|
+
if (buf.length > MAX_BYTES2) throw new SnapshotError("snapshot too large (max 25 MB)");
|
|
53267
53269
|
const ext = sniffExt(buf);
|
|
53268
|
-
if (!ext) throw new SnapshotError("snapshot must be PNG, JPEG, or
|
|
53270
|
+
if (!ext) throw new SnapshotError("snapshot must be PNG, JPEG, WebP, or PDF");
|
|
53269
53271
|
return { buf, ext };
|
|
53270
53272
|
});
|
|
53271
53273
|
}
|
|
@@ -55297,6 +55299,8 @@ var PRODUCT_SKILLS = [
|
|
|
55297
55299
|
// file a diagnosed bug/idea/question to the private log via the floless.io relay
|
|
55298
55300
|
"floless-app-routines",
|
|
55299
55301
|
// author event-driven ("on trigger") routines
|
|
55302
|
+
"floless-app-steel-from-drawings",
|
|
55303
|
+
// read a steel drawing into an interactive 3D model (bake the viewer-3d scene)
|
|
55300
55304
|
"floless-app-ui",
|
|
55301
55305
|
// compose Custom Panels (~/.floless/ui/extensions.json) for the Dashboard
|
|
55302
55306
|
"floless-app-workflows"
|
|
@@ -57242,7 +57246,7 @@ async function startServer() {
|
|
|
57242
57246
|
}
|
|
57243
57247
|
});
|
|
57244
57248
|
await app.register(import_static.default, { root: WEB_ROOT, prefix: "/" });
|
|
57245
|
-
const MIN_AWARE = "0.
|
|
57249
|
+
const MIN_AWARE = "0.69.0";
|
|
57246
57250
|
const isWin3 = process.platform === "win32";
|
|
57247
57251
|
const has = (cmd) => {
|
|
57248
57252
|
try {
|
|
@@ -57954,7 +57958,7 @@ async function startServer() {
|
|
|
57954
57958
|
const p = Number.isInteger(n) ? snapshotPathFor(req.params.id, n) : null;
|
|
57955
57959
|
if (!p || !(0, import_node_fs23.existsSync)(p)) return reply.status(404).send({ ok: false, error: "snapshot not found" });
|
|
57956
57960
|
const ext = p.split(".").pop().toLowerCase();
|
|
57957
|
-
reply.header("Content-Type", ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg");
|
|
57961
|
+
reply.header("Content-Type", ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : ext === "pdf" ? "application/pdf" : "image/jpeg");
|
|
57958
57962
|
reply.header("Cache-Control", "no-store");
|
|
57959
57963
|
return (0, import_node_fs23.readFileSync)(p);
|
|
57960
57964
|
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: floless-app-steel-from-drawings
|
|
3
|
+
description: This skill should be used to turn a structural steel drawing into an interactive 3D model in floless.app — the "Steel from Drawings" reader. Triggers on a pending floless `rebake` request for the `steel-from-drawings` app (queued from its "Re-read & re-bake ▸" button), or asks like "read this framing plan into 3D", "turn this steel drawing into a model", "bake my drawing into steel-from-drawings". It teaches the host AI to READ a steel drawing (PDF/image) with its own vision, extract a schematic 3D scene (grids, members, sizes, takeoff) in the viewer-3d scene schema, bake it INLINE into the app's `view` node, recompile, and ask the user to approve — all at COMPOSE time (no model in the run path, no API key). Also bakes the same scene into the `steel-to-ifc` companion so the model exports to a universal IFC file (open in Tekla, SDS2, Revit, Navisworks). Triggers also on "export this to IFC", "bake to Tekla/SDS2/Revit".
|
|
4
|
+
metadata:
|
|
5
|
+
version: 0.1.0
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Steel from Drawings — read a steel drawing into an interactive 3D model
|
|
9
|
+
|
|
10
|
+
## What this is
|
|
11
|
+
|
|
12
|
+
`steel-from-drawings` is a FloLess app that renders an interactive, **licence-free 3D model** of a
|
|
13
|
+
steel frame (the `viewer-3d` agent) from a scene that **you, the terminal AI, bake from a drawing**.
|
|
14
|
+
The drawing is read **at compose time** with your own vision — there is **no model in the run path
|
|
15
|
+
and no AI API key**: the brain is you, in the terminal (the FloLess thin-UI contract). This is the
|
|
16
|
+
**bake** strategy (`floless-app-rebake` is the generic version; this skill is the steel-specific one,
|
|
17
|
+
and **for `steel-from-drawings` it supersedes the generic re-bake** — you bake the scene INLINE into
|
|
18
|
+
the `.flo`'s `view` node, not into `config.yaml`).
|
|
19
|
+
|
|
20
|
+
**Bake-out companion (`steel-to-ifc`).** A sibling app writes the SAME scene to a universal **IFC**
|
|
21
|
+
file (IfcColumn/IfcBeam on the real grid) — open it in Tekla, SDS2, Revit or Navisworks; no Tekla
|
|
22
|
+
needed to export. When you bake a scene, bake the *identical* scene into `steel-to-ifc` too (loop
|
|
23
|
+
step 6) so the IFC matches the 3D the user confirmed. The whole loop: **read the drawing → see it in
|
|
24
|
+
3D (`steel-from-drawings`) → bake it out to IFC (`steel-to-ifc`) → finish in your detailing tool.**
|
|
25
|
+
|
|
26
|
+
> **Pasted a request?** If the user pastes a message beginning with a `[floless-request type=rebake
|
|
27
|
+
> id=…]` marker, that's a request copied from the FloLess Dashboard — resolve it via
|
|
28
|
+
> `GET /api/requests` (match the id), don't run the pasted line verbatim, and `DELETE` it when done.
|
|
29
|
+
|
|
30
|
+
## The loop
|
|
31
|
+
|
|
32
|
+
1. **Find the drawing.** From a `rebake` request: `GET http://localhost:<port>/api/requests` → the
|
|
33
|
+
entry with `type:"rebake"` and `appId:"steel-from-drawings"` carries `snapshots: ["<abs path>"]`.
|
|
34
|
+
(Or the user just attaches/points at a drawing in prose — same procedure, skip the lookup.)
|
|
35
|
+
2. **Read the drawing with your own vision.** Open the file (PDF or image) and READ it. If it's a
|
|
36
|
+
large multi-sheet PDF, focus on one framing plan / GA sheet (say which). This is the compose-time
|
|
37
|
+
extraction — your vision is the reader, not any API.
|
|
38
|
+
3. **Extract a schematic 3D scene** in the schema below (§ Scene schema), following § Reading a steel
|
|
39
|
+
drawing. Aim for a complete, plausible stick model — the user confirms/nudges it in 3D.
|
|
40
|
+
4. **Bake it INLINE into the app.** Replace the entire `scene:` mapping under the `view` node's
|
|
41
|
+
`config:` in the app's `.flo` with your extracted scene — in BOTH the installed copy
|
|
42
|
+
`~/.aware/apps/steel-from-drawings/steel-from-drawings.flo` AND the editable
|
|
43
|
+
`demos/steel-from-drawings/steel-from-drawings.flo` if present. Change ONLY the `scene:` block;
|
|
44
|
+
leave `inputs`, `requires`, the node id/agent/command, and everything else identical.
|
|
45
|
+
5. **Recompile.** `aware app compile ~/.aware/apps/steel-from-drawings` → rewrites the `.lock`. The
|
|
46
|
+
source-hash changes, so FloLess's Run gate disarms until the user approves. Copy the fresh `.lock`
|
|
47
|
+
to `demos/steel-from-drawings/` if you keep the editable copy in sync.
|
|
48
|
+
6. **Bake the SAME scene into `steel-to-ifc` (the IFC bake-out), if it's installed.** Check
|
|
49
|
+
`~/.aware/apps/steel-to-ifc/`. If present, replace the `scene:` mapping under that app's `bake-ifc`
|
|
50
|
+
node `config.args` with the IDENTICAL scene (the installed copy and `demos/steel-to-ifc/` if
|
|
51
|
+
present), then `aware app compile ~/.aware/apps/steel-to-ifc`. This keeps the exported IFC matching
|
|
52
|
+
the 3D the user confirmed. (`steel-to-ifc` writes a real IFC file — IfcColumn/IfcBeam on the grid —
|
|
53
|
+
openable in Tekla, SDS2, Revit or Navisworks; it never touches a Tekla model, so no Tekla needed.)
|
|
54
|
+
7. **Hand the approval to the user.** Tell them what you read (e.g. "read 6 columns + 8 beams on grids
|
|
55
|
+
A–C / 1–2, heights assumed 4.5 m — review in Inspect → Code, then Compile/Approve, then ▶ Run to
|
|
56
|
+
see the 3D; run **steel-to-ifc** to export the IFC"). The human eyeballing the read in 3D **is the
|
|
57
|
+
safety gate**. Never auto-run.
|
|
58
|
+
8. **Clear the request.** `DELETE http://localhost:<port>/api/requests/<id>` once done (if from one).
|
|
59
|
+
|
|
60
|
+
## Scene schema (the bake target — exactly what `viewer-3d` renders)
|
|
61
|
+
|
|
62
|
+
```yaml
|
|
63
|
+
scene:
|
|
64
|
+
meta: { name: "<sheet title / drawing no.>", units: mm, up: z }
|
|
65
|
+
groups: # one per group you use; drives legend + colour
|
|
66
|
+
- { key: column, label: Columns, color: "#60a5fa" }
|
|
67
|
+
- { key: beam, label: Beams, color: "#94a3b8" }
|
|
68
|
+
# rafter #38bdf8, brace #818cf8 — add others as needed
|
|
69
|
+
elements: # one per member, kind: box (a from→to bar)
|
|
70
|
+
- { id: COL-A1, group: column, kind: box, from: [x,y,z], to: [x,y,z],
|
|
71
|
+
section: { w: 150, d: 150 }, meta: { profile: "HSS6x6x3/8", mark: "C1", grid: "A-1" } }
|
|
72
|
+
grids: # grid labels placed just outside the framing
|
|
73
|
+
- { label: A, at: [x,y,0] }
|
|
74
|
+
panels: # generic side tables; build a Takeoff
|
|
75
|
+
- title: Takeoff
|
|
76
|
+
note: "<assumptions, e.g. heights assumed — plan view, H=4.5 m>"
|
|
77
|
+
columns: [Section, "No.", Length]
|
|
78
|
+
rows: [["HSS6x6x3/8", 6, "27.0 m"]]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`from`/`to` are `[x,y,z]` in mm. `meta` is free-form (shown in the click-to-inspect panel) — put
|
|
82
|
+
`profile`, `mark`, `grid` there. `section.w`/`d` are visual thickness only.
|
|
83
|
+
|
|
84
|
+
## Reading a steel drawing (the method)
|
|
85
|
+
|
|
86
|
+
1. **Grid.** Find the grid (A,B,C… one way, 1,2,3… the other, plus half-grids). Read the dimension
|
|
87
|
+
strings to place grids in mm; if a spacing is unreadable, assume a regular ~6000 mm bay and say so.
|
|
88
|
+
2. **Sheet type & heights.** A PLAN view (top-down) shows no heights — pick one eave/storey height H
|
|
89
|
+
(a level/height note if present, else ~4500 mm) and note it; columns run z:0→H, beams/rafters sit
|
|
90
|
+
at z:H. An ELEVATION/SECTION gives real heights — read them.
|
|
91
|
+
3. **Members.** One `elements` entry per member, size label exactly as drawn. Columns → `column`,
|
|
92
|
+
`from:[gx,gy,0] to:[gx,gy,H]`. Beams/rafters → `beam`/`rafter`, along the grid at z:H. Braces →
|
|
93
|
+
`brace`, the diagonal. Put the size in `meta.profile`, the mark in `meta.mark`, the grid ref in
|
|
94
|
+
`meta.grid`; approximate `section.w`/`d` from the profile (W12≈300×310, HSS6x6≈150×150, L≈90×90).
|
|
95
|
+
4. **Takeoff.** One row per distinct profile (count + total length in m). Assumptions in the note.
|
|
96
|
+
5. **meta.name** = the sheet title / drawing number. Never invent grids or members the drawing does
|
|
97
|
+
not show; if a sheet has no readable steel framing, bake an empty `elements` with a note saying so.
|
|
98
|
+
|
|
99
|
+
## Guardrails
|
|
100
|
+
|
|
101
|
+
- **Compose-time only.** The drawing is read HERE, by you, then baked. Never add a runtime node that
|
|
102
|
+
reads the drawing — `aware app validate` rejects it and it breaks determinism.
|
|
103
|
+
- **Scene, not logic.** Re-bake only the `view.scene` block. A different output (new columns, a
|
|
104
|
+
schedule) is a workflow change — use `floless-app-workflows`, not this.
|
|
105
|
+
- **Schematic + honest.** ~90% is the goal; the user tidies the last 10% in 3D. State your
|
|
106
|
+
assumptions (heights, assumed bays) in the takeoff note. Don't fabricate sizes the drawing lacks.
|
|
107
|
+
- **Thin-UI contract.** The browser only recorded intent + the drawing; the brain is you, in the
|
|
108
|
+
terminal. The UI never reads the drawing and never bakes.
|
|
109
|
+
- **Resolve the port** from the running floless.app the same way the other floless-app skills do.
|
package/dist/web/app.css
CHANGED
|
@@ -1125,9 +1125,17 @@
|
|
|
1125
1125
|
overflow-y: auto;
|
|
1126
1126
|
display: grid;
|
|
1127
1127
|
grid-template-columns: 1fr 1fr;
|
|
1128
|
+
/* align-items:start so a collapsed card isn't stretched to match an
|
|
1129
|
+
expanded sibling in the same row (the accordion-in-grid pattern). */
|
|
1130
|
+
align-items: start;
|
|
1128
1131
|
gap: 6px;
|
|
1129
1132
|
padding-right: 4px;
|
|
1130
1133
|
}
|
|
1134
|
+
/* One grid cell per agent: holds the card header + its command list, so the
|
|
1135
|
+
commands nest directly under the picked card (see renderLibrary). */
|
|
1136
|
+
.lib-card { min-width: 0; }
|
|
1137
|
+
.lib-card.expanded .lib-item { border-color: var(--accent-dim); }
|
|
1138
|
+
.lib-card.expanded .lib-fav { transform: rotate(180deg); }
|
|
1131
1139
|
.lib-item {
|
|
1132
1140
|
background: var(--surface-2);
|
|
1133
1141
|
border: 1px solid var(--border-strong);
|
|
@@ -1162,6 +1170,7 @@
|
|
|
1162
1170
|
border-radius: 3px;
|
|
1163
1171
|
flex-shrink: 0;
|
|
1164
1172
|
line-height: 1;
|
|
1173
|
+
transition: transform 0.15s ease, color 0.15s, border-color 0.15s;
|
|
1165
1174
|
}
|
|
1166
1175
|
.lib-item .lib-fav.faved { color: var(--star); border-color: var(--star); }
|
|
1167
1176
|
|
|
@@ -2476,6 +2485,7 @@ body {
|
|
|
2476
2485
|
.req-thumbs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
|
|
2477
2486
|
.req-thumb { width: 46px; height: 46px; padding: 0; border: 1px solid var(--border-strong); border-radius: 4px; background: var(--bg); overflow: hidden; cursor: pointer; flex-shrink: 0; transition: border-color 0.15s; }
|
|
2478
2487
|
.req-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
2488
|
+
.req-thumb-pdf { display: grid; place-items: center; width: 100%; height: 100%; font-size: 11px; font-weight: 700; letter-spacing: 0.04em; color: var(--text-dim); }
|
|
2479
2489
|
.req-thumb:hover { border-color: var(--accent-dim); }
|
|
2480
2490
|
|
|
2481
2491
|
/* ============================================================================
|
package/dist/web/aware.js
CHANGED
|
@@ -980,8 +980,8 @@
|
|
|
980
980
|
const name = app.rebakeInput;
|
|
981
981
|
const res = await formModal({
|
|
982
982
|
title: `Re-read & re-bake · ${app.displayName}`,
|
|
983
|
-
sub: '
|
|
984
|
-
fields: [{ name, label: `${name} — new source drawing`, type: 'file', accept: ['png', 'jpg', 'webp'] }],
|
|
983
|
+
sub: 'Attach the new drawing (PDF or image). Your terminal AI re-reads it and updates this workflow, then asks you to approve — nothing changes until you do.',
|
|
984
|
+
fields: [{ name, label: `${name} — new source drawing`, type: 'file', accept: ['pdf', 'png', 'jpg', 'webp'] }],
|
|
985
985
|
okLabel: 'Queue re-bake',
|
|
986
986
|
});
|
|
987
987
|
if (!res) return;
|
|
@@ -995,8 +995,8 @@
|
|
|
995
995
|
});
|
|
996
996
|
const out = await r.json().catch(() => ({ ok: false, error: `re-bake failed (${r.status})` }));
|
|
997
997
|
if (!out || !out.ok) { showToast((out && out.error) || 'could not queue re-bake', 'err'); return; }
|
|
998
|
-
showToast('Queued — your terminal AI will re-read
|
|
999
|
-
appendNarration(`<strong>Re-bake queued</strong> for <strong>${escapeHtml(name)}</strong>. Your terminal AI will re-read the drawing,
|
|
998
|
+
showToast('Queued — your terminal AI will re-read it and update this workflow, then ask you to approve.', 'ok');
|
|
999
|
+
appendNarration(`<strong>Re-bake queued</strong> for <strong>${escapeHtml(name)}</strong>. Your terminal AI will re-read the drawing, update this workflow, and ask you to approve the new lock.`);
|
|
1000
1000
|
} catch { showToast('could not queue re-bake', 'err'); }
|
|
1001
1001
|
}
|
|
1002
1002
|
|
|
@@ -1654,6 +1654,8 @@
|
|
|
1654
1654
|
$body.onpaste = (e) => {
|
|
1655
1655
|
const items = Array.from((e.clipboardData && e.clipboardData.items) || []);
|
|
1656
1656
|
const imgs = items.filter((it) => it.kind === 'file' && it.type.startsWith('image/'));
|
|
1657
|
+
// The single-file field also accepts PDFs (steel drawings) — paste, not just click.
|
|
1658
|
+
const fileItems = items.filter((it) => it.kind === 'file' && (it.type.startsWith('image/') || it.type === 'application/pdf'));
|
|
1657
1659
|
const box = $body.querySelector('.fm-images');
|
|
1658
1660
|
if (box) {
|
|
1659
1661
|
if (!imgs.length) return;
|
|
@@ -1673,10 +1675,10 @@
|
|
|
1673
1675
|
return;
|
|
1674
1676
|
}
|
|
1675
1677
|
const fileBox = $body.querySelector('.fm-file');
|
|
1676
|
-
if (!fileBox || !
|
|
1678
|
+
if (!fileBox || !fileItems.length) return;
|
|
1677
1679
|
e.preventDefault();
|
|
1678
1680
|
const entry = fileStates.get(fileBox.dataset.fmFileBox);
|
|
1679
|
-
const file =
|
|
1681
|
+
const file = fileItems[0].getAsFile();
|
|
1680
1682
|
if (entry && file) entry.setFile(file);
|
|
1681
1683
|
};
|
|
1682
1684
|
const collect = () => {
|
|
@@ -3622,7 +3624,7 @@
|
|
|
3622
3624
|
<div class="req-info">
|
|
3623
3625
|
<div class="req-head"><span class="${badgeCls}">${label}</span> ${r.appId ? `<code>${escapeHtml(r.appId)}</code>` : ''}${target}${time}</div>
|
|
3624
3626
|
<div class="req-instruction">${escapeHtml(instructionFor(r))}</div>
|
|
3625
|
-
${r.snapshots && r.snapshots.length ? `<div class="req-thumbs">${r.snapshots.map((
|
|
3627
|
+
${r.snapshots && r.snapshots.length ? `<div class="req-thumbs">${r.snapshots.map((sp, i) => { const pdf = /\.pdf$/i.test(String(sp)); return `<button type="button" class="req-thumb" data-id="${escapeAttr(r.id)}" data-n="${i}" aria-label="View ${pdf ? 'PDF' : 'screenshot'} ${i + 1}">${pdf ? '<span class="req-thumb-pdf">PDF</span>' : `<img src="/api/requests/${encodeURIComponent(r.id)}/snapshot/${i}" alt="">`}</button>`; }).join('')}</div>` : ''}
|
|
3626
3628
|
</div>
|
|
3627
3629
|
<div class="req-actions">
|
|
3628
3630
|
<button class="req-copy" data-id="${escapeAttr(r.id)}" data-tip="Copy this instruction">⧉</button>
|
|
@@ -3679,15 +3681,20 @@
|
|
|
3679
3681
|
renderLibrary = function renderLibraryReal() {
|
|
3680
3682
|
const q = ($libSearch.value || '').toLowerCase().trim();
|
|
3681
3683
|
const items = agentCatalog.filter((a) => !q || a.id.toLowerCase().includes(q) || (a.kind || '').toLowerCase().includes(q));
|
|
3684
|
+
// Each agent is ONE grid cell: the card header + its (lazy) command list live
|
|
3685
|
+
// in the same `.lib-card`, so expanding nests the commands directly under the
|
|
3686
|
+
// picked card instead of scattering them into a neighbouring grid slot.
|
|
3682
3687
|
$libList.innerHTML = items.map((a) => `
|
|
3683
|
-
<div class="lib-
|
|
3684
|
-
<div class="
|
|
3685
|
-
<div class="
|
|
3686
|
-
|
|
3688
|
+
<div class="lib-card" data-agent="${escapeAttr(a.id)}">
|
|
3689
|
+
<div class="lib-item">
|
|
3690
|
+
<div class="info">
|
|
3691
|
+
<div class="name">${escapeHtml(a.id)} <span style="color:var(--text-dim);font-weight:400">v${escapeHtml(String(a.version))}</span></div>
|
|
3692
|
+
<div class="meta">${escapeHtml(a.kind || 'agent')} · ${a.commands} command${a.commands === 1 ? '' : 's'} · ${a.skills} skill${a.skills === 1 ? '' : 's'}</div>
|
|
3693
|
+
</div>
|
|
3694
|
+
<button class="lib-fav" data-expand="${escapeAttr(a.id)}" data-tip="Show commands">⌄</button>
|
|
3687
3695
|
</div>
|
|
3688
|
-
<
|
|
3696
|
+
<div class="lib-commands" id="cmds-${escapeAttr(a.id)}" hidden></div>
|
|
3689
3697
|
</div>
|
|
3690
|
-
<div class="lib-commands" id="cmds-${escapeAttr(a.id)}" hidden></div>
|
|
3691
3698
|
`).join('') || '<div class="empty-state">No agents match.</div>';
|
|
3692
3699
|
$libList.querySelectorAll('[data-expand]').forEach((btn) => {
|
|
3693
3700
|
btn.onclick = () => expandAgent(btn.dataset.expand);
|
|
@@ -3697,8 +3704,10 @@
|
|
|
3697
3704
|
async function expandAgent(id) {
|
|
3698
3705
|
const box = document.getElementById(`cmds-${id}`);
|
|
3699
3706
|
if (!box) return;
|
|
3700
|
-
|
|
3707
|
+
const card = box.closest('.lib-card');
|
|
3708
|
+
if (!box.hidden) { box.hidden = true; if (card) card.classList.remove('expanded'); return; }
|
|
3701
3709
|
box.hidden = false;
|
|
3710
|
+
if (card) card.classList.add('expanded');
|
|
3702
3711
|
box.innerHTML = '<div class="cmd-desc">loading…</div>';
|
|
3703
3712
|
try {
|
|
3704
3713
|
const { agent } = await api(`/api/agent/${encodeURIComponent(id)}`);
|