@womp/mcp-ui 0.1.14 → 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 +123 -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) {
|
|
@@ -215,15 +262,29 @@ const fromViewer = (event: MessageEvent, d: Design) =>
|
|
|
215
262
|
|
|
216
263
|
window.addEventListener("message", (event) => {
|
|
217
264
|
if (!design || !fromViewer(event, design)) return;
|
|
218
|
-
const msg = event.data as { type?: string; scripts?: unknown };
|
|
265
|
+
const msg = event.data as { type?: string; scripts?: unknown; messages?: unknown; url?: string; error?: string };
|
|
219
266
|
if (msg?.type === "kakapo:ready") {
|
|
220
267
|
viewerReady = true;
|
|
268
|
+
viewerTarget = event.origin === "null" ? "*" : event.origin;
|
|
269
|
+
viewerCanSwap = Array.isArray(msg.messages) && msg.messages.includes("kakapo:setScene");
|
|
221
270
|
clearTimeout(readyTimer);
|
|
222
271
|
viewerParams = flatten(msg.scripts);
|
|
223
272
|
setStatus("");
|
|
224
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("");
|
|
225
285
|
} else if (msg?.type === "kakapo:params") {
|
|
226
286
|
onViewerParams(flatten(msg.scripts));
|
|
287
|
+
for (const done of paramsWaiters.splice(0)) done();
|
|
227
288
|
} else if (msg?.type === "kakapo:print") {
|
|
228
289
|
// The viewer's own Print button is our Order.
|
|
229
290
|
onViewerParams(flatten(msg.scripts));
|
|
@@ -234,10 +295,23 @@ window.addEventListener("message", (event) => {
|
|
|
234
295
|
// The viewer has no change event, so read its knobs on an interval while visible.
|
|
235
296
|
window.setInterval(() => {
|
|
236
297
|
if (!design || !viewerReady || document.visibilityState !== "visible") return;
|
|
237
|
-
|
|
238
|
-
viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, "*");
|
|
298
|
+
viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, viewerTarget);
|
|
239
299
|
}, 1_500);
|
|
240
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
|
+
|
|
241
315
|
function onViewerParams(params: Record<string, number>) {
|
|
242
316
|
if (!viewerParams) {
|
|
243
317
|
viewerParams = params;
|
|
@@ -254,11 +328,17 @@ function onViewerParams(params: Record<string, number>) {
|
|
|
254
328
|
|
|
255
329
|
// ---- saving -------------------------------------------------------------------
|
|
256
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
|
+
|
|
257
337
|
/**
|
|
258
|
-
* Save the viewer's knobs (and a part's colours, if they
|
|
259
|
-
* 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.
|
|
260
340
|
*/
|
|
261
|
-
async function flushSync(
|
|
341
|
+
async function flushSync(finish?: Finish) {
|
|
262
342
|
clearTimeout(syncTimer);
|
|
263
343
|
syncTimer = undefined;
|
|
264
344
|
while (syncing) await syncing;
|
|
@@ -271,7 +351,7 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
|
|
|
271
351
|
baseRev,
|
|
272
352
|
writer,
|
|
273
353
|
params: viewerParams ?? {},
|
|
274
|
-
...
|
|
354
|
+
...finish,
|
|
275
355
|
});
|
|
276
356
|
const next = res.structuredContent as Design | undefined;
|
|
277
357
|
if (res.isError || !next) {
|
|
@@ -285,11 +365,11 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
|
|
|
285
365
|
return;
|
|
286
366
|
}
|
|
287
367
|
// Anything besides our own write since baseRev (a chat change merged in)
|
|
288
|
-
// means the viewer is behind. So
|
|
368
|
+
// means the viewer is behind. So does a colour, which lives in the scene file.
|
|
289
369
|
const merged = next.rev > baseRev + 1;
|
|
290
|
-
if (merged ||
|
|
370
|
+
if (merged || finish) void loadViewer(merged ? "Merged with a change from the chat…" : "Applying colour…");
|
|
291
371
|
else loadedRev = next.rev;
|
|
292
|
-
if (!merged && !
|
|
372
|
+
if (!merged && !finish) setStatus("Saved", 1_500);
|
|
293
373
|
void pushModelContext(app, contextLine(next));
|
|
294
374
|
} catch {
|
|
295
375
|
setStatus("Couldn't save that change.");
|
|
@@ -302,14 +382,15 @@ async function flushSync(colors?: Partial<Record<Slot, string[]>>) {
|
|
|
302
382
|
}
|
|
303
383
|
}
|
|
304
384
|
|
|
305
|
-
/** "coral", or "coral → violet gradient" */
|
|
306
|
-
function paintText(paint: string[]) {
|
|
307
|
-
|
|
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}°` : ""}`;
|
|
308
389
|
}
|
|
309
390
|
|
|
310
391
|
const contextLine = (d: Design) =>
|
|
311
392
|
`The user adjusted their camera in the card. Design ${d.designId}, revision ${d.rev}: ` +
|
|
312
|
-
`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)}; ` +
|
|
313
394
|
d.options.map((o) => `${o.label} ${o.display}`).join(", ") +
|
|
314
395
|
".";
|
|
315
396
|
|
|
@@ -389,7 +470,8 @@ function render() {
|
|
|
389
470
|
$("price").textContent = `Full-colour print · ${design.price.label}`;
|
|
390
471
|
$("order-summary").textContent =
|
|
391
472
|
`Your camera, printed in full colour: ${design.price.label}. ` +
|
|
392
|
-
`Shell ${paintText(design.colors.shell
|
|
473
|
+
`Shell ${paintText(design.colors.shell, design.gradientAngles.shell)}, ` +
|
|
474
|
+
`button ${paintText(design.colors.button, design.gradientAngles.button)}.`;
|
|
393
475
|
if (!fallback.hidden) showFallback();
|
|
394
476
|
}
|
|
395
477
|
|
|
@@ -424,7 +506,12 @@ function renderStops(slot: Slot, paint: string[]) {
|
|
|
424
506
|
const remove = iconButton("−", `${slot}: remove the selected gradient colour`, paint.length <= 2, () => {
|
|
425
507
|
void setPaint(slot, paint.filter((_, i) => i !== activeStop[slot]));
|
|
426
508
|
});
|
|
427
|
-
|
|
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);
|
|
428
515
|
}
|
|
429
516
|
|
|
430
517
|
function iconButton(text: string, label: string, disabled: boolean, onClick: () => void) {
|
|
@@ -468,7 +555,18 @@ async function setPaint(slot: Slot, paint: string[]) {
|
|
|
468
555
|
design = { ...design, colors: { ...design.colors, [slot]: paint } };
|
|
469
556
|
render();
|
|
470
557
|
setStatus("Saving…");
|
|
471
|
-
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 } });
|
|
472
570
|
}
|
|
473
571
|
|
|
474
572
|
for (const slot of SLOTS) {
|
|
@@ -536,6 +634,7 @@ async function placeOrder() {
|
|
|
536
634
|
setStatus("Placing your order…");
|
|
537
635
|
try {
|
|
538
636
|
// Order what is on screen: save any pending knob turn first.
|
|
637
|
+
await freshViewerParams();
|
|
539
638
|
if (syncTimer !== undefined || syncing) await flushSync();
|
|
540
639
|
const res = await callTool(app, "place_order", { designId: design.designId, rev: design.rev, writer, contact });
|
|
541
640
|
const order = res.structuredContent as OrderResult | undefined;
|