@floless/app 0.16.1 → 0.17.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 +417 -310
- package/dist/skills/floless-app-rebake/SKILL.md +72 -0
- package/dist/skills/floless-app-workflows/references/exec-contract.md +10 -1
- package/dist/skills/floless-app-workflows/references/visual-inputs.md +94 -0
- package/dist/web/app.css +45 -2
- package/dist/web/app.js +1 -0
- package/dist/web/aware.js +189 -36
- package/package.json +1 -1
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: floless-app-rebake
|
|
3
|
+
description: This skill should be used when the user swaps the source drawing of a BAKED floless.app workflow and wants it re-read — the "Re-read & re-bake" (B3) loop for Visual Inputs. Triggers on a pending floless `rebake` request (queued from the app's "Re-read & re-bake ▸" button), or asks like "re-bake screenshot-to-saved-settings with this new schedule", "I swapped the drawing, re-read it", "re-extract and re-bake the config". It teaches the host AI to read the new image, re-extract its values, rewrite ONLY the app's config.yaml baked literals, recompile, and ask the user to approve — all at COMPOSE time (AWARE forbids reading a drawing during a run).
|
|
4
|
+
metadata:
|
|
5
|
+
version: 0.1.0
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Re-read & re-bake a baked Visual Input (B3)
|
|
9
|
+
|
|
10
|
+
## What this is
|
|
11
|
+
|
|
12
|
+
Some floless.app workflows **bake** an image/PDF at compose time: the terminal AI reads a
|
|
13
|
+
drawing once and writes the extracted values into `config.yaml` as literals, so the run is
|
|
14
|
+
fully deterministic and reads no drawing (the **decalog-#9 / "bake"** strategy — AWARE's
|
|
15
|
+
`aware app validate` *rejects* a node that reads a drawing during a run). When the user wants
|
|
16
|
+
to process a **different** drawing, they can't just "swap and run" — the literals must be
|
|
17
|
+
re-extracted and re-baked. That re-extraction is an LLM act, so it happens **here, at compose
|
|
18
|
+
time**, then the user approves the new lock. This is strategy **B3** of the Visual Inputs design
|
|
19
|
+
(`docs/superpowers/specs/2026-06-12-visual-inputs-design.md`).
|
|
20
|
+
|
|
21
|
+
The floless.app UI surfaces a **"Re-read & re-bake ▸"** button on a baked-visual app's entry
|
|
22
|
+
node. Clicking it (after attaching a new drawing) records a **`rebake` request** that you pick
|
|
23
|
+
up here.
|
|
24
|
+
|
|
25
|
+
## The loop
|
|
26
|
+
|
|
27
|
+
1. **Find the request.** `GET http://localhost:<port>/api/requests` → find the entry with
|
|
28
|
+
`type: "rebake"`. It carries `appId`, the `inputName` (the baked input, e.g. `drawing`),
|
|
29
|
+
an `instruction`, and `snapshots: ["<abs path to the new image>"]`. (The user may also just
|
|
30
|
+
ask in prose with an image attached — same procedure, skip the request lookup.)
|
|
31
|
+
2. **Read the new drawing.** Open the image at `snapshots[0]` (a real PNG/JPEG/WebP on disk).
|
|
32
|
+
Read it with your own vision — this is the compose-time extraction.
|
|
33
|
+
3. **Re-extract the same schema the app already bakes.** Open the app source +
|
|
34
|
+
`config.yaml` under `~/.aware/apps/<appId>/` (and the editable copy under `demos/<appId>/`
|
|
35
|
+
if present). Identify the baked literals (e.g. `config.schedule-rows`). Re-extract **the same
|
|
36
|
+
shape** from the new drawing — same fields, same types — so the deterministic nodes keep
|
|
37
|
+
working. Do NOT invent values the drawing doesn't state; leave the app's documented
|
|
38
|
+
sentinel/default for anything absent (mirror how it was originally baked).
|
|
39
|
+
4. **Rewrite ONLY the baked config values.** Edit `~/.aware/apps/<appId>/config.yaml`
|
|
40
|
+
(and the `demos/<appId>/config.yaml` editable copy) — replace the baked literals with the
|
|
41
|
+
re-extracted ones. **Do not change node logic, the `.flo`, or the schema** — only the values.
|
|
42
|
+
5. **Recompile.** `aware app compile ~/.aware/apps/<appId>` → writes a fresh `<appId>.lock`.
|
|
43
|
+
The source-hash changes, so floless's Run gate disarms until approval. Copy the lock back to
|
|
44
|
+
`demos/<appId>/` if you keep an editable copy.
|
|
45
|
+
6. **Hand the approval to the user.** Tell them what changed (e.g. "re-baked 7 schedule rows
|
|
46
|
+
from the new drawing — review the diff in Inspect → Code, then Compile/Approve, then ▶ Run").
|
|
47
|
+
The human eyeballing the re-extraction before any run **is the safety gate** (it replaces the
|
|
48
|
+
`approve:` block AWARE's bake pattern recommends). Never auto-run.
|
|
49
|
+
7. **Clear the request.** `DELETE http://localhost:<port>/api/requests/<id>` once done.
|
|
50
|
+
|
|
51
|
+
## Guardrails
|
|
52
|
+
|
|
53
|
+
- **Compose-time only.** Never add a runtime node that reads the drawing — `aware app validate`
|
|
54
|
+
rejects it, and it would break determinism. The whole point of B3 is to keep the run
|
|
55
|
+
deterministic by re-baking up front.
|
|
56
|
+
- **Values, not logic.** Re-bake only `config.yaml` literals. If the new drawing needs a
|
|
57
|
+
*different shape* (new columns, a different schema), that's a workflow change, not a re-bake —
|
|
58
|
+
tell the user and use `floless-app-workflows` instead.
|
|
59
|
+
- **Thin-UI contract holds.** The browser only recorded intent + the image; the brain is you,
|
|
60
|
+
in the terminal. The UI never re-bakes and never reads the drawing.
|
|
61
|
+
- **Resolve the port** from the running floless.app (the `/api` base the user's workspace is on)
|
|
62
|
+
the same way the other floless-app skills do.
|
|
63
|
+
|
|
64
|
+
## Relationship to the other strategies
|
|
65
|
+
|
|
66
|
+
- **A (bake)** is the first-time version of this (read at compose, bake). B3 is "do it again for a
|
|
67
|
+
new drawing." Same act, re-triggered by a swap.
|
|
68
|
+
- **B1 (parse)** is for deterministically-parseable inputs that DON'T need re-baking — there the
|
|
69
|
+
image is a true runtime input read by an `exec` node; the user swaps and runs with no re-bake.
|
|
70
|
+
- **B2 (vision)** — a curated runtime `vision.extract` agent — is the future zero-friction
|
|
71
|
+
replacement for B3 once AWARE ships it (RFC `aware-aeco/aware#223`). Until then, B3 is the
|
|
72
|
+
swap-and-(almost)-run answer for vision inputs.
|
|
@@ -44,7 +44,16 @@ floless extracts `data.result.html` for the HTML Viewer.
|
|
|
44
44
|
`System.IO`, `System.Diagnostics.Debugger`, and the Tekla Open API
|
|
45
45
|
(`Tekla.Structures.Model`, etc. — discovered/probed from the running install).
|
|
46
46
|
- **NOT referenced:** `System.Net` (so **no** `WebUtility.HtmlEncode` — escape manually),
|
|
47
|
-
`System.Diagnostics.Process
|
|
47
|
+
`System.Diagnostics.Process`, and **`System.Text.RegularExpressions`** (no `Regex` —
|
|
48
|
+
use `IndexOf`/`Substring` scans instead). Don't rely on them.
|
|
49
|
+
|
|
50
|
+
> **FOOTGUN — the bridge swallows the compiler diagnostic.** An exec script that fails
|
|
51
|
+
> to compile (e.g. references an unreferenced assembly like `Regex`) does NOT surface a
|
|
52
|
+
> `CS####` error — the run aborts with an opaque `agent tekla/exec failed (exit Some(2))`
|
|
53
|
+
> and an empty detail (verified on aware 0.65.0). It reads like a host-attach failure but
|
|
54
|
+
> is a compile error in your C#. Isolate by running a known-good Tekla-free exec
|
|
55
|
+
> (`dummy-report`): if that runs and yours exits 2, the fault is your script, not the
|
|
56
|
+
> bridge/host. (The opacity is an AWARE DX gap — candidate to file on aware-aeco.)
|
|
48
57
|
|
|
49
58
|
Manual HTML escape (since `System.Net` is unavailable):
|
|
50
59
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# Visual Inputs (image / PDF): one input, four read-strategies
|
|
2
|
+
|
|
3
|
+
A workflow takes an image/PDF via an input that declares **`widget: file`** (and
|
|
4
|
+
typically `type: image`). The user swaps it on the **input node** ("Set inputs ▸" →
|
|
5
|
+
a file picker); floless stores the bytes under `~/.floless/inputs/<app>/<sha>.<ext>`
|
|
6
|
+
(content-addressed, PDF-aware) and sets `{{ inputs.<name> }}` to that **path**. The
|
|
7
|
+
bytes never pass through a model in the UI — the web layer moves a file, AWARE (or
|
|
8
|
+
the terminal AI at compose) interprets it.
|
|
9
|
+
|
|
10
|
+
```yaml
|
|
11
|
+
inputs:
|
|
12
|
+
drawing:
|
|
13
|
+
type: image
|
|
14
|
+
widget: file # ← load-bearing: renders the picker, not a text box
|
|
15
|
+
accept: [pdf, png, jpg] # narrows the picker + the chip glyph
|
|
16
|
+
read-strategy: parse # parse | bake | rebake | vision (see below)
|
|
17
|
+
description: The framing PDF/image to take off (swappable per run).
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Pick the strategy from two questions — needs vision? swappable per run?
|
|
21
|
+
|
|
22
|
+
| | deterministic parser exists | needs vision |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| **fixed / rare** | parse-once, or **bake** (A) | **bake** (A) |
|
|
25
|
+
| **swap per run** | **parse** (B1) | **rebake** (B3) today · **vision** (B2) later |
|
|
26
|
+
|
|
27
|
+
- **parse (B1)** — a read-only `exec` node opens `{{ inputs.<name> }}` with `System.IO`
|
|
28
|
+
and a deterministic library (PdfPig geometry, OCR, barcode). No LLM, legal at
|
|
29
|
+
runtime. The file is a true swappable input; swap + Run, no recompile. Reference:
|
|
30
|
+
`demos/tekla-pdf-takeoff/` (page-count today; geometry takeoff is the same shape).
|
|
31
|
+
- **bake (A)** — the terminal AI reads the artifact at **compose** time and bakes
|
|
32
|
+
literals into `config.yaml`; the run never reads it. Reference:
|
|
33
|
+
`demos/screenshot-to-saved-settings/`. **AWARE REQUIRES this for any vision read:**
|
|
34
|
+
`aware app validate` rejects a runtime think-node that reads a drawing
|
|
35
|
+
(`app-spec.md` §"Reading a drawing / PDF / image", and `validate.rs`).
|
|
36
|
+
- **rebake (B3)** — swapping the artifact re-triggers the compose-time bake via the
|
|
37
|
+
request relay; the **`floless-app-rebake` skill** teaches the host AI to re-read +
|
|
38
|
+
re-bake + recompile + approve. The baked input declares `read-strategy: bake`; the
|
|
39
|
+
entry node shows a **"Re-read & re-bake ▸"** affordance. Feels like swap-and-run,
|
|
40
|
+
keeps determinism. (Shipped — Sub-project 2.)
|
|
41
|
+
- **vision (B2)** — a curated runtime `vision.extract` agent (image → schema-bound
|
|
42
|
+
JSON, content-cached, approve-gated). Needs an AWARE substrate change — RFC
|
|
43
|
+
`aware-aeco/aware#223` (+ reference impl PR). **Substrate-gated:** until `aware`
|
|
44
|
+
ships `vision.extract`, authoring a B2 app fails `validate`; use **rebake (B3)** for
|
|
45
|
+
swap + vision in the meantime (a B2 input is a drop-in upgrade of a B3 input).
|
|
46
|
+
|
|
47
|
+
**Rule:** never put an LLM in the run path except B2's curated, lock-pinned,
|
|
48
|
+
approve-gated agent. The UI never interprets the file — it only stores it and passes
|
|
49
|
+
the path.
|
|
50
|
+
|
|
51
|
+
## Authoring a B2 (vision) app — gated on AWARE `vision.extract`
|
|
52
|
+
|
|
53
|
+
A B2 app is a normal reusable `.flo` whose **source drawing is a runtime input** (the SP1
|
|
54
|
+
picker — `read-strategy: vision`, `widget: file`) feeding a **`vision.extract` node**, whose
|
|
55
|
+
schema-bound JSON then drives deterministic downstream nodes behind an `approve:` gate:
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
inputs:
|
|
59
|
+
drawing: { type: image, widget: file, read-strategy: vision, accept: [png, jpg, pdf],
|
|
60
|
+
description: The schedule drawing to extract (swappable per run). }
|
|
61
|
+
nodes:
|
|
62
|
+
- id: extract
|
|
63
|
+
agent: vision
|
|
64
|
+
command: extract # the curated carve-out — runs a model at RUN time
|
|
65
|
+
config:
|
|
66
|
+
file: "{{ inputs.drawing }}" # bytes on the edge
|
|
67
|
+
model: "claude-..." # pinned in the lock; a swap re-invalidates approval
|
|
68
|
+
schema: { type: object, properties: { rows: { type: array, ... } } } # FIXED output schema
|
|
69
|
+
prompt: "Extract each schedule row as {member, boltCount, ...}."
|
|
70
|
+
- id: write
|
|
71
|
+
# ...deterministic for-each over {{ extract.result.rows }}...
|
|
72
|
+
approve: { ... } # human eyeballs the extraction before any write
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**floless seams for B2 (mostly already in place from SP1):**
|
|
76
|
+
- The picker is the **SP1 file field** — a `read-strategy: vision` input already gets "Set
|
|
77
|
+
inputs ▸" and stores the path as `{{ inputs.drawing }}` (no new UI). Swap + Run; the
|
|
78
|
+
`vision.extract` node re-reads at runtime (content-cached, so a repeat input is replayed
|
|
79
|
+
from cache with no model call).
|
|
80
|
+
- When AWARE stamps a `runtime-model` marker on the compiled node (RFC §5.3), `app-reader`
|
|
81
|
+
surfaces it and the canvas badges **"calls a model at run time"** so the approver sees it
|
|
82
|
+
honestly — wire this against the real lock field once `vision.extract` lands.
|
|
83
|
+
- Gate the affordance/authoring on the installed `aware` version exposing `vision`
|
|
84
|
+
(`aware agent has vision extract`); degrade to B3 below the floor.
|
|
85
|
+
|
|
86
|
+
## Server / UI seams (where this lives)
|
|
87
|
+
|
|
88
|
+
- `server/visual-input-store.ts` — content-addressed store (`storeVisualInput`),
|
|
89
|
+
PDF-aware magic-byte sniff, path-safety.
|
|
90
|
+
- `server/app-reader.ts` — `AppInput.widget/accept/readStrategy` + `isVisualInput`.
|
|
91
|
+
- `POST /api/visual-input` (`server/index.ts`) — `{appId, name, dataUrl}` → `{path}`.
|
|
92
|
+
- `web/aware.js` — `openInputsDialog` renders a `type:'file'` field (reuses `.fm-drop`);
|
|
93
|
+
on a fresh pick it uploads the data URL and stores the returned path as the input
|
|
94
|
+
value; `addNodeInputs` shows a `.ni-pair-file` chip (file glyph + basename).
|
package/dist/web/app.css
CHANGED
|
@@ -367,6 +367,17 @@
|
|
|
367
367
|
}
|
|
368
368
|
/* (the grabbing cursor during a pan is already forced by `.canvas.panning *` above) */
|
|
369
369
|
|
|
370
|
+
/* Canvas chrome (top label, bottom hint + Templates bar) paints ABOVE the
|
|
371
|
+
transform-panned topology with an OPAQUE background, so dragging the canvas
|
|
372
|
+
slides node cards cleanly BEHIND the chrome instead of overlapping it. The
|
|
373
|
+
toolbar already does this via its own z-index. */
|
|
374
|
+
.canvas > .panel-label,
|
|
375
|
+
.canvas > .hint { position: relative; z-index: 6; background: var(--bg); }
|
|
376
|
+
/* fav-bar is already opaque (surface); the notes pill stays translucent by design
|
|
377
|
+
— both just need to paint ABOVE the panned content (the notes stay readable). */
|
|
378
|
+
.canvas > .fav-bar,
|
|
379
|
+
.canvas > .notes-strip { position: relative; z-index: 6; }
|
|
380
|
+
|
|
370
381
|
/* LINEAR */
|
|
371
382
|
.topology:not(.dag) .agent-card { flex: 0 0 210px; }
|
|
372
383
|
|
|
@@ -563,6 +574,16 @@
|
|
|
563
574
|
word-break: break-word;
|
|
564
575
|
}
|
|
565
576
|
.agent-card .blurb { font-size: 11.5px; color: var(--text-muted); line-height: 1.5; margin-bottom: 10px; }
|
|
577
|
+
/* B2: the one node that calls a model at run time (vision.extract). A subtle accent
|
|
578
|
+
pill so the approver sees the non-deterministic-on-first-input node honestly. */
|
|
579
|
+
.agent-card .rt-model-badge {
|
|
580
|
+
display: inline-flex; align-items: center; gap: 4px;
|
|
581
|
+
margin: 0 0 8px; padding: 2px 8px;
|
|
582
|
+
font: 600 10px/1.4 var(--ui); letter-spacing: 0.01em;
|
|
583
|
+
color: var(--accent); background: var(--accent-soft);
|
|
584
|
+
border: 1px solid var(--accent-dim); border-radius: 999px;
|
|
585
|
+
cursor: help;
|
|
586
|
+
}
|
|
566
587
|
.agent-card .footer-row {
|
|
567
588
|
display: flex;
|
|
568
589
|
justify-content: space-between;
|
|
@@ -572,7 +593,10 @@
|
|
|
572
593
|
border-top: 1px dashed var(--border-strong);
|
|
573
594
|
padding-top: 8px;
|
|
574
595
|
}
|
|
575
|
-
|
|
596
|
+
/* The "inspect ▸" hint is the explicit affordance that opens the Inspect panel —
|
|
597
|
+
read it as a button: pointer cursor + brighten/underline on hover. */
|
|
598
|
+
.agent-card .inspect-hint { color: var(--accent); opacity: 0.6; cursor: pointer; transition: opacity 0.12s; }
|
|
599
|
+
.agent-card .inspect-hint:hover { opacity: 1; text-decoration: underline; }
|
|
576
600
|
.agent-card.selected .inspect-hint { opacity: 1; }
|
|
577
601
|
|
|
578
602
|
/* LINEAR WIRES */
|
|
@@ -1515,7 +1539,9 @@
|
|
|
1515
1539
|
left: 14px;
|
|
1516
1540
|
display: flex;
|
|
1517
1541
|
gap: 8px;
|
|
1518
|
-
z-index:
|
|
1542
|
+
/* Above the canvas chrome (z-index:6) — it overlaps the hint band, and must
|
|
1543
|
+
stay fully visible/clickable (it's the always-on zoom + Fit control). */
|
|
1544
|
+
z-index: 7;
|
|
1519
1545
|
}
|
|
1520
1546
|
.toolbar-group {
|
|
1521
1547
|
display: flex;
|
|
@@ -2350,6 +2376,23 @@ body {
|
|
|
2350
2376
|
.fm-thumb-del { position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; padding: 0; display: inline-flex; align-items: center; justify-content: center; font-size: 11px; line-height: 1; background: var(--surface); border: 1px solid var(--border-strong); border-radius: 2px; color: var(--text-muted); cursor: pointer; opacity: 0; transition: opacity 0.12s, color 0.12s, border-color 0.12s; }
|
|
2351
2377
|
.fm-thumb:hover .fm-thumb-del, .fm-thumb-del:focus-visible { opacity: 1; }
|
|
2352
2378
|
.fm-thumb-del:hover { color: var(--err); border-color: var(--err); }
|
|
2379
|
+
/* ===== Visual Input: single image/PDF file field (inputs dialog) ===== */
|
|
2380
|
+
.fm-file { position: relative; }
|
|
2381
|
+
.fm-file .fm-file-input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
|
|
2382
|
+
.fm-drop .fm-drop-hint { display: block; margin-top: 3px; font-size: 10px; color: var(--text-dim); }
|
|
2383
|
+
.fm-drop.has-file { min-height: auto; padding: 6px 10px; flex-direction: row; justify-content: space-between; gap: 8px; border-style: solid; cursor: default; text-align: left; }
|
|
2384
|
+
.fm-drop.has-file:hover, .fm-drop.has-file:focus-visible { border-color: var(--border-strong); background: var(--surface-2); color: var(--text-muted); outline: none; }
|
|
2385
|
+
.fm-file-glyph { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 18px; border: 1px solid var(--accent-dim); border-radius: 3px; background: var(--accent-soft); color: var(--accent); font-family: var(--mono); font-size: 8px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; flex-shrink: 0; }
|
|
2386
|
+
.fm-file-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 11px; color: var(--text); text-align: left; }
|
|
2387
|
+
.fm-file-actions { display: flex; gap: 5px; flex-shrink: 0; }
|
|
2388
|
+
.fm-file-replace, .fm-file-clear { font-size: 10px; padding: 3px 8px; background: var(--surface); border: 1px solid var(--border-strong); color: var(--text-muted); border-radius: 3px; cursor: pointer; font-family: var(--ui); }
|
|
2389
|
+
.fm-file-replace:hover { color: var(--text); border-color: var(--accent-dim); }
|
|
2390
|
+
.fm-file-clear:hover { color: var(--err); border-color: var(--err); }
|
|
2391
|
+
/* Visual Input chip on the canvas input node */
|
|
2392
|
+
.agent-card .node-inputs .ni-pair-file { align-items: center; }
|
|
2393
|
+
.agent-card .node-inputs .ni-file-glyph { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 15px; border: 1px solid var(--accent-dim); border-radius: 3px; background: var(--accent-soft); color: var(--accent); font: 700 8px/1 var(--mono); letter-spacing: 0.04em; text-transform: uppercase; flex-shrink: 0; }
|
|
2394
|
+
.agent-card .node-inputs .ni-file-name { max-width: 92px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; vertical-align: bottom; }
|
|
2395
|
+
.agent-card .node-inputs .ni-not-set { color: var(--text-dim); font-style: italic; font-weight: 400; }
|
|
2353
2396
|
/* ===== Requests: snapshot thumbnails ===== */
|
|
2354
2397
|
.req-thumbs { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
|
|
2355
2398
|
.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; }
|
package/dist/web/app.js
CHANGED
|
@@ -346,6 +346,7 @@ function cardEl(id, ports) {
|
|
|
346
346
|
</div>
|
|
347
347
|
<div class="title">${a.title}</div>
|
|
348
348
|
<div class="subtitle">${a.subtitle}</div>
|
|
349
|
+
${a._runtimeModel ? '<div class="rt-model-badge" title="This node calls a model at run time (vision.extract). Output is content-cached and sits behind an approve gate — review the extraction before any write.">⚡ calls a model at run time</div>' : ''}
|
|
349
350
|
<div class="blurb">${a.blurb}</div>
|
|
350
351
|
<div class="footer-row">
|
|
351
352
|
<span class="ports"><span class="ports-num">${ports.in}</span> in <span class="ports-arrow">·</span> <span class="ports-num">${ports.out}</span> out</span>
|
package/dist/web/aware.js
CHANGED
|
@@ -284,6 +284,7 @@
|
|
|
284
284
|
for (const [k, v] of Object.entries(inputs || {})) if (k !== 'code') inputsNoCode[k] = v;
|
|
285
285
|
return {
|
|
286
286
|
_mode: n.mode,
|
|
287
|
+
_runtimeModel: !!n.runtimeModel, // B2: lock stamped runtime-model → card badge
|
|
287
288
|
icon: iconFor(n.agent),
|
|
288
289
|
kind: n.kind === 'agent' && n.agent ? `${agentLabel} agent` : escapeHtml(n.kind),
|
|
289
290
|
version: pin ? `v${escapeHtml(String(pin))}` : '—',
|
|
@@ -780,13 +781,27 @@
|
|
|
780
781
|
// every markSpecialNodes — which openInputsDialog calls right after a change.
|
|
781
782
|
function addNodeInputs(card) {
|
|
782
783
|
const vals = currentInputs();
|
|
783
|
-
const
|
|
784
|
+
const app = currentId && apps.get(currentId);
|
|
785
|
+
const declared = app && Array.isArray(app.inputs) ? app.inputs : [];
|
|
786
|
+
const byName = new Map(declared.map((i) => [i.name, i]));
|
|
787
|
+
const isVisual = (inp) => !!inp && (inp.widget === 'file' || inp.type === 'image' || inp.type === 'file');
|
|
788
|
+
const baseName = (p) => String(p || '').split(/[\\/]/).pop() || '';
|
|
789
|
+
// Show every SET value, plus any declared visual input (so its "not set" /
|
|
790
|
+
// attach affordance is visible on the node before the first pick).
|
|
791
|
+
const keys = [...new Set([...Object.keys(vals), ...declared.filter(isVisual).map((i) => i.name)])];
|
|
784
792
|
if (!keys.length) return;
|
|
785
793
|
const wrap = document.createElement('div');
|
|
786
794
|
wrap.className = 'node-inputs';
|
|
787
|
-
wrap.innerHTML = keys
|
|
788
|
-
|
|
789
|
-
|
|
795
|
+
wrap.innerHTML = keys.map((k) => {
|
|
796
|
+
const inp = byName.get(k);
|
|
797
|
+
if (isVisual(inp)) {
|
|
798
|
+
const v = String(vals[k] || '');
|
|
799
|
+
if (!v) return `<span class="ni-pair ni-pair-file ni-pair-file-empty"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-val ni-not-set">not set</span></span>`;
|
|
800
|
+
const glyph = /\.pdf$/i.test(v) ? 'pdf' : 'img';
|
|
801
|
+
return `<span class="ni-pair ni-pair-file"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-file-glyph">${glyph}</span><span class="ni-val ni-file-name" title="${escapeAttr(v)}">${escapeHtml(baseName(v))}</span></span>`;
|
|
802
|
+
}
|
|
803
|
+
return `<span class="ni-pair"><span class="ni-key">${escapeHtml(k)}</span><span class="ni-val">${escapeHtml(String(vals[k]))}</span></span>`;
|
|
804
|
+
}).join('');
|
|
790
805
|
card.appendChild(wrap);
|
|
791
806
|
}
|
|
792
807
|
|
|
@@ -797,18 +812,57 @@
|
|
|
797
812
|
function markSpecialNodes() {
|
|
798
813
|
const rid = reportNodeId();
|
|
799
814
|
const iid = inputNodeId();
|
|
800
|
-
|
|
815
|
+
// B3: a baked-visual app (read-strategy: bake) has no runtime input node, so the
|
|
816
|
+
// "Re-read & re-bake ▸" affordance lands on the entry (first) node card.
|
|
817
|
+
const app = currentId && apps.get(currentId);
|
|
818
|
+
const rebakeInput = app && app.rebakeInput ? app.rebakeInput : null;
|
|
819
|
+
const cards = document.querySelectorAll('.agent-card');
|
|
820
|
+
const firstId = cards.length ? cards[0].dataset.agentId : null;
|
|
821
|
+
cards.forEach((card) => {
|
|
801
822
|
const id = card.dataset.agentId;
|
|
802
823
|
const isReport = !!rid && id === rid;
|
|
803
824
|
const isInput = !!iid && id === iid;
|
|
825
|
+
const isRebake = !!rebakeInput && id === firstId;
|
|
804
826
|
card.classList.toggle('report-node', isReport);
|
|
805
827
|
card.classList.toggle('input-node', isInput);
|
|
828
|
+
card.classList.toggle('rebake-node', isRebake);
|
|
806
829
|
// clear anything we injected last render, so re-renders never stack
|
|
807
830
|
card.querySelectorAll('.node-action, .node-inputs').forEach((b) => b.remove());
|
|
808
831
|
if (isInput) addNodeInputs(card); // current values, above the button
|
|
809
832
|
if (isReport) addNodeAction(card, 'View report ▸', () => showReport(id));
|
|
810
833
|
if (isInput) addNodeAction(card, 'Set inputs ▸', () => openInputsDialog());
|
|
834
|
+
if (isRebake) addNodeAction(card, 'Re-read & re-bake ▸', () => openRebakeDialog());
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// B3 "swap re-bakes": pick a new source drawing for a baked app and queue a
|
|
839
|
+
// re-bake request. Thin-UI — the host AI (via the floless-app-rebake skill)
|
|
840
|
+
// does the actual re-read + re-bake at compose time; the browser only records
|
|
841
|
+
// intent + the new image. Re-uses the SP1 file field + the request relay.
|
|
842
|
+
async function openRebakeDialog() {
|
|
843
|
+
const app = currentId && apps.get(currentId);
|
|
844
|
+
if (!app || !app.rebakeInput) return;
|
|
845
|
+
const name = app.rebakeInput;
|
|
846
|
+
const res = await formModal({
|
|
847
|
+
title: `Re-read & re-bake · ${app.displayName}`,
|
|
848
|
+
sub: 'Swap the source drawing. Your terminal AI will re-read it, re-bake the values into config, then ask you to approve.',
|
|
849
|
+
fields: [{ name, label: `${name} — new source drawing`, type: 'file', accept: ['png', 'jpg', 'webp'] }],
|
|
850
|
+
okLabel: 'Queue re-bake',
|
|
811
851
|
});
|
|
852
|
+
if (!res) return;
|
|
853
|
+
const dataUrl = res[name];
|
|
854
|
+
if (typeof dataUrl !== 'string' || !dataUrl.startsWith('data:')) { showToast('attach a drawing to re-bake', 'warn'); return; }
|
|
855
|
+
try {
|
|
856
|
+
const r = await fetch('/api/rebake', {
|
|
857
|
+
method: 'POST',
|
|
858
|
+
headers: { 'content-type': 'application/json' },
|
|
859
|
+
body: JSON.stringify({ appId: app.id, inputName: name, snapshots: [{ dataUrl }] }),
|
|
860
|
+
});
|
|
861
|
+
const out = await r.json().catch(() => ({ ok: false, error: `re-bake failed (${r.status})` }));
|
|
862
|
+
if (!out || !out.ok) { showToast((out && out.error) || 'could not queue re-bake', 'err'); return; }
|
|
863
|
+
showToast('Queued — your terminal AI will re-read & re-bake, then ask you to approve.', 'ok');
|
|
864
|
+
appendNarration(`<strong>Re-bake queued</strong> for <strong>${escapeHtml(name)}</strong>. Your terminal AI will re-read the drawing, re-bake the config literals, and ask you to approve the new lock.`);
|
|
865
|
+
} catch { showToast('could not queue re-bake', 'err'); }
|
|
812
866
|
}
|
|
813
867
|
|
|
814
868
|
// Per-app declared-input values, set via the input node's double-click dialog.
|
|
@@ -932,25 +986,54 @@
|
|
|
932
986
|
const app = currentId && apps.get(currentId);
|
|
933
987
|
if (!app || !Array.isArray(app.inputs) || !app.inputs.length) return;
|
|
934
988
|
const cur = currentInputs();
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
989
|
+
const isVisual = (inp) => inp.widget === 'file' || inp.type === 'image' || inp.type === 'file';
|
|
990
|
+
const visualNames = new Set(app.inputs.filter(isVisual).map((i) => i.name));
|
|
991
|
+
const fields = app.inputs.map((inp) => isVisual(inp)
|
|
992
|
+
? {
|
|
993
|
+
name: inp.name,
|
|
994
|
+
label: inp.name + (inp.description ? ` — ${inp.description}` : ''),
|
|
995
|
+
type: 'file',
|
|
996
|
+
accept: inp.accept,
|
|
997
|
+
value: cur[inp.name] != null ? cur[inp.name] : '',
|
|
998
|
+
}
|
|
999
|
+
: {
|
|
1000
|
+
name: inp.name,
|
|
1001
|
+
label: inp.name + (inp.description ? ` — ${inp.description}` : ''),
|
|
1002
|
+
type: inp.type === 'integer' || inp.type === 'number' ? 'number' : 'text',
|
|
1003
|
+
value: cur[inp.name] != null ? cur[inp.name] : inp.default != null ? inp.default : '',
|
|
1004
|
+
});
|
|
941
1005
|
const res = await formModal({ title: `Inputs · ${app.displayName}`, sub: 'Set the values this run uses, then ▶ Run workflow.', fields, okLabel: 'Set inputs' });
|
|
942
1006
|
if (!res) return;
|
|
943
1007
|
const store = {};
|
|
944
|
-
app.inputs
|
|
1008
|
+
for (const inp of app.inputs) {
|
|
1009
|
+
if (isVisual(inp)) {
|
|
1010
|
+
const v = res[inp.name];
|
|
1011
|
+
if (typeof v === 'string' && v.startsWith('data:')) {
|
|
1012
|
+
// Fresh pick/paste → persist it to the store; the input VALUE is the path.
|
|
1013
|
+
try {
|
|
1014
|
+
const r = await fetch('/api/visual-input', {
|
|
1015
|
+
method: 'POST',
|
|
1016
|
+
headers: { 'content-type': 'application/json' },
|
|
1017
|
+
body: JSON.stringify({ appId: app.id, name: inp.name, dataUrl: v }),
|
|
1018
|
+
});
|
|
1019
|
+
const up = await r.json().catch(() => ({ ok: false, error: `upload failed (${r.status})` }));
|
|
1020
|
+
if (!up || !up.ok) { showToast((up && up.error) || 'could not store the file', 'err'); return; }
|
|
1021
|
+
store[inp.name] = up.path;
|
|
1022
|
+
} catch { showToast('could not upload the file', 'err'); return; }
|
|
1023
|
+
} else if (typeof v === 'string' && v) {
|
|
1024
|
+
store[inp.name] = v; // unchanged existing path
|
|
1025
|
+
} // cleared/empty → omit (no value)
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
945
1028
|
let v = String(res[inp.name] ?? '').trim();
|
|
946
|
-
if (v === '') { if (inp.default != null) v = String(inp.default); else
|
|
947
|
-
if (inp.type === 'integer' || inp.type === 'number') { const num = Number(v); if (!Number.isNaN(num)) { store[inp.name] = num;
|
|
1029
|
+
if (v === '') { if (inp.default != null) v = String(inp.default); else continue; }
|
|
1030
|
+
if (inp.type === 'integer' || inp.type === 'number') { const num = Number(v); if (!Number.isNaN(num)) { store[inp.name] = num; continue; } }
|
|
948
1031
|
store[inp.name] = v;
|
|
949
|
-
}
|
|
1032
|
+
}
|
|
950
1033
|
appInputValues.set(currentId, store);
|
|
951
1034
|
markSpecialNodes();
|
|
952
1035
|
refreshDirtyIndicator(); // values changed → reflect unsaved state in the header/menu
|
|
953
|
-
const badge = Object.entries(store).map(([k, v]) => `${k}=${v}`).join(' · ');
|
|
1036
|
+
const badge = Object.entries(store).map(([k, v]) => `${k}=${visualNames.has(k) ? String(v).split(/[\\/]/).pop() : v}`).join(' · ');
|
|
954
1037
|
appendNarration(`Inputs set — <strong>${escapeHtml(badge)}</strong>. Run with <strong>▶ Run workflow</strong>.`);
|
|
955
1038
|
showToast('Inputs set: ' + badge, 'ok');
|
|
956
1039
|
}
|
|
@@ -1286,6 +1369,16 @@
|
|
|
1286
1369
|
<div class="fm-drop" role="button" tabindex="0" aria-label="Paste a screenshot or click to attach">Paste a screenshot (Ctrl+V) or click to add<input type="file" accept="image/*" multiple class="fm-file-input" tabindex="-1" aria-hidden="true"></div>
|
|
1287
1370
|
<div class="fm-thumbs" hidden></div>
|
|
1288
1371
|
</div>`;
|
|
1372
|
+
} else if (f.type === 'file') {
|
|
1373
|
+
// Single image/PDF Visual Input. Reuses the .fm-drop visual language; the
|
|
1374
|
+
// hidden file input is a SIBLING of the drop (not a child) so render() can
|
|
1375
|
+
// freely rewrite the drop's innerHTML between empty/has-file without losing it.
|
|
1376
|
+
const accepts = (f.accept && f.accept.length) ? f.accept : ['png', 'jpg', 'webp', 'pdf'];
|
|
1377
|
+
const acceptAttr = accepts.map((e) => '.' + String(e).replace(/^\./, '')).join(',');
|
|
1378
|
+
ctl = `<div class="fm-file" data-fm-file-box="${escapeAttr(f.name)}" data-accept-hint="${escapeAttr(accepts.join(', ').toUpperCase())}" data-init-value="${escapeAttr(val)}">
|
|
1379
|
+
<div class="fm-drop" role="button" tabindex="0" aria-label="Attach an image or PDF"></div>
|
|
1380
|
+
<input type="file" accept="${escapeAttr(acceptAttr)}" class="fm-file-input" tabindex="-1" aria-hidden="true">
|
|
1381
|
+
</div>`;
|
|
1289
1382
|
} else {
|
|
1290
1383
|
ctl = f.multiline
|
|
1291
1384
|
? `<textarea id="${id}" rows="4" data-fm="${escapeHtml(f.name)}" placeholder="${ph}">${escapeHtml(val)}</textarea>`
|
|
@@ -1333,32 +1426,81 @@
|
|
|
1333
1426
|
fileInput.value = '';
|
|
1334
1427
|
};
|
|
1335
1428
|
});
|
|
1336
|
-
// ──
|
|
1429
|
+
// ── file-field setup (single image/PDF Visual Input) ───────────────────────
|
|
1430
|
+
// st.mode: 'empty' | 'path' (a previously-set on-disk path, passed through
|
|
1431
|
+
// unchanged) | 'dataurl' (a fresh pick/paste the caller uploads to the store).
|
|
1432
|
+
const fileStates = new Map();
|
|
1433
|
+
const fmBaseName = (p) => String(p || '').split(/[\\/]/).pop() || '';
|
|
1434
|
+
const fmExtOf = (p) => { const m = /\.([a-z0-9]+)$/i.exec(String(p || '')); return m ? m[1].toLowerCase() : ''; };
|
|
1435
|
+
$body.querySelectorAll('.fm-file').forEach((box) => {
|
|
1436
|
+
const name = box.dataset.fmFileBox;
|
|
1437
|
+
const hint = box.dataset.acceptHint || 'PNG, JPG, PDF';
|
|
1438
|
+
const drop = box.querySelector('.fm-drop');
|
|
1439
|
+
const fileInput = box.querySelector('.fm-file-input');
|
|
1440
|
+
const init = box.dataset.initValue || '';
|
|
1441
|
+
const st = init
|
|
1442
|
+
? { mode: 'path', value: init, name: fmBaseName(init), ext: fmExtOf(init) }
|
|
1443
|
+
: { mode: 'empty', value: '', name: '', ext: '' };
|
|
1444
|
+
const render = () => {
|
|
1445
|
+
const has = st.mode !== 'empty';
|
|
1446
|
+
drop.classList.toggle('has-file', has);
|
|
1447
|
+
if (!has) {
|
|
1448
|
+
drop.innerHTML = `Paste (Ctrl+V) or click to attach<span class="fm-drop-hint">${escapeHtml(hint)}</span>`;
|
|
1449
|
+
return;
|
|
1450
|
+
}
|
|
1451
|
+
const glyph = st.ext === 'pdf' ? 'pdf' : 'img';
|
|
1452
|
+
drop.innerHTML = `<span class="fm-file-glyph">${glyph}</span><span class="fm-file-name" title="${escapeAttr(st.value)}">${escapeHtml(st.name)}</span><span class="fm-file-actions"><button type="button" class="fm-file-replace">Replace</button><button type="button" class="fm-file-clear">Clear</button></span>`;
|
|
1453
|
+
drop.querySelector('.fm-file-replace').onclick = (e) => { e.stopPropagation(); fileInput.click(); };
|
|
1454
|
+
drop.querySelector('.fm-file-clear').onclick = (e) => { e.stopPropagation(); st.mode = 'empty'; st.value = ''; st.name = ''; st.ext = ''; render(); };
|
|
1455
|
+
};
|
|
1456
|
+
const setFile = (file) => {
|
|
1457
|
+
if (!file) return;
|
|
1458
|
+
const ext = fmExtOf(file.name) || (file.type === 'application/pdf' ? 'pdf' : '');
|
|
1459
|
+
const reader = new FileReader();
|
|
1460
|
+
reader.onload = () => { st.mode = 'dataurl'; st.value = reader.result; st.name = file.name || ('pasted.' + (ext || 'png')); st.ext = ext; render(); };
|
|
1461
|
+
reader.onerror = () => showToast('could not read the file', 'err');
|
|
1462
|
+
reader.readAsDataURL(file);
|
|
1463
|
+
};
|
|
1464
|
+
drop.onclick = () => { if (st.mode === 'empty') fileInput.click(); };
|
|
1465
|
+
drop.onkeydown = (e) => { if ((e.key === 'Enter' || e.key === ' ') && st.mode === 'empty') { e.preventDefault(); fileInput.click(); } };
|
|
1466
|
+
fileInput.onchange = () => { const f0 = (fileInput.files || [])[0]; if (f0) setFile(f0); fileInput.value = ''; };
|
|
1467
|
+
fileStates.set(name, { st, setFile });
|
|
1468
|
+
render();
|
|
1469
|
+
});
|
|
1470
|
+
// ── paste support: an image into the modal → the images field, else the file field ──
|
|
1337
1471
|
$body.onpaste = (e) => {
|
|
1338
|
-
const box = $body.querySelector('.fm-images');
|
|
1339
|
-
if (!box) return;
|
|
1340
1472
|
const items = Array.from((e.clipboardData && e.clipboardData.items) || []);
|
|
1341
1473
|
const imgs = items.filter((it) => it.kind === 'file' && it.type.startsWith('image/'));
|
|
1342
|
-
|
|
1474
|
+
const box = $body.querySelector('.fm-images');
|
|
1475
|
+
if (box) {
|
|
1476
|
+
if (!imgs.length) return;
|
|
1477
|
+
e.preventDefault();
|
|
1478
|
+
const entry = imageStates.get(box.dataset.fmImages);
|
|
1479
|
+
if (!entry) return;
|
|
1480
|
+
const { state, renderThumbs } = entry;
|
|
1481
|
+
const room = Math.max(0, 8 - state.length);
|
|
1482
|
+
if (imgs.length > room) showToast('Max 8 snapshots', 'warn');
|
|
1483
|
+
imgs.slice(0, room).forEach((it) => {
|
|
1484
|
+
const file = it.getAsFile();
|
|
1485
|
+
if (!file) return;
|
|
1486
|
+
const reader = new FileReader();
|
|
1487
|
+
reader.onload = () => { state.push({ name: file.name || 'pasted.png', dataUrl: reader.result }); renderThumbs(); };
|
|
1488
|
+
reader.readAsDataURL(file);
|
|
1489
|
+
});
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
const fileBox = $body.querySelector('.fm-file');
|
|
1493
|
+
if (!fileBox || !imgs.length) return;
|
|
1343
1494
|
e.preventDefault();
|
|
1344
|
-
const
|
|
1345
|
-
const
|
|
1346
|
-
if (
|
|
1347
|
-
const { state, renderThumbs } = entry;
|
|
1348
|
-
const room = Math.max(0, 8 - state.length);
|
|
1349
|
-
if (imgs.length > room) showToast('Max 8 snapshots', 'warn');
|
|
1350
|
-
imgs.slice(0, room).forEach((it) => {
|
|
1351
|
-
const file = it.getAsFile();
|
|
1352
|
-
if (!file) return;
|
|
1353
|
-
const reader = new FileReader();
|
|
1354
|
-
reader.onload = () => { state.push({ name: file.name || 'pasted.png', dataUrl: reader.result }); renderThumbs(); };
|
|
1355
|
-
reader.readAsDataURL(file);
|
|
1356
|
-
});
|
|
1495
|
+
const entry = fileStates.get(fileBox.dataset.fmFileBox);
|
|
1496
|
+
const file = imgs[0].getAsFile();
|
|
1497
|
+
if (entry && file) entry.setFile(file);
|
|
1357
1498
|
};
|
|
1358
1499
|
const collect = () => {
|
|
1359
1500
|
const out = {};
|
|
1360
1501
|
$body.querySelectorAll('[data-fm]').forEach((el) => { out[el.dataset.fm] = el.value; });
|
|
1361
1502
|
imageStates.forEach((entry, name) => { out[name] = entry.state; });
|
|
1503
|
+
fileStates.forEach((entry, name) => { out[name] = entry.st.mode === 'empty' ? '' : entry.st.value; });
|
|
1362
1504
|
return out;
|
|
1363
1505
|
};
|
|
1364
1506
|
const done = (result) => {
|
|
@@ -3036,6 +3178,13 @@
|
|
|
3036
3178
|
const scope = req.panelId ? ` (panel "${req.panelId}")` : '';
|
|
3037
3179
|
return `Customize my floless.app dashboard${scope}: ${req.instruction}. Edit ~/.floless/ui/extensions.json per the floless-app-ui skill, then check it with \`aware agent invoke ui validate\`.`;
|
|
3038
3180
|
}
|
|
3181
|
+
if (req.type === 'rebake') {
|
|
3182
|
+
const where = req.inputName ? ` (input "${req.inputName}")` : '';
|
|
3183
|
+
const snaps = req.snapshots && req.snapshots.length
|
|
3184
|
+
? `\nNew drawing${req.snapshots.length > 1 ? 's' : ''} (read for the re-extraction): ${req.snapshots.join(', ')}`
|
|
3185
|
+
: '';
|
|
3186
|
+
return `In floless app "${req.appId}", re-read & re-bake${where} per the floless-app-rebake skill: ${req.instruction}${snaps}`;
|
|
3187
|
+
}
|
|
3039
3188
|
return '';
|
|
3040
3189
|
}
|
|
3041
3190
|
|
|
@@ -3114,9 +3263,13 @@
|
|
|
3114
3263
|
return;
|
|
3115
3264
|
}
|
|
3116
3265
|
$list.innerHTML = pendingRequests.map((r) => {
|
|
3117
|
-
const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : 'tweak';
|
|
3118
|
-
const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' ? 'req-type req-type-tweak' : 'req-type';
|
|
3119
|
-
const target = r.type === 'tweak' && r.nodeId
|
|
3266
|
+
const label = r.type === 'use-template' ? 'template' : r.type === 'ui-customize' ? 'dashboard' : r.type === 'rebake' ? 're-bake' : 'tweak';
|
|
3267
|
+
const badgeCls = r.type === 'tweak' || r.type === 'ui-customize' || r.type === 'rebake' ? 'req-type req-type-tweak' : 'req-type';
|
|
3268
|
+
const target = r.type === 'tweak' && r.nodeId
|
|
3269
|
+
? ` · node <code>${escapeHtml(r.nodeId)}</code>`
|
|
3270
|
+
: r.type === 'rebake' && r.inputName
|
|
3271
|
+
? ` · input <code>${escapeHtml(r.inputName)}</code>`
|
|
3272
|
+
: '';
|
|
3120
3273
|
const when = r.createdAt ? new Date(r.createdAt) : null;
|
|
3121
3274
|
const time = when && !isNaN(when) ? `<span class="req-time">${escapeHtml(nowStamp(when))}</span>` : '';
|
|
3122
3275
|
return `
|