@floless/app 0.20.0 → 0.22.0
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 +69 -96
- package/dist/skills/floless-app-steel-from-drawings/SKILL.md +109 -0
- package/dist/web/app.css +1 -0
- package/dist/web/aware.js +19 -11
- package/package.json +1 -1
package/dist/floless-server.cjs
CHANGED
|
@@ -50958,6 +50958,17 @@ function appProvider(id) {
|
|
|
50958
50958
|
return "other";
|
|
50959
50959
|
}
|
|
50960
50960
|
}
|
|
50961
|
+
function appBaked(id) {
|
|
50962
|
+
try {
|
|
50963
|
+
const dir = appDir(id);
|
|
50964
|
+
const sourcePath = firstWithExt(dir, ".app") ?? firstWithExt(dir, ".flo");
|
|
50965
|
+
if (!sourcePath) return false;
|
|
50966
|
+
const src = asRecord((0, import_yaml.parse)((0, import_node_fs3.readFileSync)(sourcePath, "utf8")));
|
|
50967
|
+
return src["exposes-as-agent"] === true;
|
|
50968
|
+
} catch {
|
|
50969
|
+
return false;
|
|
50970
|
+
}
|
|
50971
|
+
}
|
|
50961
50972
|
function readApp(id) {
|
|
50962
50973
|
const dir = appDir(id);
|
|
50963
50974
|
const sourcePath = firstWithExt(dir, ".app") ?? firstWithExt(dir, ".flo");
|
|
@@ -51933,6 +51944,41 @@ var aware = {
|
|
|
51933
51944
|
if (code !== 0) throw new AwareError(`aware app uninstall failed (exit ${code})`, { stdout, stderr });
|
|
51934
51945
|
return { output: stdout.trim() };
|
|
51935
51946
|
},
|
|
51947
|
+
/**
|
|
51948
|
+
* Rename an installed app in place (`aware app rename <old> <new>`, CLI ≥ 0.68).
|
|
51949
|
+
* AWARE owns the whole move — the app dir, the source `app:` field, the
|
|
51950
|
+
* regenerated lock (so the Run gate stays green, no drift), and the synthesized
|
|
51951
|
+
* agent of a baked (`exposes-as-agent`) app. `compiled` reflects whether AWARE
|
|
51952
|
+
* refreshed the lock (false → the app needs a manual Compile). Throws
|
|
51953
|
+
* AwareUnsupportedError on a pre-0.68 CLI so app-lifecycle can say "update AWARE"
|
|
51954
|
+
* instead of treating an old CLI as a hard fault.
|
|
51955
|
+
*/
|
|
51956
|
+
async rename(oldId, newId) {
|
|
51957
|
+
const { code, stdout, stderr } = await runRaw(["app", "rename", assertId(oldId), assertId(newId)]);
|
|
51958
|
+
if (code !== 0) {
|
|
51959
|
+
if (isInvokeUnsupported({ stdout, stderr })) {
|
|
51960
|
+
throw new AwareUnsupportedError("aware CLI does not support `app rename` (needs \u2265 0.68) \u2014 update AWARE", { stdout, stderr });
|
|
51961
|
+
}
|
|
51962
|
+
throw new AwareError(`aware app rename ${oldId} \u2192 ${newId} failed (exit ${code})`, { stdout, stderr, code });
|
|
51963
|
+
}
|
|
51964
|
+
return { compiled: /lock refreshed/i.test(stdout), output: stdout.trim() };
|
|
51965
|
+
},
|
|
51966
|
+
/**
|
|
51967
|
+
* Duplicate an installed app into an independent copy (`aware app duplicate
|
|
51968
|
+
* <src> <new>`, CLI ≥ 0.68). The original is untouched; a baked copy gets its
|
|
51969
|
+
* own synthesized agent. Same `compiled` + unsupported-degradation contract as
|
|
51970
|
+
* rename.
|
|
51971
|
+
*/
|
|
51972
|
+
async duplicate(srcId, newId) {
|
|
51973
|
+
const { code, stdout, stderr } = await runRaw(["app", "duplicate", assertId(srcId), assertId(newId)]);
|
|
51974
|
+
if (code !== 0) {
|
|
51975
|
+
if (isInvokeUnsupported({ stdout, stderr })) {
|
|
51976
|
+
throw new AwareUnsupportedError("aware CLI does not support `app duplicate` (needs \u2265 0.68) \u2014 update AWARE", { stdout, stderr });
|
|
51977
|
+
}
|
|
51978
|
+
throw new AwareError(`aware app duplicate ${srcId} \u2192 ${newId} failed (exit ${code})`, { stdout, stderr, code });
|
|
51979
|
+
}
|
|
51980
|
+
return { compiled: /lock refreshed/i.test(stdout), output: stdout.trim() };
|
|
51981
|
+
},
|
|
51936
51982
|
/**
|
|
51937
51983
|
* Install a first-party agent by registry id (`aware agent install <id>`). The
|
|
51938
51984
|
* registry install pulls a large tarball (~200s observed) — far over runRaw's 60s
|
|
@@ -52683,7 +52729,7 @@ function appVersion() {
|
|
|
52683
52729
|
return resolveVersion({
|
|
52684
52730
|
isSea: isSea2(),
|
|
52685
52731
|
sqVersionXml: readSqVersionXml(),
|
|
52686
|
-
define: true ? "0.
|
|
52732
|
+
define: true ? "0.22.0" : void 0,
|
|
52687
52733
|
pkgVersion: readPkgVersion()
|
|
52688
52734
|
});
|
|
52689
52735
|
}
|
|
@@ -52693,7 +52739,7 @@ function resolveChannel(s) {
|
|
|
52693
52739
|
return "dev";
|
|
52694
52740
|
}
|
|
52695
52741
|
function appChannel() {
|
|
52696
|
-
return resolveChannel({ isSea: isSea2(), define: true ? "0.
|
|
52742
|
+
return resolveChannel({ isSea: isSea2(), define: true ? "0.22.0" : void 0 });
|
|
52697
52743
|
}
|
|
52698
52744
|
|
|
52699
52745
|
// oauth-presets.ts
|
|
@@ -53202,11 +53248,12 @@ var flolessRoot = ROOT2;
|
|
|
53202
53248
|
var SnapshotError = class extends Error {
|
|
53203
53249
|
};
|
|
53204
53250
|
var MAX_SNAPSHOTS = 8;
|
|
53205
|
-
var MAX_BYTES2 =
|
|
53251
|
+
var MAX_BYTES2 = 25 * 1024 * 1024;
|
|
53206
53252
|
function sniffExt(buf) {
|
|
53207
53253
|
if (buf.length >= 8 && buf[0] === 137 && buf[1] === 80 && buf[2] === 78 && buf[3] === 71) return "png";
|
|
53208
53254
|
if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) return "jpg";
|
|
53209
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";
|
|
53210
53257
|
return null;
|
|
53211
53258
|
}
|
|
53212
53259
|
function decodeSnapshots(inputs) {
|
|
@@ -53215,11 +53262,12 @@ function decodeSnapshots(inputs) {
|
|
|
53215
53262
|
return inputs.map((s) => {
|
|
53216
53263
|
const m = /^data:([\w/+.-]+);base64,(.*)$/s.exec(s.dataUrl || "");
|
|
53217
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)");
|
|
53218
53266
|
const buf = Buffer.from(m[2] ?? "", "base64");
|
|
53219
53267
|
if (buf.length === 0) throw new SnapshotError("empty snapshot");
|
|
53220
|
-
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)");
|
|
53221
53269
|
const ext = sniffExt(buf);
|
|
53222
|
-
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");
|
|
53223
53271
|
return { buf, ext };
|
|
53224
53272
|
});
|
|
53225
53273
|
}
|
|
@@ -54104,49 +54152,15 @@ function appDirPath(id) {
|
|
|
54104
54152
|
function appInstalled(id) {
|
|
54105
54153
|
return APP_ID3.test(id) && (0, import_node_fs16.existsSync)(appDirPath(id));
|
|
54106
54154
|
}
|
|
54107
|
-
function findSource(dir) {
|
|
54108
|
-
const files = (0, import_node_fs16.readdirSync)(dir);
|
|
54109
|
-
const hit = files.find((f) => f.toLowerCase().endsWith(".app")) ?? files.find((f) => f.toLowerCase().endsWith(".flo"));
|
|
54110
|
-
return hit ? (0, import_node_path14.join)(dir, hit) : null;
|
|
54111
|
-
}
|
|
54112
|
-
function rewriteAppId(text, newId) {
|
|
54113
|
-
const re = /^(app:[ \t]*)(["']?)([^"'#\r\n]+?)\2([ \t]*(?:#[^\r\n]*)?)$/m;
|
|
54114
|
-
if (!re.test(text)) throw new AppLifecycleError("source has no top-level app: field", 422);
|
|
54115
|
-
return text.replace(re, (_m, pre, quote, _id, trail) => `${pre}${quote}${newId}${quote}${trail}`);
|
|
54116
|
-
}
|
|
54117
|
-
function isExposedAsAgent(dir) {
|
|
54118
|
-
const src = findSource(dir);
|
|
54119
|
-
return src ? /^exposes-as-agent:[ \t]*true\b/m.test((0, import_node_fs16.readFileSync)(src, "utf8")) : false;
|
|
54120
|
-
}
|
|
54121
54155
|
function logCascade(what, e) {
|
|
54122
54156
|
console.error(`[app-lifecycle] cascade step failed (${what}):`, e instanceof Error ? e.message : e);
|
|
54123
54157
|
}
|
|
54124
|
-
function
|
|
54125
|
-
|
|
54126
|
-
|
|
54127
|
-
|
|
54128
|
-
|
|
54129
|
-
|
|
54130
|
-
} catch {
|
|
54131
|
-
}
|
|
54132
|
-
}
|
|
54133
|
-
}
|
|
54134
|
-
}
|
|
54135
|
-
function restampSource(dir, newId) {
|
|
54136
|
-
const src = findSource(dir);
|
|
54137
|
-
if (!src) throw new AppLifecycleError("app has no source file", 422);
|
|
54138
|
-
const text = rewriteAppId((0, import_node_fs16.readFileSync)(src, "utf8"), newId);
|
|
54139
|
-
const ext = src.toLowerCase().endsWith(".flo") ? ".flo" : ".app";
|
|
54140
|
-
const newSrc = (0, import_node_path14.join)(dir, `${newId}${ext}`);
|
|
54141
|
-
(0, import_node_fs16.writeFileSync)(newSrc, text);
|
|
54142
|
-
if (src !== newSrc) {
|
|
54143
|
-
try {
|
|
54144
|
-
(0, import_node_fs16.unlinkSync)(src);
|
|
54145
|
-
} catch {
|
|
54146
|
-
}
|
|
54147
|
-
}
|
|
54148
|
-
removeLocks(dir);
|
|
54149
|
-
return newSrc;
|
|
54158
|
+
function mapAwareError(e) {
|
|
54159
|
+
const code = e instanceof AwareError ? e.detail?.code : void 0;
|
|
54160
|
+
if (code === 8) throw new AppLifecycleError("a workflow with that name already exists", 409);
|
|
54161
|
+
if (code === 7) throw new AppLifecycleError("workflow not found", 404);
|
|
54162
|
+
if (code === 3) throw new AppLifecycleError(e instanceof Error ? e.message : "invalid name", 400);
|
|
54163
|
+
throw e;
|
|
54150
54164
|
}
|
|
54151
54165
|
function assertNewId(newId) {
|
|
54152
54166
|
if (!newId || !APP_ID3.test(newId) || !safeSegment(newId)) {
|
|
@@ -54154,39 +54168,16 @@ function assertNewId(newId) {
|
|
|
54154
54168
|
}
|
|
54155
54169
|
}
|
|
54156
54170
|
var defaultDeps2 = {
|
|
54157
|
-
|
|
54171
|
+
rename: (oldId, newId) => aware.rename(oldId, newId),
|
|
54172
|
+
duplicate: (srcId, newId) => aware.duplicate(srcId, newId),
|
|
54158
54173
|
uninstall: (id) => aware.uninstall(id)
|
|
54159
54174
|
};
|
|
54160
|
-
async function tryCompile(deps, sourcePath) {
|
|
54161
|
-
try {
|
|
54162
|
-
await deps.compile(sourcePath);
|
|
54163
|
-
return true;
|
|
54164
|
-
} catch {
|
|
54165
|
-
return false;
|
|
54166
|
-
}
|
|
54167
|
-
}
|
|
54168
54175
|
async function renameApp(oldId, newId, deps = defaultDeps2) {
|
|
54169
54176
|
if (!appInstalled(oldId)) throw new AppLifecycleError(`workflow not found: ${oldId}`, 404);
|
|
54170
54177
|
assertNewId(newId);
|
|
54171
54178
|
if (newId === oldId) throw new AppLifecycleError("that is already its name", 400);
|
|
54172
54179
|
if (appInstalled(newId)) throw new AppLifecycleError(`a workflow named "${newId}" already exists`, 409);
|
|
54173
|
-
|
|
54174
|
-
throw new AppLifecycleError("this workflow is baked into an agent \u2014 rename isn\u2019t supported yet (the agent wouldn\u2019t follow). Un-bake it first, or rename it from the terminal.", 409);
|
|
54175
|
-
}
|
|
54176
|
-
const oldDir = appDirPath(oldId);
|
|
54177
|
-
const newDir = appDirPath(newId);
|
|
54178
|
-
(0, import_node_fs16.renameSync)(oldDir, newDir);
|
|
54179
|
-
let newSrc;
|
|
54180
|
-
try {
|
|
54181
|
-
newSrc = restampSource(newDir, newId);
|
|
54182
|
-
} catch (e) {
|
|
54183
|
-
try {
|
|
54184
|
-
(0, import_node_fs16.renameSync)(newDir, oldDir);
|
|
54185
|
-
} catch {
|
|
54186
|
-
}
|
|
54187
|
-
throw e;
|
|
54188
|
-
}
|
|
54189
|
-
const compiled = await tryCompile(deps, newSrc);
|
|
54180
|
+
const { compiled } = await deps.rename(oldId, newId).catch(mapAwareError);
|
|
54190
54181
|
try {
|
|
54191
54182
|
renameVisualInputs(oldId, newId);
|
|
54192
54183
|
} catch (e) {
|
|
@@ -54203,27 +54194,7 @@ async function duplicateApp(srcId, newId, deps = defaultDeps2) {
|
|
|
54203
54194
|
if (!appInstalled(srcId)) throw new AppLifecycleError(`workflow not found: ${srcId}`, 404);
|
|
54204
54195
|
assertNewId(newId);
|
|
54205
54196
|
if (appInstalled(newId)) throw new AppLifecycleError(`a workflow named "${newId}" already exists`, 409);
|
|
54206
|
-
|
|
54207
|
-
throw new AppLifecycleError("this workflow is baked into an agent \u2014 duplicate isn\u2019t supported yet (the copy\u2019s agent wouldn\u2019t be installed). Un-bake it first, or copy it from the terminal.", 409);
|
|
54208
|
-
}
|
|
54209
|
-
const newDir = appDirPath(newId);
|
|
54210
|
-
try {
|
|
54211
|
-
(0, import_node_fs16.mkdirSync)(newDir);
|
|
54212
|
-
} catch {
|
|
54213
|
-
throw new AppLifecycleError(`a workflow named "${newId}" already exists`, 409);
|
|
54214
|
-
}
|
|
54215
|
-
let newSrc;
|
|
54216
|
-
try {
|
|
54217
|
-
(0, import_node_fs16.cpSync)(appDirPath(srcId), newDir, { recursive: true });
|
|
54218
|
-
newSrc = restampSource(newDir, newId);
|
|
54219
|
-
} catch (e) {
|
|
54220
|
-
try {
|
|
54221
|
-
(0, import_node_fs16.rmSync)(newDir, { recursive: true, force: true });
|
|
54222
|
-
} catch {
|
|
54223
|
-
}
|
|
54224
|
-
throw e;
|
|
54225
|
-
}
|
|
54226
|
-
const compiled = await tryCompile(deps, newSrc);
|
|
54197
|
+
const { compiled } = await deps.duplicate(srcId, newId).catch(mapAwareError);
|
|
54227
54198
|
try {
|
|
54228
54199
|
copyVisualInputs(srcId, newId);
|
|
54229
54200
|
} catch (e) {
|
|
@@ -54233,7 +54204,7 @@ async function duplicateApp(srcId, newId, deps = defaultDeps2) {
|
|
|
54233
54204
|
}
|
|
54234
54205
|
async function deleteApp(id, deps = defaultDeps2) {
|
|
54235
54206
|
if (!appInstalled(id)) throw new AppLifecycleError(`workflow not found: ${id}`, 404);
|
|
54236
|
-
await deps.uninstall(id);
|
|
54207
|
+
await deps.uninstall(id).catch(mapAwareError);
|
|
54237
54208
|
try {
|
|
54238
54209
|
deleteVisualInputs(id);
|
|
54239
54210
|
} catch (e) {
|
|
@@ -55328,6 +55299,8 @@ var PRODUCT_SKILLS = [
|
|
|
55328
55299
|
// file a diagnosed bug/idea/question to the private log via the floless.io relay
|
|
55329
55300
|
"floless-app-routines",
|
|
55330
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)
|
|
55331
55304
|
"floless-app-ui",
|
|
55332
55305
|
// compose Custom Panels (~/.floless/ui/extensions.json) for the Dashboard
|
|
55333
55306
|
"floless-app-workflows"
|
|
@@ -57273,7 +57246,7 @@ async function startServer() {
|
|
|
57273
57246
|
}
|
|
57274
57247
|
});
|
|
57275
57248
|
await app.register(import_static.default, { root: WEB_ROOT, prefix: "/" });
|
|
57276
|
-
const MIN_AWARE = "0.
|
|
57249
|
+
const MIN_AWARE = "0.69.0";
|
|
57277
57250
|
const isWin3 = process.platform === "win32";
|
|
57278
57251
|
const has = (cmd) => {
|
|
57279
57252
|
try {
|
|
@@ -57499,7 +57472,7 @@ async function startServer() {
|
|
|
57499
57472
|
onSeatLost((reason) => broadcast({ type: "seat-taken", reason }));
|
|
57500
57473
|
app.get("/api/apps", async () => {
|
|
57501
57474
|
const apps = await aware.list();
|
|
57502
|
-
return { ok: true, apps: apps.map((a) => ({ ...a, provider: appProvider(a.id) })) };
|
|
57475
|
+
return { ok: true, apps: apps.map((a) => ({ ...a, provider: appProvider(a.id), baked: appBaked(a.id) })) };
|
|
57503
57476
|
});
|
|
57504
57477
|
app.get("/api/agents", async () => {
|
|
57505
57478
|
const agents = await aware.agentList();
|
|
@@ -57985,7 +57958,7 @@ async function startServer() {
|
|
|
57985
57958
|
const p = Number.isInteger(n) ? snapshotPathFor(req.params.id, n) : null;
|
|
57986
57959
|
if (!p || !(0, import_node_fs23.existsSync)(p)) return reply.status(404).send({ ok: false, error: "snapshot not found" });
|
|
57987
57960
|
const ext = p.split(".").pop().toLowerCase();
|
|
57988
|
-
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");
|
|
57989
57962
|
reply.header("Cache-Control", "no-store");
|
|
57990
57963
|
return (0, import_node_fs23.readFileSync)(p);
|
|
57991
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
|
@@ -2476,6 +2476,7 @@ body {
|
|
|
2476
2476
|
.req-thumbs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
|
|
2477
2477
|
.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
2478
|
.req-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
2479
|
+
.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
2480
|
.req-thumb:hover { border-color: var(--accent-dim); }
|
|
2480
2481
|
|
|
2481
2482
|
/* ============================================================================
|
package/dist/web/aware.js
CHANGED
|
@@ -583,7 +583,7 @@
|
|
|
583
583
|
// <button>s without nesting interactive elements (#85). Selection stays driven by
|
|
584
584
|
// the delegated $wfList click + comboMove highlight (aria-activedescendant).
|
|
585
585
|
const aid = escapeAttr(a.id);
|
|
586
|
-
html += `<div class="wf-option" role="option" id="wf-opt-${aid}" data-id="${aid}" data-search="${escapeAttr((a.id + ' ' + p).toLowerCase())}" tabindex="-1" aria-selected="false">`
|
|
586
|
+
html += `<div class="wf-option" role="option" id="wf-opt-${aid}" data-id="${aid}" data-baked="${a.baked ? '1' : ''}" data-search="${escapeAttr((a.id + ' ' + p).toLowerCase())}" tabindex="-1" aria-selected="false">`
|
|
587
587
|
+ `<span class="wf-option-name">${escapeHtml(a.id)}</span><span class="wf-option-meta">${meta}</span>`
|
|
588
588
|
+ `<span class="wf-option-actions">`
|
|
589
589
|
+ `<button type="button" class="wf-act act-edit" data-wf-act="rename" data-id="${aid}" tabindex="-1" data-tip="Rename" aria-label="Rename ${aid}">✎</button>`
|
|
@@ -709,7 +709,7 @@
|
|
|
709
709
|
const act = e.target.closest('[data-wf-act]');
|
|
710
710
|
if (act) {
|
|
711
711
|
const id = act.dataset.id;
|
|
712
|
-
if (act.dataset.wfAct === 'rename') openRenameWorkflow(id);
|
|
712
|
+
if (act.dataset.wfAct === 'rename') openRenameWorkflow(id, act.closest('.wf-option')?.dataset.baked === '1');
|
|
713
713
|
else if (act.dataset.wfAct === 'duplicate') openDuplicateWorkflow(id);
|
|
714
714
|
else if (act.dataset.wfAct === 'delete') confirmDeleteWorkflow(id);
|
|
715
715
|
return;
|
|
@@ -738,11 +738,17 @@
|
|
|
738
738
|
appInputValues.delete(id); dirtyBaseline.delete(id); apps.delete(id);
|
|
739
739
|
}
|
|
740
740
|
|
|
741
|
-
async function openRenameWorkflow(oldId) {
|
|
741
|
+
async function openRenameWorkflow(oldId, baked = false) {
|
|
742
742
|
closeCombo(false);
|
|
743
|
+
// A baked workflow is a reusable agent; renaming it also renames that agent, so any
|
|
744
|
+
// OTHER workflow that uses it as a node will reference the old name and need updating.
|
|
745
|
+
// Warn (informed consent) — not a blocker; the user may be renaming an unreferenced
|
|
746
|
+
// one (#226 UX review).
|
|
747
|
+
const sub = 'This is the name you pick here, and what .flo files and your terminal AI use to refer to it.'
|
|
748
|
+
+ (baked ? ' Renaming a baked workflow also renames its synthesized agent — other workflows that use it as a node will need updating.' : '');
|
|
743
749
|
const res = await formModal({
|
|
744
750
|
title: 'Rename workflow',
|
|
745
|
-
sub
|
|
751
|
+
sub,
|
|
746
752
|
fields: [{ name: 'name', label: 'New name', type: 'text', value: oldId, placeholder: 'e.g. tekla-bom-by-phase' }],
|
|
747
753
|
okLabel: 'Rename',
|
|
748
754
|
});
|
|
@@ -974,8 +980,8 @@
|
|
|
974
980
|
const name = app.rebakeInput;
|
|
975
981
|
const res = await formModal({
|
|
976
982
|
title: `Re-read & re-bake · ${app.displayName}`,
|
|
977
|
-
sub: '
|
|
978
|
-
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'] }],
|
|
979
985
|
okLabel: 'Queue re-bake',
|
|
980
986
|
});
|
|
981
987
|
if (!res) return;
|
|
@@ -989,8 +995,8 @@
|
|
|
989
995
|
});
|
|
990
996
|
const out = await r.json().catch(() => ({ ok: false, error: `re-bake failed (${r.status})` }));
|
|
991
997
|
if (!out || !out.ok) { showToast((out && out.error) || 'could not queue re-bake', 'err'); return; }
|
|
992
|
-
showToast('Queued — your terminal AI will re-read
|
|
993
|
-
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.`);
|
|
994
1000
|
} catch { showToast('could not queue re-bake', 'err'); }
|
|
995
1001
|
}
|
|
996
1002
|
|
|
@@ -1648,6 +1654,8 @@
|
|
|
1648
1654
|
$body.onpaste = (e) => {
|
|
1649
1655
|
const items = Array.from((e.clipboardData && e.clipboardData.items) || []);
|
|
1650
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'));
|
|
1651
1659
|
const box = $body.querySelector('.fm-images');
|
|
1652
1660
|
if (box) {
|
|
1653
1661
|
if (!imgs.length) return;
|
|
@@ -1667,10 +1675,10 @@
|
|
|
1667
1675
|
return;
|
|
1668
1676
|
}
|
|
1669
1677
|
const fileBox = $body.querySelector('.fm-file');
|
|
1670
|
-
if (!fileBox || !
|
|
1678
|
+
if (!fileBox || !fileItems.length) return;
|
|
1671
1679
|
e.preventDefault();
|
|
1672
1680
|
const entry = fileStates.get(fileBox.dataset.fmFileBox);
|
|
1673
|
-
const file =
|
|
1681
|
+
const file = fileItems[0].getAsFile();
|
|
1674
1682
|
if (entry && file) entry.setFile(file);
|
|
1675
1683
|
};
|
|
1676
1684
|
const collect = () => {
|
|
@@ -3616,7 +3624,7 @@
|
|
|
3616
3624
|
<div class="req-info">
|
|
3617
3625
|
<div class="req-head"><span class="${badgeCls}">${label}</span> ${r.appId ? `<code>${escapeHtml(r.appId)}</code>` : ''}${target}${time}</div>
|
|
3618
3626
|
<div class="req-instruction">${escapeHtml(instructionFor(r))}</div>
|
|
3619
|
-
${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>` : ''}
|
|
3620
3628
|
</div>
|
|
3621
3629
|
<div class="req-actions">
|
|
3622
3630
|
<button class="req-copy" data-id="${escapeAttr(r.id)}" data-tip="Copy this instruction">⧉</button>
|