@womp/mcp-ui 0.1.3 → 0.1.4

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/src/print/main.ts CHANGED
@@ -1,13 +1,23 @@
1
- import { $, createCard, openLink, type GenerationResult } from "../shared/bridge";
1
+ import { $, callTool, createCard, openLink, type GenerationResult } from "../shared/bridge";
2
2
 
3
3
  /**
4
- * The print card — a quote, and (after prepare_print) the way out to checkout.
4
+ * The print card — a live configurator, and the way out to checkout.
5
5
  *
6
- * Deliberately has NO payment surface: the button opens womp.com's print flow
7
- * via `ui/open-link`, where address, shipping and payment run with every gate
8
- * the web app already has. The card renders what estimate_print /
9
- * prepare_print returned; it never calls a tool itself, so unlike the mesh
10
- * card it needs no app-callable visibility anywhere.
6
+ * The whole point (why this is not a static receipt): the user turns the knobs
7
+ * — material, size, quantity — and the card re-quotes THROUGH THE HOST by
8
+ * calling `estimate_print` itself, so tuning a print never round-trips through
9
+ * the conversation. The model seeds the starting settings simply by calling
10
+ * the tool with them: the host announces the call's arguments (`ontoolinput`)
11
+ * before any result exists, and the controls initialize from that.
12
+ *
13
+ * Deliberately NO payment surface: "Continue on womp.com" calls
14
+ * `prepare_print` (creates a free project) and opens the returned URL via
15
+ * `ui/open-link` — address, shipping and payment all run in the browser flow
16
+ * with every gate the web app already has.
17
+ *
18
+ * Static mode: a result that already carries `printUrl` (the model called
19
+ * prepare_print directly) renders as a receipt — no controls, the button just
20
+ * opens the link.
11
21
  */
12
22
 
13
23
  /** One priced variant, as swan-backend's QUOTE_ROW_SCHEMA emits it. */
