@womp/mcp-ui 0.1.0 → 0.1.2
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 +91 -18
- package/dist/image/index.html +15 -27
- package/dist/index.js +4 -4
- package/dist/mesh/index.html +15 -27
- package/package.json +2 -1
- package/src/image/index.html +14 -26
- package/src/image/main.ts +7 -7
- package/src/mesh/index.html +14 -26
- package/src/mesh/main.ts +36 -12
- package/src/shared/bridge.ts +25 -0
package/src/image/index.html
CHANGED
|
@@ -63,25 +63,6 @@
|
|
|
63
63
|
.skeleton.pulsing { animation: none; }
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
/* Overlays float above the image, mirroring swan-frontend's generation
|
|
67
|
-
tile rather than sitting in a bar beneath it. */
|
|
68
|
-
.chips {
|
|
69
|
-
position: absolute;
|
|
70
|
-
top: 10px;
|
|
71
|
-
left: 10px;
|
|
72
|
-
display: flex;
|
|
73
|
-
gap: 6px;
|
|
74
|
-
}
|
|
75
|
-
.chip {
|
|
76
|
-
font-size: var(--font-text-xs-size, 11px);
|
|
77
|
-
padding: 3px 8px;
|
|
78
|
-
border-radius: var(--border-radius-full, 999px);
|
|
79
|
-
background: rgba(0, 0, 0, 0.55);
|
|
80
|
-
color: #fff;
|
|
81
|
-
backdrop-filter: blur(6px);
|
|
82
|
-
white-space: nowrap;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
66
|
.actions {
|
|
86
67
|
position: absolute;
|
|
87
68
|
bottom: 10px;
|
|
@@ -147,12 +128,19 @@
|
|
|
147
128
|
/* Nothing to reveal on hover when the image fills the screen. */
|
|
148
129
|
:root[data-display-mode="fullscreen"] .actions { opacity: 1; }
|
|
149
130
|
|
|
150
|
-
.status:empty
|
|
151
|
-
.
|
|
131
|
+
.status:empty,
|
|
132
|
+
.meta:empty { display: none; }
|
|
133
|
+
.status,
|
|
134
|
+
.meta {
|
|
152
135
|
font-size: var(--font-text-xs-size, 11px);
|
|
153
136
|
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
154
137
|
padding: 8px 12px;
|
|
155
138
|
}
|
|
139
|
+
/* When both are shown the pair reads as one block, so the meta line drops
|
|
140
|
+
its bottom padding rather than double-spacing them. */
|
|
141
|
+
.meta:has(+ .status:not(:empty)) {
|
|
142
|
+
padding-bottom: 0;
|
|
143
|
+
}
|
|
156
144
|
</style>
|
|
157
145
|
</head>
|
|
158
146
|
<body>
|
|
@@ -160,11 +148,6 @@
|
|
|
160
148
|
<img id="hero" class="hero" alt="" style="display: none" />
|
|
161
149
|
<div id="skeleton" class="skeleton"></div>
|
|
162
150
|
|
|
163
|
-
<div class="chips">
|
|
164
|
-
<span class="chip" id="kind">Image</span>
|
|
165
|
-
<span class="chip" id="gid" hidden></span>
|
|
166
|
-
</div>
|
|
167
|
-
|
|
168
151
|
<div class="actions" id="actions" hidden>
|
|
169
152
|
<button class="btn" id="download" title="Open full size" hidden>
|
|
170
153
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
@@ -178,6 +161,11 @@
|
|
|
178
161
|
</button>
|
|
179
162
|
</div>
|
|
180
163
|
|
|
164
|
+
<!-- Two lines, not one: `meta` is what produced the result, `status` is
|
|
165
|
+
when something went wrong. Sharing an element meant a preview error
|
|
166
|
+
erased the model name, or the reverse, depending on which landed
|
|
167
|
+
last. Both collapse when empty. -->
|
|
168
|
+
<div class="meta" id="meta"></div>
|
|
181
169
|
<div class="status" id="status">Connecting…</div>
|
|
182
170
|
</div>
|
|
183
171
|
<script type="module" src="./main.ts"></script>
|
package/src/image/main.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $, createCard, openLink, toggleFullscreen, type GenerationResult } from "../shared/bridge";
|
|
1
|
+
import { $, createCard, describeMeta, openLink, toggleFullscreen, type GenerationResult } from "../shared/bridge";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The image card — the light one.
|
|
@@ -12,6 +12,10 @@ const setStatus = (text: string) => {
|
|
|
12
12
|
$("status").textContent = text;
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
+
const setMeta = (text: string) => {
|
|
16
|
+
$("meta").textContent = text;
|
|
17
|
+
};
|
|
18
|
+
|
|
15
19
|
function toggle(id: string, on: boolean, title?: string) {
|
|
16
20
|
const el = $(id);
|
|
17
21
|
el.hidden = !on;
|
|
@@ -22,9 +26,9 @@ const card = createCard("Womp image", {
|
|
|
22
26
|
onTimeout: () => setStatus("Still running — ask me to check on it."),
|
|
23
27
|
onResult: (data: GenerationResult) => {
|
|
24
28
|
if (data.status === "running") {
|
|
25
|
-
$("kind").textContent = "Working";
|
|
26
29
|
$("skeleton").classList.add("pulsing");
|
|
27
30
|
$("actions").hidden = true;
|
|
31
|
+
setMeta("");
|
|
28
32
|
setStatus("Generating…");
|
|
29
33
|
return;
|
|
30
34
|
}
|
|
@@ -45,11 +49,7 @@ const card = createCard("Womp image", {
|
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
const isMesh = !!data.glbUrl;
|
|
48
|
-
|
|
49
|
-
if (data.generationId) {
|
|
50
|
-
$("gid").textContent = "#" + data.generationId;
|
|
51
|
-
$("gid").hidden = false;
|
|
52
|
-
}
|
|
52
|
+
setMeta(describeMeta(data));
|
|
53
53
|
|
|
54
54
|
// No "Make 3D" button on purpose. The card could send a user turn via
|
|
55
55
|
// app.sendMessage(), but the host shows a prompt-injection warning on any
|
package/src/mesh/index.html
CHANGED
|
@@ -74,25 +74,6 @@
|
|
|
74
74
|
.skeleton.pulsing { animation: none; }
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/* Overlays float above the image, mirroring swan-frontend's generation
|
|
78
|
-
tile rather than sitting in a bar beneath it. */
|
|
79
|
-
.chips {
|
|
80
|
-
position: absolute;
|
|
81
|
-
top: 10px;
|
|
82
|
-
left: 10px;
|
|
83
|
-
display: flex;
|
|
84
|
-
gap: 6px;
|
|
85
|
-
}
|
|
86
|
-
.chip {
|
|
87
|
-
font-size: var(--font-text-xs-size, 11px);
|
|
88
|
-
padding: 3px 8px;
|
|
89
|
-
border-radius: var(--border-radius-full, 999px);
|
|
90
|
-
background: rgba(0, 0, 0, 0.55);
|
|
91
|
-
color: #fff;
|
|
92
|
-
backdrop-filter: blur(6px);
|
|
93
|
-
white-space: nowrap;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
77
|
.actions {
|
|
97
78
|
position: absolute;
|
|
98
79
|
bottom: 10px;
|
|
@@ -158,12 +139,19 @@
|
|
|
158
139
|
/* Nothing to reveal on hover when the image fills the screen. */
|
|
159
140
|
:root[data-display-mode="fullscreen"] .actions { opacity: 1; }
|
|
160
141
|
|
|
161
|
-
.status:empty
|
|
162
|
-
.
|
|
142
|
+
.status:empty,
|
|
143
|
+
.meta:empty { display: none; }
|
|
144
|
+
.status,
|
|
145
|
+
.meta {
|
|
163
146
|
font-size: var(--font-text-xs-size, 11px);
|
|
164
147
|
color: var(--color-text-secondary, rgba(232, 232, 232, 0.6));
|
|
165
148
|
padding: 8px 12px;
|
|
166
149
|
}
|
|
150
|
+
/* When both are shown the pair reads as one block, so the meta line drops
|
|
151
|
+
its bottom padding rather than double-spacing them. */
|
|
152
|
+
.meta:has(+ .status:not(:empty)) {
|
|
153
|
+
padding-bottom: 0;
|
|
154
|
+
}
|
|
167
155
|
</style>
|
|
168
156
|
</head>
|
|
169
157
|
<body>
|
|
@@ -172,11 +160,6 @@
|
|
|
172
160
|
<img id="hero" class="hero" alt="" style="display: none" />
|
|
173
161
|
<div id="skeleton" class="skeleton"></div>
|
|
174
162
|
|
|
175
|
-
<div class="chips">
|
|
176
|
-
<span class="chip" id="kind">Image</span>
|
|
177
|
-
<span class="chip" id="gid" hidden></span>
|
|
178
|
-
</div>
|
|
179
|
-
|
|
180
163
|
<div class="actions" id="actions" hidden>
|
|
181
164
|
<button class="btn" id="download" title="Open full size" hidden>
|
|
182
165
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
|
|
@@ -190,6 +173,11 @@
|
|
|
190
173
|
</button>
|
|
191
174
|
</div>
|
|
192
175
|
|
|
176
|
+
<!-- Two lines, not one: `meta` is what produced the result, `status` is
|
|
177
|
+
when something went wrong. Sharing an element meant a preview error
|
|
178
|
+
erased the model name, or the reverse, depending on which landed
|
|
179
|
+
last. Both collapse when empty. -->
|
|
180
|
+
<div class="meta" id="meta"></div>
|
|
193
181
|
<div class="status" id="status">Connecting…</div>
|
|
194
182
|
</div>
|
|
195
183
|
<script type="module" src="./main.ts"></script>
|
package/src/mesh/main.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
Box3,
|
|
5
5
|
Color,
|
|
6
6
|
DirectionalLight,
|
|
7
|
+
Group,
|
|
7
8
|
PerspectiveCamera,
|
|
8
9
|
Scene,
|
|
9
10
|
SRGBColorSpace,
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
|
14
15
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
|
15
16
|
|
|
16
|
-
import { $, createCard, openLink, toggleFullscreen, type GenerationResult } from "../shared/bridge";
|
|
17
|
+
import { $, createCard, describeMeta, openLink, toggleFullscreen, type GenerationResult } from "../shared/bridge";
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* The mesh card — an interactive GLB viewer.
|
|
@@ -29,6 +30,10 @@ import { $, createCard, openLink, toggleFullscreen, type GenerationResult } from
|
|
|
29
30
|
* blocked request, which is a slow thing to debug.
|
|
30
31
|
*/
|
|
31
32
|
|
|
33
|
+
const setMeta = (text: string) => {
|
|
34
|
+
$("meta").textContent = text;
|
|
35
|
+
};
|
|
36
|
+
|
|
32
37
|
const setStatus = (text: string) => {
|
|
33
38
|
$("status").textContent = text;
|
|
34
39
|
};
|
|
@@ -47,9 +52,13 @@ function frame(camera: PerspectiveCamera, target: Vector3, size: number) {
|
|
|
47
52
|
camera.lookAt(target);
|
|
48
53
|
}
|
|
49
54
|
|
|
55
|
+
/** Matches swan-frontend's MeshViewer: radians per frame, paused while orbiting. */
|
|
56
|
+
const AUTO_ROTATE_STEP = 0.005;
|
|
57
|
+
|
|
50
58
|
async function mountViewer(url: string) {
|
|
51
59
|
const canvasHost = $("viewer");
|
|
52
|
-
|
|
60
|
+
// Stays hidden until the model is actually ready. Revealing it here put a
|
|
61
|
+
// second empty box under the skeleton for the whole download.
|
|
53
62
|
|
|
54
63
|
const scene = new Scene();
|
|
55
64
|
scene.background = null;
|
|
@@ -78,6 +87,16 @@ async function mountViewer(url: string) {
|
|
|
78
87
|
controls.enableDamping = true;
|
|
79
88
|
controls.enablePan = false;
|
|
80
89
|
|
|
90
|
+
// Auto-rotation yields to the user the moment they grab the model and
|
|
91
|
+
// resumes when they let go — same behaviour as swan-frontend's MeshViewer.
|
|
92
|
+
let userInteracting = false;
|
|
93
|
+
controls.addEventListener("start", () => {
|
|
94
|
+
userInteracting = true;
|
|
95
|
+
});
|
|
96
|
+
controls.addEventListener("end", () => {
|
|
97
|
+
userInteracting = false;
|
|
98
|
+
});
|
|
99
|
+
|
|
81
100
|
const resize = () => {
|
|
82
101
|
const w = canvasHost.clientWidth || 360;
|
|
83
102
|
const h = canvasHost.clientHeight || 360;
|
|
@@ -93,26 +112,33 @@ async function mountViewer(url: string) {
|
|
|
93
112
|
if (ev.total) setStatus(`Loading model… ${Math.round((ev.loaded / ev.total) * 100)}%`);
|
|
94
113
|
});
|
|
95
114
|
|
|
96
|
-
scene.add(gltf.scene);
|
|
97
|
-
|
|
98
115
|
// Recentre on the model's own bounding box: exported meshes are not reliably
|
|
99
|
-
// origin-centred, and an off-centre pivot makes orbiting feel broken.
|
|
116
|
+
// origin-centred, and an off-centre pivot makes orbiting feel broken. The
|
|
117
|
+
// pivot group is what auto-rotation spins — rotating gltf.scene itself would
|
|
118
|
+
// orbit the mesh around its original off-centre origin and wobble.
|
|
100
119
|
const box = new Box3().setFromObject(gltf.scene);
|
|
101
120
|
const centre = box.getCenter(new Vector3());
|
|
102
121
|
const size = box.getSize(new Vector3()).length();
|
|
103
122
|
gltf.scene.position.sub(centre);
|
|
123
|
+
const pivot = new Group();
|
|
124
|
+
pivot.add(gltf.scene);
|
|
125
|
+
scene.add(pivot);
|
|
104
126
|
controls.target.set(0, 0, 0);
|
|
105
127
|
frame(camera, new Vector3(0, 0, 0), size);
|
|
106
128
|
controls.update();
|
|
107
129
|
|
|
108
130
|
const loop = () => {
|
|
109
131
|
if (stopLoop) return;
|
|
132
|
+
if (!userInteracting) pivot.rotation.y += AUTO_ROTATE_STEP;
|
|
110
133
|
controls!.update();
|
|
111
134
|
renderer!.render(scene, camera);
|
|
112
135
|
requestAnimationFrame(loop);
|
|
113
136
|
};
|
|
114
137
|
loop();
|
|
115
138
|
|
|
139
|
+
// The swap: model is loaded and framed, so the placeholder gives way to the
|
|
140
|
+
// canvas in one step — never both at once.
|
|
141
|
+
canvasHost.hidden = false;
|
|
116
142
|
$("skeleton").style.display = "none";
|
|
117
143
|
setStatus("");
|
|
118
144
|
}
|
|
@@ -121,27 +147,26 @@ const card = createCard("Womp mesh", {
|
|
|
121
147
|
onTimeout: () => setStatus("Still running — ask me to check on it."),
|
|
122
148
|
onResult: (data: GenerationResult) => {
|
|
123
149
|
if (data.status === "running") {
|
|
124
|
-
$("kind").textContent = "Working";
|
|
125
150
|
$("skeleton").classList.add("pulsing");
|
|
126
151
|
$("actions").hidden = true;
|
|
152
|
+
setMeta("");
|
|
127
153
|
setStatus("Generating…");
|
|
128
154
|
return;
|
|
129
155
|
}
|
|
130
156
|
|
|
131
157
|
$("skeleton").classList.remove("pulsing");
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
158
|
+
// Replaces the old #<generationId> chip: an internal id tells the reader
|
|
159
|
+
// nothing, the time and the model do.
|
|
160
|
+
setMeta(describeMeta(data));
|
|
136
161
|
|
|
137
162
|
if (data.glbUrl) {
|
|
138
|
-
$("kind").textContent = "3D mesh";
|
|
139
163
|
$("actions").hidden = false;
|
|
140
164
|
$("download").hidden = false;
|
|
141
165
|
$("expand").hidden = false;
|
|
142
166
|
mountViewer(data.glbUrl).catch((err) => {
|
|
143
167
|
// Degrade to the preview image rather than an empty box: the GLB may be
|
|
144
168
|
// blocked by CSP, or WebGL may be unavailable in the sandbox.
|
|
169
|
+
$("viewer").hidden = true;
|
|
145
170
|
setStatus("Could not load the 3D view — " + ((err as Error)?.message ?? err));
|
|
146
171
|
showStill(data.previewUrl || data.imageUrl);
|
|
147
172
|
});
|
|
@@ -150,7 +175,6 @@ const card = createCard("Womp mesh", {
|
|
|
150
175
|
|
|
151
176
|
// get_generation can return an image; this card is also its target, so it
|
|
152
177
|
// has to cope with one.
|
|
153
|
-
$("kind").textContent = "Image";
|
|
154
178
|
$("actions").hidden = false;
|
|
155
179
|
$("download").hidden = !data.imageUrl;
|
|
156
180
|
$("expand").hidden = !data.imageUrl;
|
package/src/shared/bridge.ts
CHANGED
|
@@ -33,6 +33,27 @@ export interface GenerationResult {
|
|
|
33
33
|
imageUrl?: string;
|
|
34
34
|
glbUrl?: string;
|
|
35
35
|
previewUrl?: string;
|
|
36
|
+
/** `AiWorkflow.name`, e.g. "Nano Banana 2". */
|
|
37
|
+
model?: string;
|
|
38
|
+
/** The provider's own generation time, in seconds. */
|
|
39
|
+
durationSeconds?: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* "8.77s · Nano Banana 2" — what produced this result and how long it took,
|
|
44
|
+
* matching swan-frontend's generation tile (`OperationModelTags.tsx`), down to
|
|
45
|
+
* the two decimal places.
|
|
46
|
+
*
|
|
47
|
+
* Returns "" when the backend sent neither, which is a normal outcome: an older
|
|
48
|
+
* swan-backend does not send these fields at all, and not every provider
|
|
49
|
+
* reports a time. Both call sites treat "" as "hide the line" rather than
|
|
50
|
+
* printing a lonely separator or a bare "0s".
|
|
51
|
+
*/
|
|
52
|
+
export function describeMeta(data: GenerationResult): string {
|
|
53
|
+
const parts: string[] = [];
|
|
54
|
+
if (typeof data.durationSeconds === "number") parts.push(`${data.durationSeconds.toFixed(2)}s`);
|
|
55
|
+
if (data.model) parts.push(data.model);
|
|
56
|
+
return parts.join(" · ");
|
|
36
57
|
}
|
|
37
58
|
|
|
38
59
|
export const $ = (id: string) => document.getElementById(id) as HTMLElement;
|
|
@@ -78,6 +99,10 @@ export function createCard(name: string, hooks: CardHooks): Card {
|
|
|
78
99
|
}
|
|
79
100
|
};
|
|
80
101
|
|
|
102
|
+
// autoResize is on by default (the App constructor's third parameter defaults
|
|
103
|
+
// to `{ autoResize: true }`), which is what sends `ui/notifications/size-changed`
|
|
104
|
+
// as the card grows. Note it runs inside requestAnimationFrame, so it does not
|
|
105
|
+
// fire in a tab that is not being rendered — a harness caveat, not a bug.
|
|
81
106
|
const app = new App({ name, version: "0.0.1" }, {});
|
|
82
107
|
|
|
83
108
|
const fetchGeneration = async (operationId: number) => {
|