partforge 0.4.0 → 0.5.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/README.md +5 -0
- package/bin/cli.js +57 -35
- package/docs/AUTHORING-PARTS.md +18 -0
- package/package.json +2 -1
- package/skills/partforge/SKILL.md +58 -0
- package/src/framework/app.css +36 -1
- package/src/framework/mount.js +13 -0
- package/src/framework/pick-request/batch.js +42 -0
- package/src/framework/pick-request/client.js +65 -0
- package/src/framework/pick-request/index.js +1 -0
- package/src/framework/pick-request/prompt-banner.js +88 -0
- package/src/framework/pick-request/server.js +158 -0
package/README.md
CHANGED
|
@@ -50,6 +50,11 @@ hide internal params, and keep the interface simple while staying deeply adjusta
|
|
|
50
50
|
`src/parts/demo.js` is a minimal worked example; run `npm run dev` and open
|
|
51
51
|
`/demo.html` to see it live.
|
|
52
52
|
|
|
53
|
+
- **Agent clarification (`request-a-pick`):** an external tool can ask the user to click
|
|
54
|
+
geometry and get the `Selection` back — serve with `?pickserver`, drive with
|
|
55
|
+
`partforge pick-serve` + `partforge pick "<prompt>" …`. See
|
|
56
|
+
`skills/partforge/SKILL.md` and the authoring guide.
|
|
57
|
+
|
|
53
58
|
## License
|
|
54
59
|
|
|
55
60
|
MIT
|
package/bin/cli.js
CHANGED
|
@@ -8,54 +8,76 @@ import { detectBackend } from "../src/framework/geometry/probe.js";
|
|
|
8
8
|
import { bootOcctKernel } from "../src/testing/occt.js";
|
|
9
9
|
import { measure } from "../src/testing/measure.js";
|
|
10
10
|
import { renderViews } from "../src/testing/render.js";
|
|
11
|
+
import { createPickServer, requestPicks, formatPickResult } from "../src/framework/pick-request/server.js";
|
|
11
12
|
|
|
12
13
|
const die = (msg) => { console.error(msg); process.exit(1); };
|
|
13
14
|
const slug = (s) => String(s).toLowerCase().replace(/\s+/g, "-");
|
|
14
15
|
|
|
15
|
-
const [, , cmd,
|
|
16
|
+
const [, , cmd, ...args] = process.argv;
|
|
16
17
|
const flags = {};
|
|
17
18
|
const positional = [];
|
|
18
|
-
for (let i = 0; i <
|
|
19
|
-
if (
|
|
20
|
-
const key =
|
|
21
|
-
flags[key] =
|
|
22
|
-
} else positional.push(
|
|
19
|
+
for (let i = 0; i < args.length; i++) {
|
|
20
|
+
if (args[i].startsWith("--")) {
|
|
21
|
+
const key = args[i].slice(2);
|
|
22
|
+
flags[key] = args[i + 1] && !args[i + 1].startsWith("--") ? args[++i] : true;
|
|
23
|
+
} else positional.push(args[i]);
|
|
23
24
|
}
|
|
24
|
-
const view = positional[0];
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
if (!partPath) die(`usage: partforge ${cmd} <part-module> [view]`);
|
|
26
|
+
const USAGE = "usage: partforge <measure|render|pick-serve|pick> …";
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
const
|
|
39
|
-
|
|
28
|
+
// --- pick-serve / pick: no part module, no kernel boot --------------------------
|
|
29
|
+
if (cmd === "pick-serve") {
|
|
30
|
+
const port = Number(flags.port) || 4518;
|
|
31
|
+
const timeoutMs = (Number(flags.timeout) || 120) * 1000;
|
|
32
|
+
const { port: bound } = await createPickServer({ port, timeoutMs }).start();
|
|
33
|
+
console.log(`partforge pick-server listening on http://127.0.0.1:${bound}`);
|
|
34
|
+
// keep the process alive serving requests
|
|
35
|
+
} else if (cmd === "pick") {
|
|
36
|
+
if (positional.length === 0) die('usage: partforge pick "<prompt>" ["<prompt>" …] [--port N]');
|
|
37
|
+
const port = Number(flags.port) || 4518;
|
|
38
|
+
const out = await requestPicks({ port, prompts: positional }).catch((e) => die(e.message));
|
|
39
|
+
console.log(formatPickResult(out));
|
|
40
|
+
process.exit(out.status === "done" ? 0 : 1);
|
|
41
|
+
} else if (!["measure", "render"].includes(cmd)) {
|
|
42
|
+
die(USAGE);
|
|
40
43
|
}
|
|
41
44
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
const partPath = positional[0];
|
|
46
|
+
const view = positional[1];
|
|
47
|
+
if (["measure", "render"].includes(cmd) && !partPath) die(`usage: partforge ${cmd} <part-module> [view]`);
|
|
48
|
+
|
|
49
|
+
if (["measure", "render"].includes(cmd)) {
|
|
50
|
+
const mod = await import(pathToFileURL(resolve(process.cwd(), partPath)))
|
|
51
|
+
.catch((e) => die(`cannot load part "${partPath}": ${e.message}`));
|
|
52
|
+
const part = mod.default;
|
|
53
|
+
if (!part?.parts || !part?.views) die(`"${partPath}" has no default-exported PartDefinition`);
|
|
54
|
+
|
|
55
|
+
let kernel;
|
|
56
|
+
if (detectBackend(part) === "occt") {
|
|
57
|
+
kernel = await bootOcctKernel();
|
|
51
58
|
} else {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
59
|
+
const wasm = await Module(); wasm.setup();
|
|
60
|
+
kernel = createManifoldKernel(wasm, { quality: "preview" });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
if (cmd === "measure") {
|
|
65
|
+
const report = measure(kernel, part, view);
|
|
66
|
+
printMeasure(report);
|
|
67
|
+
const file = `measure-${slug(report.part)}-${report.view}.json`;
|
|
68
|
+
writeFileSync(file, JSON.stringify(report, null, 2));
|
|
69
|
+
console.log(`\nwrote ${file}`);
|
|
70
|
+
if (flags.json) console.log(JSON.stringify(report, null, 2));
|
|
71
|
+
process.exit(report.ok ? 0 : 1);
|
|
72
|
+
} else {
|
|
73
|
+
const views = typeof flags.views === "string" ? flags.views.split(",") : undefined;
|
|
74
|
+
const files = await renderViews(kernel, part, view, { views, out: flags.out || "render" });
|
|
75
|
+
for (const f of files) console.log(`wrote ${f}`);
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
78
|
+
} catch (e) {
|
|
79
|
+
die(`${cmd} failed: ${e.message || e}`);
|
|
56
80
|
}
|
|
57
|
-
} catch (e) {
|
|
58
|
-
die(`${cmd} failed: ${e.message || e}`);
|
|
59
81
|
}
|
|
60
82
|
|
|
61
83
|
function printMeasure(r) {
|
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -467,3 +467,21 @@ entirely on OCCT, its fillets are exact in the STEP **and** present in the print
|
|
|
467
467
|
`place(..., { purpose: "export" })` may depend on `view`.
|
|
468
468
|
- Keep geometry backend-agnostic (kernel calls only) so it works in both backends; only
|
|
469
469
|
STEP requires OCCT.
|
|
470
|
+
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
## Interactive clarification: request-a-pick
|
|
474
|
+
|
|
475
|
+
An external tool (e.g. an AI agent editing your part) can ask the *user* to click
|
|
476
|
+
geometry and receive the `Selection` back, closing the loop in the other direction
|
|
477
|
+
from `?pick`.
|
|
478
|
+
|
|
479
|
+
- Serve your app with **`?pickserver`** (or `?pickserver=http://host:port`) to enable
|
|
480
|
+
it. While idle nothing changes; when the local pick-server requests a click, a banner
|
|
481
|
+
appears ("🤖 Claude needs you to click …") and the picker arms for one click.
|
|
482
|
+
- The agent side runs `partforge pick-serve` once, then `partforge pick "<prompt>" …`
|
|
483
|
+
for one or more clicks (collected in order, returned together). The CLI blocks until
|
|
484
|
+
the user clicks, then prints the `Selection`(s) as JSON.
|
|
485
|
+
|
|
486
|
+
See the bundled skill `skills/partforge/SKILL.md` for the agent workflow. This is plain
|
|
487
|
+
click-routing — no LLM logic lives in partforge.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"files": [
|
|
17
17
|
"src",
|
|
18
18
|
"bin",
|
|
19
|
+
"skills/partforge/SKILL.md",
|
|
19
20
|
"docs/AUTHORING-PARTS.md",
|
|
20
21
|
"README.md"
|
|
21
22
|
],
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: partforge-request-pick
|
|
3
|
+
description: Use when editing a partforge part for a user who has the live app open and you need them to point at geometry — ask for one or more clicks and get the Selection(s) back.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# partforge: request-a-pick
|
|
7
|
+
|
|
8
|
+
When you're editing a partforge part and you're unsure *which* face, edge, hole, or
|
|
9
|
+
sub-part the user means, don't guess — ask them to click it in the live app. Their
|
|
10
|
+
click comes back to you as a structured `Selection` (sub-part, local CAD point,
|
|
11
|
+
surface normal, the parameters they were viewing).
|
|
12
|
+
|
|
13
|
+
## When to use
|
|
14
|
+
|
|
15
|
+
- The user's request is geometrically ambiguous ("make this thicker", "fillet that
|
|
16
|
+
edge", "move the hole") and more than one feature could match.
|
|
17
|
+
- You need a concrete location/normal to drive an edit.
|
|
18
|
+
|
|
19
|
+
## One-time setup (per session)
|
|
20
|
+
|
|
21
|
+
Start the pick-server (it bridges the app and this CLI). The user must have the app
|
|
22
|
+
open with `?pickserver` (e.g. `http://localhost:5173/?pickserver`).
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
partforge pick-serve & # default http://127.0.0.1:4518
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Requesting clicks
|
|
29
|
+
|
|
30
|
+
Ask for one or many — they're collected in order and returned together:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
partforge pick "click the face you want filleted"
|
|
34
|
+
partforge pick "click the mounting hole" "click the top edge" "click the boss"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Tell the user out loud to check their browser ("I've put a prompt in your browser —
|
|
38
|
+
click the face you want filleted"). The command **blocks** until they click (or
|
|
39
|
+
timeout), then prints a summary plus JSON:
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{ "status": "done", "picks": [ { "prompt": "...", "selection": { "subPart": "...", "point": [...], "normal": [...], "params": {...} } } ] }
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Picks come back **in request order**, each echoing its prompt, so you can map them.
|
|
46
|
+
|
|
47
|
+
## Handling outcomes
|
|
48
|
+
|
|
49
|
+
- `done` — proceed with the returned `selection`(s).
|
|
50
|
+
- `timeout` — the user didn't click in time; `picks` holds any collected so far. Ask
|
|
51
|
+
again or fall back to asking in words.
|
|
52
|
+
- `cancelled` — the user clicked "Can't find it"; reconsider what you're asking for.
|
|
53
|
+
- `busy` (exit non-zero) — a request is already in flight; wait and retry.
|
|
54
|
+
|
|
55
|
+
## Notes
|
|
56
|
+
|
|
57
|
+
- This only *reads* a click — it never edits files. You make the edits yourself after.
|
|
58
|
+
- The server is localhost-only and holds one request at a time.
|
package/src/framework/app.css
CHANGED
|
@@ -163,4 +163,39 @@ button.action:disabled { opacity: .5; cursor: default; }
|
|
|
163
163
|
/* relevance: controls/sections that don't affect the on-screen parts */
|
|
164
164
|
.irrelevant { opacity: 0.45; }
|
|
165
165
|
.irrelevant:hover { opacity: 0.7; }
|
|
166
|
-
.section-hidden { display: none; }
|
|
166
|
+
.section-hidden { display: none; }
|
|
167
|
+
|
|
168
|
+
/* request-a-pick: agent prompt banner, floated top-centre well below the part tabs,
|
|
169
|
+
laid out like a chat message (avatar + text). Slides/fades in when shown; themed. */
|
|
170
|
+
#pf-pick-banner {
|
|
171
|
+
position: fixed; top: 78px; left: 50%; transform: translateX(-50%);
|
|
172
|
+
z-index: 30; max-width: min(56ch, calc(100vw - 24px));
|
|
173
|
+
padding: 10px 36px 10px 12px;
|
|
174
|
+
background: var(--surface); color: var(--text);
|
|
175
|
+
border: 1px solid var(--border); border-radius: 10px;
|
|
176
|
+
box-shadow: 0 6px 24px rgba(0,0,0,.35);
|
|
177
|
+
font-size: 12px; line-height: 1.45;
|
|
178
|
+
animation: pf-pick-in .18s ease;
|
|
179
|
+
}
|
|
180
|
+
#pf-pick-banner .pf-pick-row { display: flex; align-items: center; gap: 10px; }
|
|
181
|
+
#pf-pick-banner .pf-pick-avatar {
|
|
182
|
+
flex: none; width: 30px; height: 30px; border-radius: 50%; font-size: 17px;
|
|
183
|
+
display: flex; align-items: center; justify-content: center;
|
|
184
|
+
background: var(--surface-2); border: 1px solid var(--border);
|
|
185
|
+
}
|
|
186
|
+
#pf-pick-banner .pf-pick-msg { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
|
187
|
+
#pf-pick-banner .pf-pick-label { color: var(--muted); font-size: 11px; }
|
|
188
|
+
#pf-pick-banner .pf-pick-prompt { color: var(--text-strong); font-weight: 600; }
|
|
189
|
+
#pf-pick-close {
|
|
190
|
+
position: absolute; top: 6px; right: 6px; appearance: none;
|
|
191
|
+
width: 20px; height: 20px; padding: 0;
|
|
192
|
+
border: 1px solid var(--border); border-radius: 6px;
|
|
193
|
+
background: var(--surface-2); color: var(--muted);
|
|
194
|
+
cursor: pointer; font-size: 13px; line-height: 1;
|
|
195
|
+
display: flex; align-items: center; justify-content: center;
|
|
196
|
+
}
|
|
197
|
+
#pf-pick-close:hover { color: var(--text-strong); border-color: var(--accent); }
|
|
198
|
+
@keyframes pf-pick-in {
|
|
199
|
+
from { opacity: 0; transform: translateX(-50%) translateY(-10px); }
|
|
200
|
+
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
201
|
+
}
|
package/src/framework/mount.js
CHANGED
|
@@ -9,6 +9,7 @@ import { viewSubParts } from "./jobs.js";
|
|
|
9
9
|
import { detectBackend } from "./geometry/probe.js";
|
|
10
10
|
import { createDebugOverlay } from "./debug-overlay.js";
|
|
11
11
|
import { attachPicker, formatSelection } from "./selection/index.js";
|
|
12
|
+
import { createPickRequestClient } from "./pick-request/index.js";
|
|
12
13
|
|
|
13
14
|
// Mount a full parametric-part app from a PartDefinition: 3-D viewer + control
|
|
14
15
|
// panel + the two geometry workers + the auto-regenerating view/cache loop +
|
|
@@ -108,6 +109,18 @@ export function mount(part, { createWorker, container = document.getElementById(
|
|
|
108
109
|
picker.setActive(isActive);
|
|
109
110
|
btn.style.outline = isActive ? "2px solid #ffcc33" : "";
|
|
110
111
|
});
|
|
112
|
+
} else if (qs.has("pickserver")) {
|
|
113
|
+
// Agent-driven mode: arm the picker only when the local pick-server asks for a
|
|
114
|
+
// click. Mutually exclusive with the clipboard ?pick toggle (else-if), so only one
|
|
115
|
+
// click listener is ever live. `?pickserver` or `?pickserver=http://host:port`.
|
|
116
|
+
const serverUrl = typeof qs.get("pickserver") === "string" && qs.get("pickserver")
|
|
117
|
+
? qs.get("pickserver") : "http://127.0.0.1:4518";
|
|
118
|
+
createPickRequestClient({
|
|
119
|
+
serverUrl,
|
|
120
|
+
viewer,
|
|
121
|
+
part,
|
|
122
|
+
getContext: () => ({ view, params, derived: part.derive ? part.derive({ ...part.defaults, ...params }) : {} }),
|
|
123
|
+
});
|
|
111
124
|
}
|
|
112
125
|
|
|
113
126
|
let framedView = null; // the view the camera was last framed to (null until first show)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Pure state machine for one batch of click requests: ordered prompts in,
|
|
2
|
+
// ordered picks out. No http, no DOM, no timers (the server owns the clock).
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export function createBatch(prompts) {
|
|
6
|
+
return { id: randomUUID(), prompts: [...prompts], picks: [], index: 0, status: "collecting" };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function view(batch) {
|
|
10
|
+
const collecting = batch.status === "collecting";
|
|
11
|
+
return {
|
|
12
|
+
id: batch.id,
|
|
13
|
+
index: batch.index,
|
|
14
|
+
total: batch.prompts.length,
|
|
15
|
+
prompt: collecting ? batch.prompts[batch.index] : null,
|
|
16
|
+
status: batch.status,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Record a click for the current step. Ignores a non-collecting batch or an index
|
|
21
|
+
// that isn't the one we're waiting on (stale/duplicate click guard).
|
|
22
|
+
export function resolve(batch, index, selection) {
|
|
23
|
+
if (batch.status !== "collecting" || index !== batch.index) return batch;
|
|
24
|
+
batch.picks.push({ prompt: batch.prompts[batch.index], selection });
|
|
25
|
+
batch.index += 1;
|
|
26
|
+
if (batch.index >= batch.prompts.length) batch.status = "done";
|
|
27
|
+
return batch;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function cancel(batch) {
|
|
31
|
+
if (batch.status === "collecting") batch.status = "cancelled";
|
|
32
|
+
return batch;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function timeout(batch) {
|
|
36
|
+
if (batch.status === "collecting") batch.status = "timeout";
|
|
37
|
+
return batch;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function result(batch) {
|
|
41
|
+
return { status: batch.status, picks: batch.picks };
|
|
42
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Browser side of request-a-pick: drives the shared prompt banner from two sources —
|
|
2
|
+
// an agent asking for clicks over SSE, and a click-to-copy button in the viewbar.
|
|
3
|
+
// Created only under ?pickserver (see mount.js), so both are present exactly when the
|
|
4
|
+
// user is working with an agent.
|
|
5
|
+
import { createPromptBanner } from "./prompt-banner.js";
|
|
6
|
+
import { formatSelection } from "../selection/format.js";
|
|
7
|
+
|
|
8
|
+
export function createPickRequestClient({ serverUrl = "http://127.0.0.1:4518", viewer, part, getContext }) {
|
|
9
|
+
let active = null; // { id, index } of the agent prompt we're waiting on
|
|
10
|
+
const banner = createPromptBanner({ viewer, part, getContext });
|
|
11
|
+
|
|
12
|
+
const postJson = (path, body) =>
|
|
13
|
+
fetch(`${serverUrl}${path}`, {
|
|
14
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
|
|
15
|
+
}).catch(() => banner.message("⚠ couldn't reach pick-server — click not sent"));
|
|
16
|
+
|
|
17
|
+
// --- agent prompts over SSE -------------------------------------------------
|
|
18
|
+
const es = new globalThis.EventSource(`${serverUrl}/events`);
|
|
19
|
+
es.addEventListener("prompt", (e) => {
|
|
20
|
+
const v = JSON.parse(e.data);
|
|
21
|
+
active = { id: v.id, index: v.index };
|
|
22
|
+
banner.request({
|
|
23
|
+
avatar: "🤖",
|
|
24
|
+
label: v.total > 1 ? `Your agent asks: (${v.index + 1} of ${v.total})` : "Your agent asks:",
|
|
25
|
+
text: v.prompt,
|
|
26
|
+
onResolve: (selection) => postJson("/resolve", { id: active.id, index: active.index, selection }),
|
|
27
|
+
onDismiss: () => { if (active) postJson("/cancel", { id: active.id }); },
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
es.addEventListener("cleared", () => { active = null; banner.dismiss(); });
|
|
31
|
+
es.onerror = () => { banner.message("⚠ agent pick-server not reachable"); };
|
|
32
|
+
es.onopen = () => { if (!active) banner.dismiss(); };
|
|
33
|
+
|
|
34
|
+
// --- click-to-copy button, prepended into the viewbar (left of the play btn) -
|
|
35
|
+
// Copies the SAME description an agent receives for a click, so the two paths match.
|
|
36
|
+
const viewbar = document.getElementById("viewbar");
|
|
37
|
+
let copyBtn = null;
|
|
38
|
+
if (viewbar) {
|
|
39
|
+
copyBtn = document.createElement("button");
|
|
40
|
+
copyBtn.id = "copy";
|
|
41
|
+
copyBtn.type = "button";
|
|
42
|
+
// Flat single-colour copy glyph (inherits the viewbar button's currentColor).
|
|
43
|
+
copyBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" aria-hidden="true"><rect x="5.5" y="5.5" width="8" height="8" rx="1.5"/><path d="M10.5 5.5V4A1.5 1.5 0 0 0 9 2.5H4A1.5 1.5 0 0 0 2.5 4V9A1.5 1.5 0 0 0 4 10.5H5.5"/></svg>';
|
|
44
|
+
copyBtn.title = "Click a detail to copy its agent description";
|
|
45
|
+
copyBtn.setAttribute("aria-label", "Copy a detail's agent description");
|
|
46
|
+
copyBtn.addEventListener("click", () => {
|
|
47
|
+
if (banner.isOpen()) { banner.dismiss(); return; } // toggle off
|
|
48
|
+
banner.request({
|
|
49
|
+
avatar: "📋",
|
|
50
|
+
label: "Copy to clipboard",
|
|
51
|
+
text: "Click on a detail to copy its agent description to the clipboard.",
|
|
52
|
+
onResolve: (selection) => {
|
|
53
|
+
navigator.clipboard?.writeText(formatSelection(selection, { style: "prompt" }));
|
|
54
|
+
banner.message("✓ Copied to clipboard");
|
|
55
|
+
setTimeout(() => banner.dismiss(), 1000);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
viewbar.prepend(copyBtn);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
detach: () => { es.close(); banner.detach(); copyBtn?.remove(); },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createPickRequestClient } from "./client.js";
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// A small chat-style prompt banner that arms the selection picker for one click.
|
|
2
|
+
// Shared by request-a-pick's two drivers (an agent asking via SSE, and the
|
|
3
|
+
// click-to-copy button) so they look and behave identically. The look (position,
|
|
4
|
+
// theme colours, slide-in animation) lives in app.css (#pf-pick-banner).
|
|
5
|
+
import { attachPicker } from "../selection/pick.js";
|
|
6
|
+
|
|
7
|
+
export function createPromptBanner({ viewer, part, getContext }) {
|
|
8
|
+
let onResolve = null; // called with the Selection on the next pick
|
|
9
|
+
let onDismiss = null; // called when the user closes the banner via ×
|
|
10
|
+
|
|
11
|
+
const banner = document.createElement("div");
|
|
12
|
+
banner.id = "pf-pick-banner";
|
|
13
|
+
banner.style.display = "none";
|
|
14
|
+
|
|
15
|
+
const row = document.createElement("div");
|
|
16
|
+
row.className = "pf-pick-row";
|
|
17
|
+
const avatar = document.createElement("div");
|
|
18
|
+
avatar.className = "pf-pick-avatar";
|
|
19
|
+
const msg = document.createElement("div");
|
|
20
|
+
msg.className = "pf-pick-msg";
|
|
21
|
+
const label = document.createElement("div");
|
|
22
|
+
label.className = "pf-pick-label";
|
|
23
|
+
const prompt = document.createElement("div");
|
|
24
|
+
prompt.className = "pf-pick-prompt";
|
|
25
|
+
msg.append(label, prompt);
|
|
26
|
+
row.append(avatar, msg);
|
|
27
|
+
|
|
28
|
+
const close = document.createElement("button");
|
|
29
|
+
close.id = "pf-pick-close";
|
|
30
|
+
close.type = "button";
|
|
31
|
+
close.textContent = "×";
|
|
32
|
+
close.setAttribute("aria-label", "Dismiss");
|
|
33
|
+
|
|
34
|
+
banner.append(row, close);
|
|
35
|
+
document.body.appendChild(banner);
|
|
36
|
+
|
|
37
|
+
const isOpen = () => banner.style.display !== "none";
|
|
38
|
+
|
|
39
|
+
function dismiss() {
|
|
40
|
+
onResolve = null;
|
|
41
|
+
onDismiss = null;
|
|
42
|
+
picker.setActive(false);
|
|
43
|
+
banner.style.display = "none";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const picker = attachPicker(viewer, {
|
|
47
|
+
part, getContext,
|
|
48
|
+
onPick: (selection) => {
|
|
49
|
+
const resolve = onResolve;
|
|
50
|
+
onResolve = null; // a pick resolves once; a stray click after is a no-op
|
|
51
|
+
picker.setActive(false); // consumer re-arms via request() if more clicks are wanted
|
|
52
|
+
resolve?.(selection);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
close.addEventListener("click", () => {
|
|
57
|
+
const dismissed = onDismiss;
|
|
58
|
+
dismiss();
|
|
59
|
+
dismissed?.();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Show a prompt and arm the picker for one click. Prompt/label text is rendered via
|
|
63
|
+
// textContent (never innerHTML) — it can carry agent-supplied strings.
|
|
64
|
+
function request({ avatar: glyph, label: text, text: body, onResolve: resolve = null, onDismiss: dismissed = null }) {
|
|
65
|
+
onResolve = resolve;
|
|
66
|
+
onDismiss = dismissed;
|
|
67
|
+
avatar.textContent = glyph;
|
|
68
|
+
label.textContent = text || "";
|
|
69
|
+
prompt.textContent = body;
|
|
70
|
+
banner.style.display = "block";
|
|
71
|
+
picker.setActive(true);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Show a transient note (error or confirmation) with no pending pick. Keeps the
|
|
75
|
+
// current avatar so a "✓ Copied" / "⚠ …" note stays in context.
|
|
76
|
+
function message(body) {
|
|
77
|
+
onResolve = null;
|
|
78
|
+
picker.setActive(false);
|
|
79
|
+
label.textContent = "";
|
|
80
|
+
prompt.textContent = body;
|
|
81
|
+
banner.style.display = "block";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
request, message, dismiss, isOpen,
|
|
86
|
+
detach: () => { picker.detach(); banner.remove(); },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// src/framework/pick-request/server.js
|
|
2
|
+
// The Node side of request-a-pick: an http+SSE server holding one active batch,
|
|
3
|
+
// a blocking CLI client (requestPicks), and CLI output formatting. 127.0.0.1 only.
|
|
4
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
5
|
+
import { createBatch, view, resolve, cancel, timeout, result } from "./batch.js";
|
|
6
|
+
import { formatSelection } from "../selection/format.js";
|
|
7
|
+
|
|
8
|
+
const json = (res, code, obj, origin) => {
|
|
9
|
+
res.writeHead(code, { "content-type": "application/json", "access-control-allow-origin": origin || "*" });
|
|
10
|
+
res.end(JSON.stringify(obj));
|
|
11
|
+
};
|
|
12
|
+
const readBody = (req) => new Promise((resolve_) => {
|
|
13
|
+
let b = "";
|
|
14
|
+
req.on("data", (c) => (b += c));
|
|
15
|
+
req.on("end", () => {
|
|
16
|
+
if (!b) { resolve_({}); return; }
|
|
17
|
+
try { resolve_(JSON.parse(b)); } catch { resolve_({ _parseError: true }); }
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export function createPickServer({ port = 4518, timeoutMs = 120000 } = {}) {
|
|
22
|
+
let batch = null; // the one active batch (or null)
|
|
23
|
+
let pending = null; // { res, timer } — the held POST /request response
|
|
24
|
+
const sseClients = new Set();
|
|
25
|
+
const allSockets = new Set(); // track every socket for forceful teardown
|
|
26
|
+
|
|
27
|
+
const sse = (event, data) => {
|
|
28
|
+
for (const res of sseClients) res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
29
|
+
};
|
|
30
|
+
const finish = () => { // resolve the held /request with the result
|
|
31
|
+
if (pending) {
|
|
32
|
+
clearTimeout(pending.timer);
|
|
33
|
+
json(pending.res, 200, result(batch));
|
|
34
|
+
pending = null;
|
|
35
|
+
}
|
|
36
|
+
sse("cleared", {});
|
|
37
|
+
batch = null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const server = createServer(async (req, res) => {
|
|
41
|
+
const origin = req.headers.origin;
|
|
42
|
+
if (req.method === "OPTIONS") {
|
|
43
|
+
res.writeHead(204, {
|
|
44
|
+
"access-control-allow-origin": origin || "*",
|
|
45
|
+
"access-control-allow-methods": "GET,POST,OPTIONS",
|
|
46
|
+
"access-control-allow-headers": "content-type",
|
|
47
|
+
});
|
|
48
|
+
return res.end();
|
|
49
|
+
}
|
|
50
|
+
const url = req.url.split("?")[0];
|
|
51
|
+
|
|
52
|
+
if (req.method === "POST" && url === "/request") {
|
|
53
|
+
if (batch) return json(res, 409, { status: "busy" }, origin);
|
|
54
|
+
const body = await readBody(req);
|
|
55
|
+
if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
|
|
56
|
+
if (!Array.isArray(body.prompts) || body.prompts.length === 0) {
|
|
57
|
+
return json(res, 400, { error: "prompts must be a non-empty array" }, origin);
|
|
58
|
+
}
|
|
59
|
+
batch = createBatch(body.prompts);
|
|
60
|
+
pending = { res, timer: setTimeout(() => { timeout(batch); finish(); }, timeoutMs) };
|
|
61
|
+
sse("prompt", view(batch));
|
|
62
|
+
return; // held open until finish()
|
|
63
|
+
}
|
|
64
|
+
if (req.method === "GET" && url === "/events") {
|
|
65
|
+
res.writeHead(200, {
|
|
66
|
+
"content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive",
|
|
67
|
+
"access-control-allow-origin": origin || "*",
|
|
68
|
+
});
|
|
69
|
+
res.write(": connected\n\n"); // SSE comment — flushes headers, makes fetch() resolve
|
|
70
|
+
sseClients.add(res);
|
|
71
|
+
if (batch) res.write(`event: prompt\ndata: ${JSON.stringify(view(batch))}\n\n`); // replay current
|
|
72
|
+
req.on("close", () => sseClients.delete(res));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (req.method === "POST" && url === "/resolve") {
|
|
76
|
+
const body = await readBody(req);
|
|
77
|
+
if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
|
|
78
|
+
const { id, index, selection } = body;
|
|
79
|
+
if (batch && id === batch.id) {
|
|
80
|
+
resolve(batch, index, selection);
|
|
81
|
+
if (view(batch).status === "collecting") sse("prompt", view(batch));
|
|
82
|
+
else finish();
|
|
83
|
+
}
|
|
84
|
+
return json(res, 200, { ok: true }, origin);
|
|
85
|
+
}
|
|
86
|
+
if (req.method === "POST" && url === "/cancel") {
|
|
87
|
+
const body = await readBody(req);
|
|
88
|
+
if (body._parseError) return json(res, 400, { error: "invalid JSON" }, origin);
|
|
89
|
+
const { id } = body;
|
|
90
|
+
if (batch && id === batch.id) { cancel(batch); finish(); }
|
|
91
|
+
return json(res, 200, { ok: true }, origin);
|
|
92
|
+
}
|
|
93
|
+
return json(res, 404, { error: "not found" }, origin);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
server.on("connection", (socket) => {
|
|
97
|
+
allSockets.add(socket);
|
|
98
|
+
socket.on("close", () => allSockets.delete(socket));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
start: () => new Promise((res_) => server.listen(port, "127.0.0.1", () => res_({ port: server.address().port }))),
|
|
103
|
+
stop: () => new Promise((res_) => {
|
|
104
|
+
// If a batch is active, cancel it and resolve the held /request cleanly
|
|
105
|
+
// so any awaiting requestPicks() gets a result instead of a socket hang-up.
|
|
106
|
+
if (batch) { cancel(batch); finish(); }
|
|
107
|
+
for (const c of sseClients) c.end();
|
|
108
|
+
server.close(() => res_());
|
|
109
|
+
// destroy any lingering sockets (SSE keep-alive) so server.close resolves
|
|
110
|
+
for (const s of allSockets) s.destroy();
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// CLI client: POST the prompts and await the held response (blocks until the batch
|
|
116
|
+
// reaches a terminal status server-side). Fails fast with a hint if nothing answers.
|
|
117
|
+
export function requestPicks({ port = 4518, host = "127.0.0.1", prompts }) {
|
|
118
|
+
return new Promise((resolve_, reject) => {
|
|
119
|
+
const payload = JSON.stringify({ prompts });
|
|
120
|
+
const req = httpRequest(
|
|
121
|
+
{ host, port, path: "/request", method: "POST", headers: { "content-type": "application/json", "content-length": Buffer.byteLength(payload) } },
|
|
122
|
+
(res) => {
|
|
123
|
+
let b = "";
|
|
124
|
+
res.on("data", (c) => (b += c));
|
|
125
|
+
res.on("end", () => {
|
|
126
|
+
try {
|
|
127
|
+
resolve_(JSON.parse(b));
|
|
128
|
+
} catch {
|
|
129
|
+
reject(new Error(`unexpected response from pick-server on ${host}:${port} (is the app open and \`partforge pick-serve\` running?)`));
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
req.on("error", (e) => reject(new Error(`could not reach pick-server on ${host}:${port} (is the app open and \`partforge pick-serve\` running?) — ${e.message}`)));
|
|
135
|
+
req.end(payload);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Human-readable CLI output: one summary line per pick, then the raw JSON to parse.
|
|
140
|
+
export function formatPickResult({ status, picks }) {
|
|
141
|
+
const safePicks = Array.isArray(picks) ? picks : [];
|
|
142
|
+
const lines = [`status: ${status} (${safePicks.length} pick${safePicks.length === 1 ? "" : "s"})`];
|
|
143
|
+
for (const { prompt, selection } of safePicks) {
|
|
144
|
+
let summary;
|
|
145
|
+
try {
|
|
146
|
+
if (selection && selection.point && selection.normal && selection.params) {
|
|
147
|
+
summary = formatSelection(selection, { style: "prompt" });
|
|
148
|
+
} else {
|
|
149
|
+
summary = selection?.subPart ?? JSON.stringify(selection);
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
summary = JSON.stringify(selection);
|
|
153
|
+
}
|
|
154
|
+
lines.push(`• "${prompt}" → ${summary}`);
|
|
155
|
+
}
|
|
156
|
+
lines.push("", JSON.stringify({ status, picks }, null, 2));
|
|
157
|
+
return lines.join("\n");
|
|
158
|
+
}
|