@@ -22,87 +32,221 @@ interface QuoteRow {
22
32
  interface PrintQuoteResult {
23
33
  material?: string;
24
34
  quantity?: number;
35
+ longestSideCm?: number;
25
36
  dimensionsCm?: { width: number; height: number; depth: number };
26
37
  solid?: QuoteRow;
27
38
  hollow?: QuoteRow;
28
39
  callerIsPro?: boolean;
40
+ /** Which mesh was quoted — what re-quotes and prepare_print are keyed on. */
41
+ source?: { generationId?: number; glbUrl?: string };
42
+ /** Present on estimate results only — populates the material picker. */
43
+ materials?: { id: string }[];
29
44
  projectId?: number;
30
- /** Present on prepare_print only — what the CTA opens. */
45
+ /** Present on prepare_print results only. */
31
46
  printUrl?: string;
32
47
  }
33
48
 
34
49
  const usd = (n: number) => `$${n.toFixed(2)}`;
35
50
 
36
- const setStatus = (text: string) => {
37
- $("status").textContent = text;
38
- };
51
+ const materialSelect = $("material") as HTMLSelectElement;
52
+ const longestInput = $("longest") as HTMLInputElement;
53
+ const quantityInput = $("quantity") as HTMLInputElement;
54
+ const orderBtn = $("order") as HTMLButtonElement;
39
55
 
40
- function render(data: PrintQuoteResult) {
41
- $("card").classList.remove("measuring");
56
+ /** The mesh being quoted, and whether the card is a receipt or a configurator. */
57
+ let source: { generationId?: number; glbUrl?: string } | undefined;
58
+ let staticPrintUrl: string | undefined;
59
+ /** Monotonic guard: a slow older quote must never overwrite a newer one. */
60
+ let quoteSeq = 0;
61
+ let debounceTimer: number | undefined;
42
62
 
43
- if (data.material) {
44
- $("material").textContent = `${data.quantity && data.quantity > 1 ? `${data.quantity}x ` : ""}${data.material}`;
45
- }
63
+ function setStatus(text: string, isError = false) {
64
+ const el = $("status");
65
+ el.textContent = text;
66
+ el.classList.toggle("error", isError);
67
+ }
68
+
69
+ function renderQuote(data: PrintQuoteResult) {
70
+ $("card").classList.remove("quoting");
46
71
 
47
72
  const d = data.dimensionsCm;
48
- if (d) {
49
- $("size").textContent = `${d.width.toFixed(1)} x ${d.height.toFixed(1)} x ${d.depth.toFixed(1)} cm`;
50
- }
73
+ if (d) $("size").textContent = `${d.width.toFixed(1)} x ${d.height.toFixed(1)} x ${d.depth.toFixed(1)} cm`;
51
74
 
52
75
  const solid = data.solid;
53
- if (solid) {
54
- const price = $("price");
55
- price.textContent = "";
56
- const amount = document.createElement("span");
57
- amount.className = "amount";
58
- amount.textContent = usd(solid.unitPrice);
59
- price.append(amount);
60
- if (solid.discount > 0) {
61
- const was = document.createElement("span");
62
- was.className = "was";
63
- was.textContent = usd(solid.salePrice);
64
- price.append(was);
65
- }
66
- const per = document.createElement("span");
67
- per.className = "per";
68
- per.textContent = "per unit";
69
- price.append(per);
70
-
71
- const rows = $("rows");
72
- rows.textContent = "";
73
- if (data.hollow) {
74
- const hollow = document.createElement("div");
75
- hollow.textContent = `hollow (Pro): ${usd(data.hollow.unitPrice)} per unit${data.callerIsPro ? "" : " — requires a subscription"}`;
76
- rows.append(hollow);
76
+ if (!solid) return;
77
+
78
+ const price = $("price");
79
+ price.textContent = "";
80
+ const amount = document.createElement("span");
81
+ amount.className = "amount";
82
+ amount.textContent = usd(solid.unitPrice);
83
+ price.append(amount);
84
+ if (solid.discount > 0) {
85
+ const was = document.createElement("span");
86
+ was.className = "was";
87
+ was.textContent = usd(solid.salePrice);
88
+ price.append(was);
89
+ }
90
+ const per = document.createElement("span");
91
+ per.className = "per";
92
+ per.textContent = data.quantity && data.quantity > 1 ? `per unit x ${data.quantity}` : "per unit";
93
+ price.append(per);
94
+
95
+ const rows = $("rows");
96
+ rows.textContent = "";
97
+ if (data.hollow) {
98
+ const hollow = document.createElement("div");
99
+ hollow.textContent = `hollow (Pro): ${usd(data.hollow.unitPrice)} per unit${data.callerIsPro ? "" : " — requires a subscription"}`;
100
+ rows.append(hollow);
101
+ }
102
+ const days = document.createElement("div");
103
+ days.textContent = `~${solid.productionDays} production day${solid.productionDays === 1 ? "" : "s"}`;
104
+ rows.append(days);
105
+
106
+ $("note").hidden = false;
107
+ orderBtn.hidden = false;
108
+ orderBtn.disabled = false;
109
+ setStatus("");
110
+ }
111
+
112
+ /** Fill the controls from settings without firing their change handlers. */
113
+ function syncControls(data: PrintQuoteResult) {
114
+ if (data.material) {
115
+ // The picker may not exist yet on the very first input announcement; the
116
+ // result's materials list (below) is what populates it.
117
+ if (![...materialSelect.options].some((o) => o.value === data.material)) {
118
+ const opt = document.createElement("option");
119
+ opt.value = data.material;
120
+ opt.textContent = data.material;
121
+ materialSelect.append(opt);
77
122
  }
78
- const days = document.createElement("div");
79
- days.textContent = `~${solid.productionDays} production day${solid.productionDays === 1 ? "" : "s"}`;
80
- rows.append(days);
123
+ materialSelect.value = data.material;
124
+ }
125
+ if (typeof data.longestSideCm === "number") longestInput.value = String(Math.round(data.longestSideCm * 10) / 10);
126
+ if (typeof data.quantity === "number") quantityInput.value = String(data.quantity);
127
+ }
128
+
129
+ function populateMaterials(materials: { id: string }[], selected?: string) {
130
+ materialSelect.textContent = "";
131
+ for (const m of materials) {
132
+ const opt = document.createElement("option");
133
+ opt.value = m.id;
134
+ opt.textContent = m.id;
135
+ materialSelect.append(opt);
136
+ }
137
+ if (selected) materialSelect.value = selected;
138
+ }
81
139
 
82
- $("note").hidden = false;
83
- setStatus("");
140
+ function currentSettings() {
141
+ return {
142
+ ...(source?.generationId ? { generationId: source.generationId } : {}),
143
+ ...(source?.glbUrl ? { glbUrl: source.glbUrl } : {}),
144
+ material: materialSelect.value,
145
+ quantity: Math.min(100, Math.max(1, Math.round(Number(quantityInput.value) || 1))),
146
+ longestSideCm: Math.min(60, Math.max(2, Number(longestInput.value) || 10)),
147
+ };
148
+ }
149
+
150
+ async function requote() {
151
+ if (!source || staticPrintUrl) return;
152
+ const seq = ++quoteSeq;
153
+ $("card").classList.add("quoting");
154
+ orderBtn.disabled = true;
155
+ setStatus("Updating price…");
156
+ try {
157
+ const res = await callTool(card.app, "estimate_print", currentSettings());
158
+ if (seq !== quoteSeq) return; // superseded by a newer knob turn
159
+ if (res.isError) {
160
+ const text = (res.content as any[])?.find((c) => c.type === "text")?.text || "Could not update the quote.";
161
+ $("card").classList.remove("quoting");
162
+ orderBtn.disabled = false;
163
+ setStatus(String(text), true);
164
+ return;
165
+ }
166
+ renderQuote((res.structuredContent || {}) as PrintQuoteResult);
167
+ } catch {
168
+ if (seq !== quoteSeq) return;
169
+ $("card").classList.remove("quoting");
170
+ orderBtn.disabled = false;
171
+ setStatus("Could not update the quote — check the connection and try again.", true);
84
172
  }
173
+ }
85
174
 
86
- const order = $("order");
87
- order.hidden = !data.printUrl;
175
+ function scheduleRequote() {
176
+ // Same idea as the web print panel's debounce: a slider being dragged must
177
+ // not fire an engine estimate per pixel.
178
+ if (debounceTimer !== undefined) clearTimeout(debounceTimer);
179
+ debounceTimer = window.setTimeout(() => void requote(), 600);
88
180
  }
89
181
 
182
+ materialSelect.addEventListener("change", scheduleRequote);
183
+ longestInput.addEventListener("change", scheduleRequote);
184
+ quantityInput.addEventListener("change", scheduleRequote);
185
+
90
186
  const card = createCard("Womp print", {
91
187
  onTimeout: () => setStatus("Still measuring — ask me to check on it."),
188
+ onInput: (args) => {
189
+ // Seed the controls from what the model asked for, before any result: the
190
+ // user sees "resin-clear, 15cm" selected while the first quote computes.
191
+ syncControls({
192
+ material: typeof args.material === "string" ? args.material : undefined,
193
+ longestSideCm: typeof args.longestSideCm === "number" ? args.longestSideCm : undefined,
194
+ quantity: typeof args.quantity === "number" ? args.quantity : undefined,
195
+ });
196
+ },
92
197
  onResult: (raw: GenerationResult) => {
93
198
  const data = raw as PrintQuoteResult & GenerationResult;
94
199
  // ontoolinput sends {status: "running"} while the engine measures; a print
95
200
  // result itself never carries `status`.
96
201
  if (data.status === "running") {
97
- $("card").classList.add("measuring");
202
+ $("card").classList.add("quoting");
98
203
  setStatus("Measuring mesh…");
99
204
  return;
100
205
  }
101
- render(data);
206
+ if (data.source) source = data.source;
207
+ if (data.printUrl) {
208
+ // A model-initiated prepare_print: render a receipt, not a configurator.
209
+ staticPrintUrl = data.printUrl;
210
+ $("controls").hidden = true;
211
+ $("order-label").textContent = "Complete order on womp.com";
212
+ } else if (data.materials?.length) {
213
+ populateMaterials(data.materials, data.material);
214
+ $("controls").hidden = false;
215
+ }
216
+ syncControls(data);
217
+ renderQuote(data);
102
218
  },
103
219
  });
104
220
 
105
- $("order").addEventListener("click", () => {
106
- const data = card.current() as PrintQuoteResult | undefined;
107
- if (data?.printUrl) openLink(card.app, data.printUrl);
221
+ orderBtn.addEventListener("click", async () => {
222
+ if (staticPrintUrl) {
223
+ void openLink(card.app, staticPrintUrl);
224
+ return;
225
+ }
226
+ if (!source) return;
227
+ orderBtn.disabled = true;
228
+ $("order-label").textContent = "Setting up project…";
229
+ setStatus("");
230
+ try {
231
+ const res = await callTool(card.app, "prepare_print", currentSettings());
232
+ const data = (res.structuredContent || {}) as PrintQuoteResult;
233
+ if (res.isError || !data.printUrl) {
234
+ const text = (res.content as any[])?.find((c) => c.type === "text")?.text || "Could not set up the print.";
235
+ setStatus(String(text), true);
236
+ return;
237
+ }
238
+ // From here on the card is a receipt for the project that now exists —
239
+ // silently re-quoting a project the user is already checking out would
240
+ // desynchronize the two.
241
+ staticPrintUrl = data.printUrl;
242
+ $("controls").hidden = true;
243
+ renderQuote(data);
244
+ $("order-label").textContent = "Complete order on womp.com";
245
+ void openLink(card.app, data.printUrl);
246
+ } catch {
247
+ setStatus("Could not set up the print — try again.", true);
248
+ } finally {
249
+ orderBtn.disabled = false;
250
+ if (!staticPrintUrl) $("order-label").textContent = "Continue on womp.com";
251
+ }
108
252
  });
@@ -69,6 +69,13 @@ export interface CardHooks {
69
69
  onResult: (data: GenerationResult) => void;
70
70
  /** Called when a poll gives up. */
71
71
  onTimeout?: () => void;
72
+ /**
73
+ * Called with the tool call's ARGUMENTS when the host announces the input
74
+ * (before any result exists). This is how a configurator card starts on the
75
+ * settings the model was asked for — "print it in resin-clear at 15cm" seeds
76
+ * the controls before the first quote lands.
77
+ */
78
+ onInput?: (args: Record<string, unknown>) => void;
72
79
  }
73
80
 
74
81
  export interface Card {
@@ -148,7 +155,10 @@ export function createCard(name: string, hooks: CardHooks): Card {
148
155
  };
149
156
 
150
157
  // Before connect(), always.
151
- app.ontoolinput = () => hooks.onResult({ status: "running" });
158
+ app.ontoolinput = (params) => {
159
+ hooks.onInput?.(((params as any)?.arguments as Record<string, unknown>) || {});
160
+ hooks.onResult({ status: "running" });
161
+ };
152
162
  app.ontoolresult = (params) => handle(params?.structuredContent as GenerationResult);
153
163
  app.onhostcontextchanged = (ctx) => {
154
164
  if (ctx?.theme) applyDocumentTheme(ctx.theme);
@@ -181,6 +191,17 @@ export function openLink(app: App, url: string) {
181
191
  return app.openLink({ url });
182
192
  }
183
193
 
194
+ /**
195
+ * Call a server tool through the host. Only works for tools that declare
196
+ * `visibility: ["model", "app"]` — the host refuses everything else, which is
197
+ * the security boundary keeping credit-spending tools out of widget reach.
198
+ * Returns the raw result; callers check `isError` themselves because an error
199
+ * here is normal UI state (bad material, mesh gone), not an exception.
200
+ */
201
+ export async function callTool(app: App, name: string, args: Record<string, unknown>) {
202
+ return app.request({ method: "tools/call", params: { name, arguments: args } }, CallToolResultSchema);
203
+ }
204
+
184
205
  /** Toggle fullscreen, trusting the host's answer over our request. */
185
206
  export function toggleFullscreen(app: App) {
186
207
  const isFull = document.documentElement.dataset.displayMode === "fullscreen";