@womp/mcp-ui 0.1.6 → 0.1.8
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 +86 -2
- package/dist/index.d.ts +4 -0
- package/dist/index.js +15 -6
- package/dist/print/index.html +700 -134
- package/dist/upload/index.html +351 -0
- package/package.json +4 -3
- package/src/print/index.html +683 -117
- package/src/print/main.ts +747 -210
- package/src/print/pantone-coated.ts +5 -0
- package/src/print/pantone.ts +224 -0
- package/src/upload/index.html +277 -0
- package/src/upload/main.ts +351 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { $, callTool, createCard, openLink, type GenerationResult } from "../shared/bridge";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The upload card — renders `create_upload` / `get_upload` and lets the user
|
|
5
|
+
* drop a mesh file straight into the conversation.
|
|
6
|
+
*
|
|
7
|
+
* Why it exists: MCP has no client→server file channel, and a chat host's
|
|
8
|
+
* sandbox usually cannot reach Womp (Claude's egress allowlist is package
|
|
9
|
+
* managers only), so a file the user already attached to the chat could not
|
|
10
|
+
* reach the print pipeline without a trip to a womp.com page. This card is that
|
|
11
|
+
* page, inside the chat. The bytes go from the user's disk to the ticket URL
|
|
12
|
+
* the backend minted — a raw PUT, the same request the agent's own `curl -T`
|
|
13
|
+
* would make — and never through the model.
|
|
14
|
+
*
|
|
15
|
+
* What it needs from the host: `connectDomains` on the resource's CSP must
|
|
16
|
+
* include the API origin (swan-backend adds its own origin at registration —
|
|
17
|
+
* the baked CSP cannot know which environment it will serve), and the backend
|
|
18
|
+
* answers the PUT with open CORS because the iframe origin is opaque. If either
|
|
19
|
+
* is missing the PUT fails as a network error, which the card turns into the
|
|
20
|
+
* "open the upload page instead" fallback rather than a dead end.
|
|
21
|
+
*
|
|
22
|
+
* It also polls the app-callable `get_upload` while pending, so a file the
|
|
23
|
+
* AGENT pushed itself (an agent with a shell does that first) flips this card
|
|
24
|
+
* to done instead of leaving a drop zone for a file that already landed.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
interface UploadSummary {
|
|
28
|
+
uploadId: number;
|
|
29
|
+
status: "pending" | "ready" | "expired";
|
|
30
|
+
fileName: string;
|
|
31
|
+
format: string;
|
|
32
|
+
maxBytes: number;
|
|
33
|
+
maxMb: number;
|
|
34
|
+
expiresAt: string;
|
|
35
|
+
uploadUrl: string;
|
|
36
|
+
browserUploadUrl: string;
|
|
37
|
+
fileSize?: number;
|
|
38
|
+
url?: string;
|
|
39
|
+
lastError?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type Phase = "connecting" | "pending" | "uploading" | "done" | "expired";
|
|
43
|
+
|
|
44
|
+
const MB = 1024 * 1024;
|
|
45
|
+
const POLL_START_MS = 5_000;
|
|
46
|
+
const POLL_MAX_MS = 15_000;
|
|
47
|
+
|
|
48
|
+
const state: {
|
|
49
|
+
summary?: UploadSummary;
|
|
50
|
+
phase: Phase;
|
|
51
|
+
/** The name of the file being sent / that was sent — the drop's, not the ticket's. */
|
|
52
|
+
fileName?: string;
|
|
53
|
+
fileSize?: number;
|
|
54
|
+
} = { phase: "connecting" };
|
|
55
|
+
|
|
56
|
+
let pollTimer: number | undefined;
|
|
57
|
+
|
|
58
|
+
const setStatus = (text: string, isError = false) => {
|
|
59
|
+
const el = $("status");
|
|
60
|
+
el.textContent = text;
|
|
61
|
+
el.classList.toggle("error", isError);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** "399 KB" below a megabyte, "1.4 MB" / "150 MB" above — never "0.0 MB". */
|
|
65
|
+
const formatSize = (bytes: number) => {
|
|
66
|
+
if (bytes < MB) return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
67
|
+
const mb = bytes / MB;
|
|
68
|
+
return `${mb >= 10 ? mb.toFixed(0) : mb.toFixed(1)} MB`;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const extensionOf = (name: string) => {
|
|
72
|
+
const i = name.lastIndexOf(".");
|
|
73
|
+
return i < 0 || i >= name.length - 1 ? "" : name.slice(i + 1).toLowerCase();
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** "until 14:32" in the viewer's clock, so the link's lifetime is concrete. */
|
|
77
|
+
const untilLabel = (iso: string) => {
|
|
78
|
+
const d = new Date(iso);
|
|
79
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
80
|
+
return `until ${d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// --- rendering -----------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
function showOnly(id: "drop" | "progress" | "done" | "expired" | null) {
|
|
86
|
+
for (const key of ["drop", "progress", "done", "expired"] as const) $(key).hidden = key !== id;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function showError(text: string, withPageFallback = false) {
|
|
90
|
+
$("error-text").textContent = text;
|
|
91
|
+
$("error-actions").hidden = !withPageFallback || !state.summary?.browserUploadUrl;
|
|
92
|
+
$("error").hidden = false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function clearError() {
|
|
96
|
+
$("error").hidden = true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function render() {
|
|
100
|
+
const s = state.summary;
|
|
101
|
+
if (!s) {
|
|
102
|
+
$("title").textContent = state.fileName ? `Send ${state.fileName} to Womp` : "Send your 3D model to Womp";
|
|
103
|
+
$("sub").textContent = "so it can be quoted and printed";
|
|
104
|
+
showOnly(null);
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
$("title").textContent = `Send ${s.fileName} to Womp`;
|
|
109
|
+
$("sub").textContent = "so it can be quoted and printed";
|
|
110
|
+
|
|
111
|
+
switch (state.phase) {
|
|
112
|
+
case "pending": {
|
|
113
|
+
$("drop-text").textContent = `Drop ${s.fileName} here`;
|
|
114
|
+
const until = untilLabel(s.expiresAt);
|
|
115
|
+
$("drop-hint").textContent = `Only .${s.format} · up to ${s.maxMb} MB${until ? ` · link valid ${until}` : ""}`;
|
|
116
|
+
($("file") as HTMLInputElement).accept = `.${s.format}`;
|
|
117
|
+
showOnly("drop");
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "uploading": {
|
|
121
|
+
$("progress-name").textContent = `Uploading ${state.fileName || s.fileName}…`;
|
|
122
|
+
showOnly("progress");
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "done": {
|
|
126
|
+
$("done-name").textContent = state.fileName || s.fileName;
|
|
127
|
+
const size = state.fileSize ?? s.fileSize;
|
|
128
|
+
$("done-size").textContent = size ? ` (${formatSize(size)})` : "";
|
|
129
|
+
$("sub").textContent = "ready to quote";
|
|
130
|
+
showOnly("done");
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case "expired": {
|
|
134
|
+
$("expired-name").textContent = s.fileName;
|
|
135
|
+
$("sub").textContent = "upload link expired";
|
|
136
|
+
showOnly("expired");
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
default:
|
|
140
|
+
showOnly(null);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function setProgress(fraction: number) {
|
|
145
|
+
// Holds at 99% until the server answers — see the CSS note.
|
|
146
|
+
const pct = Math.min(99, Math.round(fraction * 100));
|
|
147
|
+
$("fill").style.width = `${pct}%`;
|
|
148
|
+
$("progress-pct").textContent = `${pct}%`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- applying a summary ---------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
function applySummary(s: UploadSummary) {
|
|
154
|
+
state.summary = s;
|
|
155
|
+
if (s.status === "ready") {
|
|
156
|
+
state.phase = "done";
|
|
157
|
+
stopPolling();
|
|
158
|
+
} else if (s.status === "expired") {
|
|
159
|
+
state.phase = "expired";
|
|
160
|
+
stopPolling();
|
|
161
|
+
} else if (state.phase !== "uploading") {
|
|
162
|
+
state.phase = "pending";
|
|
163
|
+
}
|
|
164
|
+
clearError();
|
|
165
|
+
if (s.status === "pending" && s.lastError && state.phase === "pending") {
|
|
166
|
+
// A PUT elsewhere (the agent's shell, a previous drop) was refused — say
|
|
167
|
+
// why, since that attempt's own response is not visible here.
|
|
168
|
+
showError(`A previous attempt was refused: ${s.lastError}`);
|
|
169
|
+
}
|
|
170
|
+
render();
|
|
171
|
+
setStatus("");
|
|
172
|
+
if (state.phase === "pending") schedulePoll(POLL_START_MS);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- polling get_upload while pending ------------------------------------------
|
|
176
|
+
|
|
177
|
+
function stopPolling() {
|
|
178
|
+
if (pollTimer !== undefined) {
|
|
179
|
+
clearTimeout(pollTimer);
|
|
180
|
+
pollTimer = undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function schedulePoll(delay: number) {
|
|
185
|
+
stopPolling();
|
|
186
|
+
const s = state.summary;
|
|
187
|
+
if (!s || state.phase !== "pending") return;
|
|
188
|
+
// The ticket's own expiry bounds the loop; nothing to learn after it.
|
|
189
|
+
const expiresAt = new Date(s.expiresAt).getTime();
|
|
190
|
+
if (Number.isFinite(expiresAt) && Date.now() > expiresAt + 60_000) return;
|
|
191
|
+
|
|
192
|
+
pollTimer = window.setTimeout(async () => {
|
|
193
|
+
if (state.phase !== "pending") return;
|
|
194
|
+
try {
|
|
195
|
+
const res = await callTool(card.app, "get_upload", { uploadId: s.uploadId });
|
|
196
|
+
if (!res.isError && res.structuredContent) {
|
|
197
|
+
const next = res.structuredContent as unknown as UploadSummary;
|
|
198
|
+
if (next.status !== "pending" || next.lastError !== s.lastError) {
|
|
199
|
+
applySummary(next);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
} catch {
|
|
204
|
+
// A failed poll is not a failed upload; the expiry bound stops this.
|
|
205
|
+
}
|
|
206
|
+
schedulePoll(Math.min(delay * 1.5, POLL_MAX_MS));
|
|
207
|
+
}, delay);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// --- the upload itself ------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/** Why this file cannot be sent for this ticket, in words, or null when it can. */
|
|
213
|
+
function localRefusal(file: File, s: UploadSummary): string | null {
|
|
214
|
+
const ext = extensionOf(file.name);
|
|
215
|
+
if (ext !== s.format) {
|
|
216
|
+
return `This upload expects a .${s.format} file (${s.fileName}); "${file.name}" is a .${ext || "?"}. Ask your assistant for a new upload if the format changed.`;
|
|
217
|
+
}
|
|
218
|
+
if (file.size > s.maxBytes) {
|
|
219
|
+
return `"${file.name}" is ${formatSize(file.size)}; this account's limit is ${s.maxMb} MB per file.`;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function messageFrom(body: string, status: number): string {
|
|
225
|
+
try {
|
|
226
|
+
const parsed = JSON.parse(body) as { error?: { message?: string } };
|
|
227
|
+
if (parsed?.error?.message) return parsed.error.message;
|
|
228
|
+
} catch {
|
|
229
|
+
// not JSON — a proxy page or nothing; fall through to the status
|
|
230
|
+
}
|
|
231
|
+
return status ? `Upload failed (${status}). Try again.` : "";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function upload(file: File) {
|
|
235
|
+
const s = state.summary;
|
|
236
|
+
if (!s || state.phase !== "pending") return;
|
|
237
|
+
clearError();
|
|
238
|
+
const refusal = localRefusal(file, s);
|
|
239
|
+
if (refusal) {
|
|
240
|
+
showError(refusal);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
stopPolling();
|
|
245
|
+
state.phase = "uploading";
|
|
246
|
+
state.fileName = file.name;
|
|
247
|
+
state.fileSize = file.size;
|
|
248
|
+
setProgress(0);
|
|
249
|
+
render();
|
|
250
|
+
setStatus("");
|
|
251
|
+
|
|
252
|
+
const xhr = new XMLHttpRequest();
|
|
253
|
+
xhr.open("PUT", s.uploadUrl);
|
|
254
|
+
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
|
255
|
+
xhr.upload.onprogress = (e) => {
|
|
256
|
+
if (e.lengthComputable) setProgress(e.loaded / e.total);
|
|
257
|
+
};
|
|
258
|
+
xhr.onload = () => {
|
|
259
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
260
|
+
let stored: UploadSummary | undefined;
|
|
261
|
+
try {
|
|
262
|
+
stored = JSON.parse(xhr.responseText) as UploadSummary;
|
|
263
|
+
} catch {
|
|
264
|
+
// keep what we know; the row is ready either way
|
|
265
|
+
}
|
|
266
|
+
applySummary(stored ?? { ...s, status: "ready", fileSize: file.size });
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (xhr.status === 409) {
|
|
270
|
+
// Someone else's upload (the agent's shell, another tab) won. Theirs is the file.
|
|
271
|
+
applySummary({ ...s, status: "ready" });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (xhr.status === 410) {
|
|
275
|
+
applySummary({ ...s, status: "expired" });
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
state.phase = "pending";
|
|
279
|
+
render();
|
|
280
|
+
showError(messageFrom(xhr.responseText, xhr.status) || `Upload failed (${xhr.status}). Try again.`);
|
|
281
|
+
schedulePoll(POLL_START_MS);
|
|
282
|
+
};
|
|
283
|
+
const networkFailure = () => {
|
|
284
|
+
// No response at all: the host's sandbox refused the request (CSP or
|
|
285
|
+
// CORS), or the network dropped. The womp.com page does the same job.
|
|
286
|
+
state.phase = "pending";
|
|
287
|
+
render();
|
|
288
|
+
showError("Could not send the file from inside the chat. The upload page does the same thing in a tab.", true);
|
|
289
|
+
setStatus("PUT to the upload URL got no response — check connectDomains / CORS if this repeats.", true);
|
|
290
|
+
schedulePoll(POLL_START_MS);
|
|
291
|
+
};
|
|
292
|
+
xhr.onerror = networkFailure;
|
|
293
|
+
xhr.onabort = networkFailure;
|
|
294
|
+
xhr.send(file);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// --- wiring ---------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
const card = createCard("Womp upload", {
|
|
300
|
+
onInput: (args) => {
|
|
301
|
+
// The host announces the call before any result: name the file straight away.
|
|
302
|
+
if (typeof args.fileName === "string") state.fileName = args.fileName;
|
|
303
|
+
render();
|
|
304
|
+
setStatus("Preparing upload…");
|
|
305
|
+
},
|
|
306
|
+
onResult: (raw: GenerationResult) => {
|
|
307
|
+
const data = raw as unknown as Partial<UploadSummary> & GenerationResult;
|
|
308
|
+
if (data.status === "running") {
|
|
309
|
+
render();
|
|
310
|
+
setStatus("Preparing upload…");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (typeof data.uploadId !== "number" || !data.uploadUrl) {
|
|
314
|
+
setStatus("The upload result is missing its URL — is swan-backend on a build with create_upload?", true);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
applySummary(data as UploadSummary);
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
const drop = $("drop");
|
|
322
|
+
const input = $("file") as HTMLInputElement;
|
|
323
|
+
|
|
324
|
+
$("choose").addEventListener("click", () => input.click());
|
|
325
|
+
input.addEventListener("change", () => {
|
|
326
|
+
const file = input.files?.[0];
|
|
327
|
+
input.value = "";
|
|
328
|
+
if (file) upload(file);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
for (const type of ["dragenter", "dragover"]) {
|
|
332
|
+
drop.addEventListener(type, (e) => {
|
|
333
|
+
e.preventDefault();
|
|
334
|
+
drop.classList.add("active");
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
for (const type of ["dragleave", "drop"]) {
|
|
338
|
+
drop.addEventListener(type, (e) => {
|
|
339
|
+
e.preventDefault();
|
|
340
|
+
drop.classList.remove("active");
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
drop.addEventListener("drop", (e) => {
|
|
344
|
+
const file = (e as DragEvent).dataTransfer?.files?.[0];
|
|
345
|
+
if (file) upload(file);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
$("open-page").addEventListener("click", () => {
|
|
349
|
+
const url = state.summary?.browserUploadUrl;
|
|
350
|
+
if (url) void openLink(card.app, url);
|
|
351
|
+
});
|