@womp/mcp-ui 0.1.11 → 0.1.12
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 +8 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +15 -6
- package/dist/openai-camera/index.html +332 -0
- package/package.json +6 -3
- package/src/openai-camera/index.html +258 -0
- package/src/openai-camera/main.ts +442 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import { $, callTool, createCard, pushModelContext, toggleFullscreen } from "../shared/bridge";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The camera card — the OpenAI MCP's first project (swan-backend
|
|
5
|
+
* docs/OPENAI_MCP.md). Brand-neutral: nothing here may say Womp.
|
|
6
|
+
*
|
|
7
|
+
* The 3D view is Carlos's hosted viewer (Kakapo on WebGPU) in a nested frame,
|
|
8
|
+
* pointed at the scene the server renders for the design's current revision.
|
|
9
|
+
* Two directions of change meet here:
|
|
10
|
+
*
|
|
11
|
+
* - The user drags a slider in the viewer's Customize panel. The viewer only
|
|
12
|
+
* answers `kakapo:getParams`, so the card asks every 1.5s, and a difference
|
|
13
|
+
* is saved with `sync_camera_design` (app-only). The viewer already shows it;
|
|
14
|
+
* no reload.
|
|
15
|
+
* - The model calls `update_camera_design`. That renders no card, so this card
|
|
16
|
+
* polls `get_camera_design` and reloads the viewer when the revision moved
|
|
17
|
+
* for any reason other than its own write.
|
|
18
|
+
*
|
|
19
|
+
* Colours are not viewer knobs (they live in the scene's materials), so a swatch
|
|
20
|
+
* saves and then reloads the viewer with the new revision.
|
|
21
|
+
*
|
|
22
|
+
* Ordering happens in the card: Order (or a `checkout_camera` result, which
|
|
23
|
+
* arrives with mode "checkout") opens the shipping form, and the form submits to
|
|
24
|
+
* the app-only `place_order` for the revision on screen. Nothing is charged.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
interface DesignOption {
|
|
28
|
+
name: string;
|
|
29
|
+
label: string;
|
|
30
|
+
value: number;
|
|
31
|
+
display: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface Design {
|
|
35
|
+
designId: string;
|
|
36
|
+
rev: number;
|
|
37
|
+
mode?: "design" | "checkout";
|
|
38
|
+
viewerUrl: string;
|
|
39
|
+
viewerOrigin: string;
|
|
40
|
+
sceneUrl: string;
|
|
41
|
+
options: DesignOption[];
|
|
42
|
+
colors: Record<Slot, string>;
|
|
43
|
+
palette: { name: string; hex: string }[];
|
|
44
|
+
price: { amount: number; currency: string; label: string };
|
|
45
|
+
lastChange?: { source: string; writer: string; fields: string[] };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface OrderResult {
|
|
49
|
+
orderId: string;
|
|
50
|
+
rev: number;
|
|
51
|
+
price: { label: string };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Slot = "shell" | "button";
|
|
55
|
+
const SLOTS: Slot[] = ["shell", "button"];
|
|
56
|
+
type View = "design" | "order" | "done";
|
|
57
|
+
|
|
58
|
+
const CONTACT_FIELDS = ["name", "email", "address", "city", "postcode", "country"] as const;
|
|
59
|
+
|
|
60
|
+
/** Marks this card's own writes, so its polling does not reload the viewer for them. */
|
|
61
|
+
const writer = `card-${Math.random().toString(36).slice(2, 10)}`;
|
|
62
|
+
|
|
63
|
+
const cardEl = $("card");
|
|
64
|
+
const viewer = $("viewer") as HTMLIFrameElement;
|
|
65
|
+
const fallback = $("fallback");
|
|
66
|
+
const form = $("order-form") as HTMLFormElement;
|
|
67
|
+
|
|
68
|
+
let design: Design | undefined;
|
|
69
|
+
/** Revision the viewer is showing. */
|
|
70
|
+
let loadedRev = 0;
|
|
71
|
+
let viewerReady = false;
|
|
72
|
+
/** Last knob values read back from the viewer. */
|
|
73
|
+
let viewerParams: Record<string, number> | undefined;
|
|
74
|
+
let webgpu: boolean | undefined;
|
|
75
|
+
let syncTimer: number | undefined;
|
|
76
|
+
let syncing: Promise<void> | undefined;
|
|
77
|
+
let readyTimer: number | undefined;
|
|
78
|
+
let pollTimer: number | undefined;
|
|
79
|
+
let lastActivity = Date.now();
|
|
80
|
+
let statusTimer: number | undefined;
|
|
81
|
+
|
|
82
|
+
function setStatus(text: string, clearAfterMs?: number) {
|
|
83
|
+
clearTimeout(statusTimer);
|
|
84
|
+
$("status").textContent = text;
|
|
85
|
+
if (clearAfterMs) statusTimer = window.setTimeout(() => ($("status").textContent = ""), clearAfterMs);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function showView(view: View) {
|
|
89
|
+
cardEl.dataset.view = view;
|
|
90
|
+
$("view-design").hidden = view !== "design";
|
|
91
|
+
$("view-order").hidden = view !== "order";
|
|
92
|
+
$("view-done").hidden = view !== "done";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const { app } = createCard("Camera", {
|
|
96
|
+
onInput: () => setStatus("Setting up your camera…"),
|
|
97
|
+
onResult: (raw) => {
|
|
98
|
+
const data = raw as unknown as Partial<Design>;
|
|
99
|
+
// The bridge also calls this with a bare { status: "running" } placeholder.
|
|
100
|
+
if (typeof data.designId !== "string") return;
|
|
101
|
+
adopt(data as Design);
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/** A design arrived from the tool call that rendered this card. */
|
|
106
|
+
function adopt(next: Design) {
|
|
107
|
+
const isNewDesign = next.designId !== design?.designId;
|
|
108
|
+
design = next;
|
|
109
|
+
render();
|
|
110
|
+
if (isNewDesign) {
|
|
111
|
+
void loadViewer();
|
|
112
|
+
schedulePoll(2_500);
|
|
113
|
+
} else if (next.rev > loadedRev) {
|
|
114
|
+
void loadViewer();
|
|
115
|
+
}
|
|
116
|
+
if (next.mode === "checkout") openOrderForm();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---- the viewer -------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
async function detectWebGPU(): Promise<boolean> {
|
|
122
|
+
const gpu = (navigator as Navigator & { gpu?: { requestAdapter(): Promise<unknown> } }).gpu;
|
|
123
|
+
if (!gpu) return false;
|
|
124
|
+
try {
|
|
125
|
+
return Boolean(await gpu.requestAdapter());
|
|
126
|
+
} catch {
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function loadViewer(note?: string) {
|
|
132
|
+
if (!design) return;
|
|
133
|
+
if (webgpu === undefined) {
|
|
134
|
+
setStatus("Checking 3D support…");
|
|
135
|
+
webgpu = await detectWebGPU();
|
|
136
|
+
report(webgpu ? "webgpu-available" : "webgpu-missing");
|
|
137
|
+
}
|
|
138
|
+
loadedRev = design.rev;
|
|
139
|
+
if (!webgpu) {
|
|
140
|
+
showFallback();
|
|
141
|
+
setStatus("");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
viewerReady = false;
|
|
145
|
+
viewerParams = undefined;
|
|
146
|
+
fallback.hidden = true;
|
|
147
|
+
viewer.hidden = false;
|
|
148
|
+
viewer.src = design.viewerUrl;
|
|
149
|
+
setStatus(note ?? "Loading the 3D viewer…");
|
|
150
|
+
clearTimeout(readyTimer);
|
|
151
|
+
readyTimer = window.setTimeout(() => {
|
|
152
|
+
if (viewerReady) return;
|
|
153
|
+
setStatus("The 3D viewer hasn't loaded — this app may not allow it here.");
|
|
154
|
+
report("viewer-timeout");
|
|
155
|
+
}, 25_000);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function showFallback() {
|
|
159
|
+
if (!design) return;
|
|
160
|
+
viewer.hidden = true;
|
|
161
|
+
fallback.hidden = false;
|
|
162
|
+
fallback.textContent = "";
|
|
163
|
+
const p = document.createElement("p");
|
|
164
|
+
p.textContent =
|
|
165
|
+
"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.";
|
|
166
|
+
const list = document.createElement("dl");
|
|
167
|
+
const rows: [string, string][] = [
|
|
168
|
+
["Shell colour", design.colors.shell],
|
|
169
|
+
["Button colour", design.colors.button],
|
|
170
|
+
...design.options.map((o) => [o.label, o.display] as [string, string]),
|
|
171
|
+
];
|
|
172
|
+
for (const [k, v] of rows) {
|
|
173
|
+
const dt = document.createElement("dt");
|
|
174
|
+
dt.textContent = k;
|
|
175
|
+
const dd = document.createElement("dd");
|
|
176
|
+
dd.textContent = v;
|
|
177
|
+
list.append(dt, dd);
|
|
178
|
+
}
|
|
179
|
+
fallback.append(p, list);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The viewer reports `scripts: [{ nodeId, name, params: { knob: value } }]`; knob names are unique across them. */
|
|
183
|
+
function flatten(scripts: unknown): Record<string, number> {
|
|
184
|
+
const out: Record<string, number> = {};
|
|
185
|
+
if (!Array.isArray(scripts)) return out;
|
|
186
|
+
for (const script of scripts) {
|
|
187
|
+
const params = (script as { params?: Record<string, unknown> })?.params ?? {};
|
|
188
|
+
for (const [name, value] of Object.entries(params)) {
|
|
189
|
+
if (typeof value === "number" && Number.isFinite(value)) out[name] = value;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
window.addEventListener("message", (event) => {
|
|
196
|
+
if (!design || event.source !== viewer.contentWindow || event.origin !== design.viewerOrigin) return;
|
|
197
|
+
const msg = event.data as { type?: string; scripts?: unknown };
|
|
198
|
+
if (msg?.type === "kakapo:ready") {
|
|
199
|
+
viewerReady = true;
|
|
200
|
+
clearTimeout(readyTimer);
|
|
201
|
+
viewerParams = flatten(msg.scripts);
|
|
202
|
+
setStatus("");
|
|
203
|
+
report("viewer-ready");
|
|
204
|
+
} else if (msg?.type === "kakapo:params") {
|
|
205
|
+
onViewerParams(flatten(msg.scripts));
|
|
206
|
+
} else if (msg?.type === "kakapo:print") {
|
|
207
|
+
// The viewer's own Print button is our Order.
|
|
208
|
+
onViewerParams(flatten(msg.scripts));
|
|
209
|
+
openOrderForm();
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// The viewer has no change event, so read its knobs on an interval while visible.
|
|
214
|
+
window.setInterval(() => {
|
|
215
|
+
if (!design || !viewerReady || document.visibilityState !== "visible") return;
|
|
216
|
+
viewer.contentWindow?.postMessage({ type: "kakapo:getParams" }, design.viewerOrigin);
|
|
217
|
+
}, 1_500);
|
|
218
|
+
|
|
219
|
+
function onViewerParams(params: Record<string, number>) {
|
|
220
|
+
if (!viewerParams) {
|
|
221
|
+
viewerParams = params;
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
const before = viewerParams;
|
|
225
|
+
if (!Object.keys(params).some((k) => params[k] !== before[k])) return;
|
|
226
|
+
viewerParams = params;
|
|
227
|
+
lastActivity = Date.now();
|
|
228
|
+
setStatus("Saving…");
|
|
229
|
+
clearTimeout(syncTimer);
|
|
230
|
+
syncTimer = window.setTimeout(() => void flushSync(), 700);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---- saving -------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Save the viewer's knobs (and a colour, if one was picked). Serialised, so two
|
|
237
|
+
* quick changes cannot race each other's baseRev.
|
|
238
|
+
*/
|
|
239
|
+
async function flushSync(colors?: Partial<Record<Slot, string>>) {
|
|
240
|
+
clearTimeout(syncTimer);
|
|
241
|
+
syncTimer = undefined;
|
|
242
|
+
while (syncing) await syncing;
|
|
243
|
+
if (!design) return;
|
|
244
|
+
const baseRev = design.rev;
|
|
245
|
+
syncing = (async () => {
|
|
246
|
+
try {
|
|
247
|
+
const res = await callTool(app, "sync_camera_design", {
|
|
248
|
+
designId: design!.designId,
|
|
249
|
+
baseRev,
|
|
250
|
+
writer,
|
|
251
|
+
params: viewerParams ?? {},
|
|
252
|
+
...(colors ? { colors } : {}),
|
|
253
|
+
});
|
|
254
|
+
const next = res.structuredContent as Design | undefined;
|
|
255
|
+
if (res.isError || !next) {
|
|
256
|
+
setStatus("Couldn't save that change.");
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
design = next;
|
|
260
|
+
render();
|
|
261
|
+
if (next.rev === baseRev) {
|
|
262
|
+
setStatus("");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
// Anything besides our own write since baseRev (a chat change merged in)
|
|
266
|
+
// means the viewer is behind. So is a colour, which it cannot set itself.
|
|
267
|
+
const merged = next.rev > baseRev + 1;
|
|
268
|
+
if (merged || colors) void loadViewer(merged ? "Merged with a change from the chat…" : "Applying colour…");
|
|
269
|
+
else loadedRev = next.rev;
|
|
270
|
+
if (!merged && !colors) setStatus("Saved", 1_500);
|
|
271
|
+
void pushModelContext(app, contextLine(next));
|
|
272
|
+
} catch {
|
|
273
|
+
setStatus("Couldn't save that change.");
|
|
274
|
+
}
|
|
275
|
+
})();
|
|
276
|
+
try {
|
|
277
|
+
await syncing;
|
|
278
|
+
} finally {
|
|
279
|
+
syncing = undefined;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const contextLine = (d: Design) =>
|
|
284
|
+
`The user adjusted their camera in the card. Design ${d.designId}, revision ${d.rev}: ` +
|
|
285
|
+
`shell ${d.colors.shell}, button ${d.colors.button}; ` +
|
|
286
|
+
d.options.map((o) => `${o.label} ${o.display}`).join(", ") +
|
|
287
|
+
".";
|
|
288
|
+
|
|
289
|
+
// ---- picking up chat changes ----------------------------------------------------
|
|
290
|
+
|
|
291
|
+
function schedulePoll(ms: number) {
|
|
292
|
+
clearTimeout(pollTimer);
|
|
293
|
+
pollTimer = window.setTimeout(poll, ms);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function poll() {
|
|
297
|
+
const idle = Date.now() - lastActivity;
|
|
298
|
+
try {
|
|
299
|
+
if (design && document.visibilityState === "visible" && !syncing) {
|
|
300
|
+
const res = await callTool(app, "get_camera_design", { designId: design.designId });
|
|
301
|
+
const next = res.structuredContent as Design | undefined;
|
|
302
|
+
if (!res.isError && next && next.rev > design.rev) {
|
|
303
|
+
lastActivity = Date.now();
|
|
304
|
+
if (syncTimer !== undefined) {
|
|
305
|
+
// A slider move is waiting to be saved: save it first, so the reload
|
|
306
|
+
// below shows the chat's change AND the user's.
|
|
307
|
+
await flushSync();
|
|
308
|
+
} else {
|
|
309
|
+
design = next;
|
|
310
|
+
render();
|
|
311
|
+
if (next.rev > loadedRev) void loadViewer("Updated from the chat…");
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// A failed poll is not a broken card; try again on the next tick.
|
|
317
|
+
}
|
|
318
|
+
// Brisk while someone is working on it, lazy once they have moved on.
|
|
319
|
+
schedulePoll(idle < 120_000 ? 2_500 : idle < 600_000 ? 8_000 : 20_000);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---- controls -----------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
function render() {
|
|
325
|
+
if (!design) return;
|
|
326
|
+
for (const slot of SLOTS) {
|
|
327
|
+
const row = $(`sw-${slot}`);
|
|
328
|
+
row.textContent = "";
|
|
329
|
+
const chosen = design.colors[slot];
|
|
330
|
+
const entries = [...design.palette];
|
|
331
|
+
// A colour the model set as a hex has no swatch of its own — show it first.
|
|
332
|
+
if (chosen.startsWith("#") && !entries.some((p) => p.name === chosen)) entries.unshift({ name: chosen, hex: chosen });
|
|
333
|
+
for (const p of entries) {
|
|
334
|
+
const b = document.createElement("button");
|
|
335
|
+
b.type = "button";
|
|
336
|
+
b.className = p.name === "clear" ? "swatch clear" : "swatch";
|
|
337
|
+
b.style.background = p.hex;
|
|
338
|
+
b.title = p.name;
|
|
339
|
+
b.setAttribute("aria-label", `${slot} colour: ${p.name}`);
|
|
340
|
+
b.setAttribute("aria-pressed", String(chosen === p.name));
|
|
341
|
+
b.addEventListener("click", () => void pickColor(slot, p.name));
|
|
342
|
+
row.append(b);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
$("price").textContent = `Full-colour print · ${design.price.label}`;
|
|
346
|
+
$("order-summary").textContent =
|
|
347
|
+
`Your camera, printed in full colour: ${design.price.label}. Shell ${design.colors.shell}, button ${design.colors.button}.`;
|
|
348
|
+
if (!fallback.hidden) showFallback();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function pickColor(slot: Slot, name: string) {
|
|
352
|
+
if (!design || design.colors[slot] === name) return;
|
|
353
|
+
lastActivity = Date.now();
|
|
354
|
+
setStatus("Saving…");
|
|
355
|
+
await flushSync({ [slot]: name });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---- ordering -----------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
function openOrderForm() {
|
|
361
|
+
if (!design) return;
|
|
362
|
+
lastActivity = Date.now();
|
|
363
|
+
showView("order");
|
|
364
|
+
setStatus("");
|
|
365
|
+
(form.elements.namedItem("name") as HTMLInputElement | null)?.focus();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The form's values, or undefined after marking what is missing. */
|
|
369
|
+
function readContact(): Record<(typeof CONTACT_FIELDS)[number], string> | undefined {
|
|
370
|
+
const contact = {} as Record<(typeof CONTACT_FIELDS)[number], string>;
|
|
371
|
+
let ok = true;
|
|
372
|
+
for (const name of CONTACT_FIELDS) {
|
|
373
|
+
const input = form.elements.namedItem(name) as HTMLInputElement;
|
|
374
|
+
const value = input.value.trim();
|
|
375
|
+
const valid = name === "email" ? /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) : value.length > 0;
|
|
376
|
+
input.setAttribute("aria-invalid", String(!valid));
|
|
377
|
+
if (!valid) ok = false;
|
|
378
|
+
contact[name] = value;
|
|
379
|
+
}
|
|
380
|
+
return ok ? contact : undefined;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function placeOrder() {
|
|
384
|
+
if (!design) return;
|
|
385
|
+
const contact = readContact();
|
|
386
|
+
if (!contact) {
|
|
387
|
+
setStatus("Fill in the highlighted fields.");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const button = $("order-place") as HTMLButtonElement;
|
|
391
|
+
button.disabled = true;
|
|
392
|
+
setStatus("Placing your order…");
|
|
393
|
+
try {
|
|
394
|
+
// Order what is on screen: save any pending knob turn first.
|
|
395
|
+
if (syncTimer !== undefined || syncing) await flushSync();
|
|
396
|
+
const res = await callTool(app, "place_order", { designId: design.designId, rev: design.rev, writer, contact });
|
|
397
|
+
const order = res.structuredContent as OrderResult | undefined;
|
|
398
|
+
if (res.isError || !order) {
|
|
399
|
+
setStatus("The order didn't go through. Check the details and try again.");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
$("done-text").textContent = `Test order ${order.orderId} is recorded (${order.price.label}). Nothing was charged.`;
|
|
403
|
+
showView("done");
|
|
404
|
+
setStatus("");
|
|
405
|
+
void pushModelContext(app, `The user placed test order ${order.orderId} for camera design ${design.designId}, revision ${order.rev}.`);
|
|
406
|
+
} catch {
|
|
407
|
+
setStatus("The order didn't go through. Try again in a moment.");
|
|
408
|
+
} finally {
|
|
409
|
+
button.disabled = false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
$("customize").addEventListener("click", () => {
|
|
414
|
+
lastActivity = Date.now();
|
|
415
|
+
void toggleFullscreen(app);
|
|
416
|
+
});
|
|
417
|
+
$("order").addEventListener("click", openOrderForm);
|
|
418
|
+
$("order-back").addEventListener("click", () => showView("design"));
|
|
419
|
+
$("order-place").addEventListener("click", () => void placeOrder());
|
|
420
|
+
$("done-back").addEventListener("click", () => showView("design"));
|
|
421
|
+
form.addEventListener("submit", (event) => {
|
|
422
|
+
event.preventDefault();
|
|
423
|
+
void placeOrder();
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// The bridge sets data-display-mode from the host; keep the toggle's label honest.
|
|
427
|
+
new MutationObserver(() => {
|
|
428
|
+
$("customize").textContent = document.documentElement.dataset.displayMode === "fullscreen" ? "Done" : "Customize";
|
|
429
|
+
}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-display-mode"] });
|
|
430
|
+
|
|
431
|
+
/** Whether the viewer could run, and where — the data that settles "how many sessions have WebGPU". */
|
|
432
|
+
function report(event: string, detail?: string) {
|
|
433
|
+
void callTool(app, "report_viewer_status", {
|
|
434
|
+
...(design ? { designId: design.designId } : {}),
|
|
435
|
+
writer,
|
|
436
|
+
event,
|
|
437
|
+
...(webgpu === undefined ? {} : { webgpu }),
|
|
438
|
+
...(detail ? { detail } : {}),
|
|
439
|
+
userAgent: navigator.userAgent.slice(0, 300),
|
|
440
|
+
displayMode: document.documentElement.dataset.displayMode ?? "inline",
|
|
441
|
+
}).catch(() => {});
|
|
442
|
+
}
|