@womp/mcp-ui 0.1.2 → 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/README.md +3 -0
- package/dist/image/index.html +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.js +13 -4
- package/dist/mesh/index.html +1 -1
- package/dist/print/index.html +267 -0
- package/package.json +4 -3
- package/src/print/index.html +193 -0
- package/src/print/main.ts +252 -0
- package/src/shared/bridge.ts +22 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@womp/mcp-ui",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "MCP Apps widgets for Womp's MCP server — interactive cards rendered inline in Claude and other MCP hosts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
17
|
"build": "yarn build:ui && yarn build:bake",
|
|
18
|
-
"build:ui": "yarn build:ui:image && yarn build:ui:mesh",
|
|
18
|
+
"build:ui": "yarn build:ui:image && yarn build:ui:mesh && yarn build:ui:print",
|
|
19
19
|
"build:bake": "node scripts/bake-html.mjs",
|
|
20
20
|
"dev": "vite",
|
|
21
21
|
"dev:host": "vite",
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
"clean": "rm -rf dist",
|
|
25
25
|
"prepublishOnly": "yarn clean && yarn build",
|
|
26
26
|
"build:ui:image": "CARD=image vite build",
|
|
27
|
-
"build:ui:mesh": "CARD=mesh vite build"
|
|
27
|
+
"build:ui:mesh": "CARD=mesh vite build",
|
|
28
|
+
"build:ui:print": "CARD=print vite build"
|
|
28
29
|
},
|
|
29
30
|
"devDependencies": {
|
|
30
31
|
"@modelcontextprotocol/ext-apps": "^1.7.5",
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>Womp 3D print</title>
|
|
6
|
+
<style>
|
|
7
|
+
/* Same var()-with-fallback discipline as the other cards: host style
|
|
8
|
+
variables arrive over the bridge after the handshake, not as CSS
|
|
9
|
+
custom properties, so the card must look right without them. */
|
|
10
|
+
:root {
|
|
11
|
+
color-scheme: light dark;
|
|
12
|
+
}
|
|
13
|
+
body {
|
|
14
|
+
margin: 0;
|
|
15
|
+
font-family: var(--font-sans, system-ui, -apple-system, "Segoe UI", sans-serif);
|
|
16
|
+
color: var(--color-text-primary, #e8e8e8);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
.card {
|
|
20
|
+
max-width: 360px;
|
|
21
|
+
border-radius: var(--border-radius-lg, 14px);
|
|
22
|
+
overflow: hidden;
|
|
23
|
+
background: var(--color-background-secondary, #1b1b1d);
|
|
24
|
+
border: 1px solid var(--color-border-primary, rgba(255, 255, 255, 0.08));
|
|
25
|
+
padding: 14px;
|
|
26
|
+
box-sizing: border-box;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.head {
|
|
30
|
+
display: flex;
|
|
31
|
+
align-items: baseline;
|
|
32
|
+
justify-content: space-between;
|
|
33
|
+
gap: 8px;
|
|
34
|
+
}
|
|
35
|
+
.title {
|
|
36
|
+
font-size: var(--font-text-sm-size, 13px);
|
|
37
|
+
font-weight: 600;
|
|
38
|
+
}
|
|
39
|
+
.size {
|
|
40
|
+
font-size: var(--font-text-xs-size, 11px);
|
|
41
|
+
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
42
|
+
white-space: nowrap;
|
|
43
|
+
}
|
|
44
|
+
.size:empty { display: none; }
|
|
45
|
+
|
|
46
|
+
/* The configurator: three labelled controls in a row. Hidden entirely in
|
|
47
|
+
static mode (a prepare_print result rendered by a model call). */
|
|
48
|
+
.controls {
|
|
49
|
+
margin-top: 10px;
|
|
50
|
+
display: grid;
|
|
51
|
+
grid-template-columns: 1fr 76px 64px;
|
|
52
|
+
gap: 8px;
|
|
53
|
+
}
|
|
54
|
+
.controls[hidden] { display: none; }
|
|
55
|
+
.field {
|
|
56
|
+
display: grid;
|
|
57
|
+
gap: 3px;
|
|
58
|
+
font-size: 10px;
|
|
59
|
+
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
60
|
+
}
|
|
61
|
+
.field select,
|
|
62
|
+
.field input {
|
|
63
|
+
font: inherit;
|
|
64
|
+
font-size: var(--font-text-xs-size, 12px);
|
|
65
|
+
color: var(--color-text-primary, #e8e8e8);
|
|
66
|
+
background: var(--color-background-tertiary, #2a2a2e);
|
|
67
|
+
border: 1px solid var(--color-border-primary, rgba(255, 255, 255, 0.1));
|
|
68
|
+
border-radius: var(--border-radius-md, 8px);
|
|
69
|
+
padding: 6px 8px;
|
|
70
|
+
min-width: 0;
|
|
71
|
+
box-sizing: border-box;
|
|
72
|
+
}
|
|
73
|
+
.field select:focus-visible,
|
|
74
|
+
.field input:focus-visible {
|
|
75
|
+
outline: 2px solid var(--color-ring-primary, #7aa2ff);
|
|
76
|
+
outline-offset: 1px;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.price {
|
|
80
|
+
margin-top: 12px;
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: baseline;
|
|
83
|
+
gap: 8px;
|
|
84
|
+
transition: opacity 120ms ease;
|
|
85
|
+
}
|
|
86
|
+
.price:empty { display: none; }
|
|
87
|
+
.amount {
|
|
88
|
+
font-size: 24px;
|
|
89
|
+
font-weight: 700;
|
|
90
|
+
letter-spacing: -0.02em;
|
|
91
|
+
}
|
|
92
|
+
.was {
|
|
93
|
+
font-size: var(--font-text-xs-size, 12px);
|
|
94
|
+
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
95
|
+
text-decoration: line-through;
|
|
96
|
+
}
|
|
97
|
+
.per {
|
|
98
|
+
font-size: var(--font-text-xs-size, 11px);
|
|
99
|
+
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.rows {
|
|
103
|
+
margin-top: 6px;
|
|
104
|
+
font-size: var(--font-text-xs-size, 11px);
|
|
105
|
+
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
106
|
+
display: grid;
|
|
107
|
+
gap: 2px;
|
|
108
|
+
transition: opacity 120ms ease;
|
|
109
|
+
}
|
|
110
|
+
.rows:empty { display: none; }
|
|
111
|
+
|
|
112
|
+
/* Re-quoting: dim the numbers that are about to change, keep the layout. */
|
|
113
|
+
.quoting .price,
|
|
114
|
+
.quoting .rows { opacity: 0.35; }
|
|
115
|
+
|
|
116
|
+
.cta {
|
|
117
|
+
margin-top: 12px;
|
|
118
|
+
display: flex;
|
|
119
|
+
width: 100%;
|
|
120
|
+
align-items: center;
|
|
121
|
+
justify-content: center;
|
|
122
|
+
gap: 6px;
|
|
123
|
+
border: 0;
|
|
124
|
+
cursor: pointer;
|
|
125
|
+
font: inherit;
|
|
126
|
+
font-size: var(--font-text-sm-size, 13px);
|
|
127
|
+
font-weight: 600;
|
|
128
|
+
padding: 9px 12px;
|
|
129
|
+
border-radius: var(--border-radius-full, 999px);
|
|
130
|
+
color: #fff;
|
|
131
|
+
background: var(--color-background-accent, #5867dd);
|
|
132
|
+
}
|
|
133
|
+
.cta:hover:not(:disabled) { filter: brightness(1.1); }
|
|
134
|
+
.cta:focus-visible { outline: 2px solid var(--color-ring-primary, #7aa2ff); outline-offset: 1px; }
|
|
135
|
+
.cta[hidden] { display: none; }
|
|
136
|
+
.cta:disabled { opacity: 0.55; cursor: default; }
|
|
137
|
+
.cta svg { width: 14px; height: 14px; display: block; }
|
|
138
|
+
|
|
139
|
+
.note {
|
|
140
|
+
margin-top: 8px;
|
|
141
|
+
font-size: 10px;
|
|
142
|
+
color: var(--color-text-secondary, rgba(232, 232, 232, 0.5));
|
|
143
|
+
}
|
|
144
|
+
.note[hidden] { display: none; }
|
|
145
|
+
|
|
146
|
+
.status {
|
|
147
|
+
margin-top: 8px;
|
|
148
|
+
font-size: var(--font-text-xs-size, 11px);
|
|
149
|
+
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
150
|
+
}
|
|
151
|
+
.status:empty { display: none; }
|
|
152
|
+
.status.error { color: var(--color-text-danger, #ff8888); }
|
|
153
|
+
</style>
|
|
154
|
+
</head>
|
|
155
|
+
<body>
|
|
156
|
+
<div class="card" id="card">
|
|
157
|
+
<div class="head">
|
|
158
|
+
<div class="title">3D print</div>
|
|
159
|
+
<div class="size" id="size"></div>
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<div class="controls" id="controls" hidden>
|
|
163
|
+
<label class="field">
|
|
164
|
+
material
|
|
165
|
+
<select id="material"></select>
|
|
166
|
+
</label>
|
|
167
|
+
<label class="field">
|
|
168
|
+
size (cm)
|
|
169
|
+
<input id="longest" type="number" min="2" max="60" step="0.5" />
|
|
170
|
+
</label>
|
|
171
|
+
<label class="field">
|
|
172
|
+
qty
|
|
173
|
+
<input id="quantity" type="number" min="1" max="100" step="1" />
|
|
174
|
+
</label>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div class="price" id="price"></div>
|
|
178
|
+
<div class="rows" id="rows"></div>
|
|
179
|
+
|
|
180
|
+
<button class="cta" id="order" hidden>
|
|
181
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
182
|
+
<path d="M7 17 17 7M9 7h8v8" />
|
|
183
|
+
</svg>
|
|
184
|
+
<span id="order-label">Continue on womp.com</span>
|
|
185
|
+
</button>
|
|
186
|
+
<div class="note" id="note" hidden>
|
|
187
|
+
Shipping is added at checkout; the final price is confirmed before printing.
|
|
188
|
+
</div>
|
|
189
|
+
<div class="status" id="status">Connecting…</div>
|
|
190
|
+
</div>
|
|
191
|
+
<script type="module" src="./main.ts"></script>
|
|
192
|
+
</body>
|
|
193
|
+
</html>
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { $, callTool, createCard, openLink, type GenerationResult } from "../shared/bridge";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The print card — a live configurator, and the way out to checkout.
|
|
5
|
+
*
|
|
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.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One priced variant, as swan-backend's QUOTE_ROW_SCHEMA emits it. */
|
|
24
|
+
interface QuoteRow {
|
|
25
|
+
salePrice: number;
|
|
26
|
+
discount: number;
|
|
27
|
+
unitPrice: number;
|
|
28
|
+
productionDays: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** structuredContent of estimate_print / prepare_print (api/mcp.ts §13). */
|
|
32
|
+
interface PrintQuoteResult {
|
|
33
|
+
material?: string;
|
|
34
|
+
quantity?: number;
|
|
35
|
+
longestSideCm?: number;
|
|
36
|
+
dimensionsCm?: { width: number; height: number; depth: number };
|
|
37
|
+
solid?: QuoteRow;
|
|
38
|
+
hollow?: QuoteRow;
|
|
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 }[];
|
|
44
|
+
projectId?: number;
|
|
45
|
+
/** Present on prepare_print results only. */
|
|
46
|
+
printUrl?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const usd = (n: number) => `$${n.toFixed(2)}`;
|
|
50
|
+
|
|
51
|
+
const materialSelect = $("material") as HTMLSelectElement;
|
|
52
|
+
const longestInput = $("longest") as HTMLInputElement;
|
|
53
|
+
const quantityInput = $("quantity") as HTMLInputElement;
|
|
54
|
+
const orderBtn = $("order") as HTMLButtonElement;
|
|
55
|
+
|
|
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;
|
|
62
|
+
|
|
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");
|
|
71
|
+
|
|
72
|
+
const d = data.dimensionsCm;
|
|
73
|
+
if (d) $("size").textContent = `${d.width.toFixed(1)} x ${d.height.toFixed(1)} x ${d.depth.toFixed(1)} cm`;
|
|
74
|
+
|
|
75
|
+
const solid = data.solid;
|
|
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);
|
|
122
|
+
}
|
|
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
|
+
}
|
|
139
|
+
|
|
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);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
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);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
materialSelect.addEventListener("change", scheduleRequote);
|
|
183
|
+
longestInput.addEventListener("change", scheduleRequote);
|
|
184
|
+
quantityInput.addEventListener("change", scheduleRequote);
|
|
185
|
+
|
|
186
|
+
const card = createCard("Womp print", {
|
|
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
|
+
},
|
|
197
|
+
onResult: (raw: GenerationResult) => {
|
|
198
|
+
const data = raw as PrintQuoteResult & GenerationResult;
|
|
199
|
+
// ontoolinput sends {status: "running"} while the engine measures; a print
|
|
200
|
+
// result itself never carries `status`.
|
|
201
|
+
if (data.status === "running") {
|
|
202
|
+
$("card").classList.add("quoting");
|
|
203
|
+
setStatus("Measuring mesh…");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
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);
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
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
|
+
}
|
|
252
|
+
});
|
package/src/shared/bridge.ts
CHANGED
|
@@ -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 = () =>
|
|
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";
|