@womp/mcp-ui 0.1.13 → 0.1.15
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/index.js +8 -8
- package/dist/openai-camera/index.html +6 -6
- package/package.json +1 -1
- package/src/openai-camera/main.ts +133 -24
package/package.json
CHANGED
|
@@ -13,13 +13,16 @@ import { $, callTool, createCard, pushModelContext, toggleFullscreen } from "../
|
|
|
13
13
|
* is saved with `sync_camera_design` (app-only). The viewer already shows it;
|
|
14
14
|
* no reload.
|
|
15
15
|
* - The model calls `update_camera_design`. That renders no card, so this card
|
|
16
|
-
* polls `get_camera_design` and
|
|
17
|
-
* for any reason other than its own write.
|
|
16
|
+
* polls `get_camera_design` and moves the viewer to the new revision when it
|
|
17
|
+
* changed for any reason other than its own write.
|
|
18
18
|
*
|
|
19
19
|
* Colours are not viewer knobs (they live in the scene's materials), so a swatch
|
|
20
|
-
* saves and
|
|
21
|
-
*
|
|
22
|
-
*
|
|
20
|
+
* saves and moves the viewer to the new revision. A viewer that says it can
|
|
21
|
+
* (`kakapo:setScene` in its ready message) swaps the revision in place, keeping
|
|
22
|
+
* its camera and re-modelling only what changed; any other is reloaded. Each part
|
|
23
|
+
* is solid or a gradient of 2-3 colours, turned 45° at a time by the arrow; the
|
|
24
|
+
* colours come from the palette, the native colour picker, or the eyedropper
|
|
25
|
+
* where the browser has one.
|
|
23
26
|
*
|
|
24
27
|
* Ordering happens in the card: Order (or a `checkout_camera` result, which
|
|
25
28
|
* arrives with mode "checkout") opens the shipping form, and the form submits to
|
|
@@ -43,6 +46,8 @@ interface Design {
|
|
|
43
46
|
options: DesignOption[];
|
|
44
47
|
/** Each part's colours: one is solid, more is a gradient. */
|
|
45
48
|
colors: Record<Slot, string[]>;
|
|
49
|
+
/** Which way each part's gradient runs, in degrees. */
|
|
50
|
+
gradientAngles: Record<Slot, number>;
|
|
46
51
|
palette: { name: string; hex: string }[];
|
|
47
52
|
price: { amount: number; currency: string; label: string };
|
|
48
53
|
lastChange?: { source: string; writer: string; fields: string[] };
|
|
@@ -61,6 +66,16 @@ type View = "design" | "order" | "done";
|
|
|
61
66
|
/** The most colours a gradient takes (the server holds the same limit). */
|
|
62
67
|
const MAX_COLORS = 3;
|
|
63
68
|
|
|
69
|
+
/** An arrow pointing right (Lucide `arrow-right`, ISC). */
|
|
70
|
+
const ARROW =
|
|
71
|
+
'<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 12h14"/><path d="m13 6 6 6-6 6"/></svg>';
|
|
72
|
+
/**
|
|
73
|
+
* Where each part's arrow points at 0°, in CSS degrees: the shell runs top to
|
|
74
|
+
* bottom, the button left to right. In the default view both turn anticlockwise
|
|
75
|
+
* as the angle grows, so the arrow turns by minus the angle.
|
|
76
|
+
*/
|
|
77
|
+
const ARROW_AT_0: Record<Slot, number> = { shell: 90, button: 0 };
|
|
78
|
+
|
|
64
79
|
/** Chromium's EyeDropper API; absent elsewhere. */
|
|
65
80
|
type EyeDropperCtor = new () => { open(): Promise<{ sRGBHex: string }> };
|
|
66
81
|
const EyeDropper = (window as Window & { EyeDropper?: EyeDropperCtor }).EyeDropper;
|
|
@@ -79,6 +94,17 @@ let design: Design | undefined;
|
|
|
79
94
|
/** Revision the viewer is showing. */
|
|
80
95
|
let loadedRev = 0;
|
|
81
96
|
let viewerReady = false;
|
|
97
|
+
/** The design the viewer frame was loaded for. */
|
|
98
|
+
let viewerDesignId: string | undefined;
|
|
99
|
+
/** Whether the viewer takes a new revision by message instead of a reload. */
|
|
100
|
+
let viewerCanSwap = false;
|
|
101
|
+
/** Where to post to the viewer: its origin, or "*" when it speaks as "null" (an opaque host). */
|
|
102
|
+
let viewerTarget = "*";
|
|
103
|
+
/** The scene URL a swap is waiting on, and the reload that stands in if it never lands. */
|
|
104
|
+
let pendingSwap: string | undefined;
|
|
105
|
+
let swapTimer: number | undefined;
|
|
106
|
+
/** Waiting for the viewer's next params message. */
|
|
107
|
+
const paramsWaiters: (() => void)[] = [];
|
|
82
108
|
/** Last knob values read back from the viewer. */
|
|
83
109
|
let viewerParams: Record<string, number> | undefined;
|
|
84
110
|
let webgpu: boolean | undefined;
|
|
@@ -153,8 +179,29 @@ async function loadViewer(note?: string) {
|
|
|
153
179
|
setStatus("");
|
|
154
180
|
return;
|
|
155
181
|
}
|
|
182
|
+
if (viewerReady && viewerCanSwap && viewerDesignId === design.designId) swapScene(design.sceneUrl, note);
|
|
183
|
+
else reloadViewer(note);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Hand the viewer another revision of the design on screen. */
|
|
187
|
+
function swapScene(url: string, note?: string) {
|
|
188
|
+
pendingSwap = url;
|
|
189
|
+
setStatus(note ?? "Updating the 3D view…");
|
|
190
|
+
viewer.contentWindow?.postMessage({ type: "kakapo:setScene", url }, viewerTarget);
|
|
191
|
+
clearTimeout(swapTimer);
|
|
192
|
+
swapTimer = window.setTimeout(() => {
|
|
193
|
+
if (pendingSwap === url) reloadViewer(note);
|
|
194
|
+
}, 15_000);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function reloadViewer(note?: string) {
|
|
198
|
+
if (!design) return;
|
|
156
199
|
viewerReady = false;
|
|
200
|
+
viewerCanSwap = false;
|
|
157
201
|
viewerParams = undefined;
|
|
202
|
+
viewerDesignId = design.designId;
|
|
203
|
+
pendingSwap = undefined;
|
|
204
|
+
clearTimeout(swapTimer);
|
|
158
205
|
fallback.hidden = true;
|
|
159
206
|
viewer.hidden = false;
|
|
160
207
|
viewer.src = design.viewerUrl;
|
|
@@ -177,8 +224,8 @@ function showFallback() {
|
|
|
177
224
|
"The 3D preview needs WebGPU, which this browser or device doesn't offer. You can still change the design here or in chat, and order it.";
|
|
178
225
|
const list = document.createElement("dl");
|
|
179
226
|
const rows: [string, string][] = [
|
|
180
|
-
["Shell colour", paintText(design.colors.shell)],
|
|
181
|
-
["Button colour", paintText(design.colors.button)],
|
|
227
|
+
["Shell colour", paintText(design.colors.shell, design.gradientAngles.shell)],
|
|
228
|
+
["Button colour", paintText(design.colors.button, design.gradientAngles.button)],
|
|
182
229
|
...design.options.map((o) => [o.label, o.display] as [string, string]),
|
|
183
230
|
];
|
|
184
231
|
for (const [k, v] of rows) {
|
|
@@ -204,17 +251,40 @@ function flatten(scripts: unknown): Record<string, number> {
|
|
|
204
251
|
return out;
|
|
205
252
|
}
|
|
206
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Where the viewer's messages come from. A host that frames the card on an
|
|
256
|
+
* opaque origin (a sandbox without allow-same-origin) passes that sandbox down
|
|
257
|
+
* to the viewer's frame, which then speaks as "null" — still our frame, as
|
|
258
|
+
* `event.source` proves.
|
|
259
|
+
*/
|
|
260
|
+
const fromViewer = (event: MessageEvent, d: Design) =>
|
|
261
|
+
event.source === viewer.contentWindow && (event.origin === d.viewerOrigin || event.origin === "null");
|
|
262
|
+
|
|
207
263
|
window.addEventListener("message", (event) => {
|
|
208
|
-
if (!design || event
|
|
209
|
-
const msg = event.data as { type?: string; scripts?: unknown };
|
|
264
|
+
if (!design || !fromViewer(event, design)) return;
|
|
265
|
+
const msg = event.data as { type?: string; scripts?: unknown; messages?: unknown; url?: string; error?: string };
|
|
210
266
|
if (msg?.type === "kakapo:ready") {
|
|
211
267
|
viewerReady = true;
|
|
268
|
+
viewerTarget = event.origin === "null" ? "*" : event.origin;
|
|
269
|
+
viewerCanSwap = Array.isArray(msg.messages) && msg.messages.includes("kakapo:setScene");
|
|
212
270
|
clearTimeout(readyTimer);
|
|
213
271
|
viewerParams = flatten(msg.scripts);
|
|
214
272
|
setStatus("");
|
|
215
273
|
report("viewer-ready");
|
|
274
|
+
} else if (msg?.type === "kakapo:sceneSet") {
|
|
275
|
+
if (msg.url !== pendingSwap) return; // an earlier swap, answering late
|
|
276
|
+
pendingSwap = undefined;
|
|
277
|
+
clearTimeout(swapTimer);
|
|
278
|
+
if (msg.error) {
|
|
279
|
+
reloadViewer();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
// The revision's knob values are the viewer's now: the baseline for spotting the user's next change.
|
|
283
|
+
viewerParams = flatten(msg.scripts);
|
|
284
|
+
setStatus("");
|
|
216
285
|
} else if (msg?.type === "kakapo:params") {
|
|
217
286
|
onViewerParams(flatten(msg.scripts));
|
|
287
|
+
for (const done of paramsWaiters.splice(0)) done();
|
|
218
288
|
} else if (msg?.type === "kakapo:print") {
|
|
219
289
|
// The viewer's own Print button is our Order.
|
|
220
290
|
onViewerParams(flatten(msg.scripts));
|
|
@@ -225,9 +295,23 @@ window.addEventListener("message", (event) => {
|
|
|
225
295
|
// The viewer has no change event, so read its knobs on an interval while visible.
|
|
226
296
|
window.setInterval(() => {
|
|
227
297
|
if (!design || !viewerReady || document.visibilityState !== "visible") return;
|
|
228
|
-
viewer.contentWindow?.postMessage({ type: "kakapo:getParams" },
|
|
298
|
+
viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, viewerTarget);
|
|
229
299
|
}, 1_500);
|
|
230
300
|
|
|
301
|
+
/**
|
|
302
|
+
* The viewer's knobs as they are now rather than at the last poll, for a save
|
|
303
|
+
* the user just asked for: one made with values up to 1.5s old would put back
|
|
304
|
+
* a slider they had just moved.
|
|
305
|
+
*/
|
|
306
|
+
function freshViewerParams(): Promise<void> {
|
|
307
|
+
if (!viewerReady) return Promise.resolve();
|
|
308
|
+
return new Promise((resolve) => {
|
|
309
|
+
paramsWaiters.push(resolve);
|
|
310
|
+
viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, viewerTarget);
|
|
311
|
+
window.setTimeout(resolve, 500);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
231
315
|
function onViewerParams(params: Record<string, number>) {
|
|
232
316
|
if (!viewerParams) {
|
|
233
317
|
viewerParams = params;
|
|
@@ -244,11 +328,17 @@ function onViewerParams(params: Record<string, number>) {
|
|
|
244
328
|
|
|
245
329
|
// ---- saving -------------------------------------------------------------------
|
|
246
330
|
|
|
331
|
+
/** What the card changes besides the viewer's knobs. */
|
|
332
|
+
interface Finish {
|
|
333
|
+
colors?: Partial<Record<Slot, string[]>>;
|
|
334
|
+
gradientAngles?: Partial<Record<Slot, number>>;
|
|
335
|
+
}
|
|
336
|
+
|
|
247
337
|
/**
|
|
248
|
-
* Save the viewer's knobs (and a part's colours, if they
|
|
249
|
-
* so two quick changes cannot race each other's baseRev.
|
|
338
|
+
* Save the viewer's knobs (and a part's colours or gradient angle, if they
|
|
339
|
+
* changed). Serialised, so two quick changes cannot race each other's baseRev.
|
|
250
340
|
*/
|
|
251
|
-
async function flushSync(
|
|
341
|
+
async function flushSync(finish?: Finish) {
|
|
252
342
|
clearTimeout(syncTimer);
|
|
253
343
|
syncTimer = undefined;
|
|
254
344
|
while (syncing) await syncing;
|
|
@@ -261,7 +351,7 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
|
|
|
261
351
|
baseRev,
|
|
262
352
|
writer,
|
|
263
353
|
params: viewerParams ?? {},
|
|
264
|
-
...
|
|
354
|
+
...finish,
|
|
265
355
|
});
|
|
266
356
|
const next = res.structuredContent as Design | undefined;
|
|
267
357
|
if (res.isError || !next) {
|
|
@@ -275,11 +365,11 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
|
|
|
275
365
|
return;
|
|
276
366
|
}
|
|
277
367
|
// Anything besides our own write since baseRev (a chat change merged in)
|
|
278
|
-
// means the viewer is behind. So
|
|
368
|
+
// means the viewer is behind. So does a colour, which lives in the scene file.
|
|
279
369
|
const merged = next.rev > baseRev + 1;
|
|
280
|
-
if (merged ||
|
|
370
|
+
if (merged || finish) void loadViewer(merged ? "Merged with a change from the chat…" : "Applying colour…");
|
|
281
371
|
else loadedRev = next.rev;
|
|
282
|
-
if (!merged && !
|
|
372
|
+
if (!merged && !finish) setStatus("Saved", 1_500);
|
|
283
373
|
void pushModelContext(app, contextLine(next));
|
|
284
374
|
} catch {
|
|
285
375
|
setStatus("Couldn't save that change.");
|
|
@@ -292,14 +382,15 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
|
|
|
292
382
|
}
|
|
293
383
|
}
|
|
294
384
|
|
|
295
|
-
/** "coral", or "coral → violet gradient" */
|
|
296
|
-
function paintText(paint: string[]) {
|
|
297
|
-
|
|
385
|
+
/** "coral", "coral → violet gradient", or "coral → violet gradient turned 45°" */
|
|
386
|
+
function paintText(paint: string[], angle = 0) {
|
|
387
|
+
if (paint.length < 2) return paint[0];
|
|
388
|
+
return `${paint.join(" → ")} gradient${angle ? ` turned ${angle}°` : ""}`;
|
|
298
389
|
}
|
|
299
390
|
|
|
300
391
|
const contextLine = (d: Design) =>
|
|
301
392
|
`The user adjusted their camera in the card. Design ${d.designId}, revision ${d.rev}: ` +
|
|
302
|
-
`shell ${paintText(d.colors.shell)}, button ${paintText(d.colors.button)}; ` +
|
|
393
|
+
`shell ${paintText(d.colors.shell, d.gradientAngles.shell)}, button ${paintText(d.colors.button, d.gradientAngles.button)}; ` +
|
|
303
394
|
d.options.map((o) => `${o.label} ${o.display}`).join(", ") +
|
|
304
395
|
".";
|
|
305
396
|
|
|
@@ -379,7 +470,8 @@ function render() {
|
|
|
379
470
|
$("price").textContent = `Full-colour print · ${design.price.label}`;
|
|
380
471
|
$("order-summary").textContent =
|
|
381
472
|
`Your camera, printed in full colour: ${design.price.label}. ` +
|
|
382
|
-
`Shell ${paintText(design.colors.shell
|
|
473
|
+
`Shell ${paintText(design.colors.shell, design.gradientAngles.shell)}, ` +
|
|
474
|
+
`button ${paintText(design.colors.button, design.gradientAngles.button)}.`;
|
|
383
475
|
if (!fallback.hidden) showFallback();
|
|
384
476
|
}
|
|
385
477
|
|
|
@@ -414,7 +506,12 @@ function renderStops(slot: Slot, paint: string[]) {
|
|
|
414
506
|
const remove = iconButton("−", `${slot}: remove the selected gradient colour`, paint.length <= 2, () => {
|
|
415
507
|
void setPaint(slot, paint.filter((_, i) => i !== activeStop[slot]));
|
|
416
508
|
});
|
|
417
|
-
|
|
509
|
+
const angle = design?.gradientAngles[slot] ?? 0;
|
|
510
|
+
const turn = iconButton("", `${slot}: turn the gradient 45° (now ${angle}°)`, false, () => void setAngle(slot, (angle + 45) % 360));
|
|
511
|
+
// The arrow points the way the colours run, first to last, as the part sits in the default view.
|
|
512
|
+
turn.innerHTML = ARROW;
|
|
513
|
+
turn.style.transform = `rotate(${ARROW_AT_0[slot] - angle}deg)`;
|
|
514
|
+
box.append(turn, track, add, remove);
|
|
418
515
|
}
|
|
419
516
|
|
|
420
517
|
function iconButton(text: string, label: string, disabled: boolean, onClick: () => void) {
|
|
@@ -458,7 +555,18 @@ async function setPaint(slot: Slot, paint: string[]) {
|
|
|
458
555
|
design = { ...design, colors: { ...design.colors, [slot]: paint } };
|
|
459
556
|
render();
|
|
460
557
|
setStatus("Saving…");
|
|
461
|
-
await
|
|
558
|
+
await freshViewerParams();
|
|
559
|
+
await flushSync({ colors: { [slot]: paint } });
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
async function setAngle(slot: Slot, angle: number) {
|
|
563
|
+
if (!design || design.gradientAngles[slot] === angle) return;
|
|
564
|
+
lastActivity = Date.now();
|
|
565
|
+
design = { ...design, gradientAngles: { ...design.gradientAngles, [slot]: angle } };
|
|
566
|
+
render();
|
|
567
|
+
setStatus("Saving…");
|
|
568
|
+
await freshViewerParams();
|
|
569
|
+
await flushSync({ gradientAngles: { [slot]: angle } });
|
|
462
570
|
}
|
|
463
571
|
|
|
464
572
|
for (const slot of SLOTS) {
|
|
@@ -526,6 +634,7 @@ async function placeOrder() {
|
|
|
526
634
|
setStatus("Placing your order…");
|
|
527
635
|
try {
|
|
528
636
|
// Order what is on screen: save any pending knob turn first.
|
|
637
|
+
await freshViewerParams();
|
|
529
638
|
if (syncTimer !== undefined || syncing) await flushSync();
|
|
530
639
|
const res = await callTool(app, "place_order", { designId: design.designId, rev: design.rev, writer, contact });
|
|
531
640
|
const order = res.structuredContent as OrderResult | undefined;
|