@unoverse-platform/studio 0.1.34 → 0.1.35
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/package.json +1 -1
- package/src/host.ts +14 -0
- package/vendor/sdk/appEngine.tsx +5 -7
- package/vendor/sdk/lib/connection.tsx +58 -2
- package/vendor/sdk/lib/core/client.ts +13 -0
- package/vendor/sdk/lib/core/index.ts +1 -0
- package/vendor/sdk/lib/core/templates.ts +55 -0
- package/vendor/sdk/lib/template.tsx +47 -0
- package/vendor/sdk/lib/voice.tsx +20 -6
- package/vendor/sdk/main.tsx +23 -36
- package/vendor/sdk/mcpAppBridge.ts +135 -0
package/package.json
CHANGED
package/src/host.ts
CHANGED
|
@@ -113,6 +113,20 @@ class LocalUnoverseClient {
|
|
|
113
113
|
return def;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/**
|
|
117
|
+
* The SYNCHRONOUS cached-definition peek (see the SDK client). Studio's `readDefinition`
|
|
118
|
+
* fetches every time and keeps no cache — deliberately, so an edit on disk shows up on
|
|
119
|
+
* the next read — so there is nothing to peek and the honest answer is `null`.
|
|
120
|
+
*
|
|
121
|
+
* `null` means "no opinion", never "no": the one caller (the latch derivation on send)
|
|
122
|
+
* treats it as "no latch", which is right here. Studio previews a definition; it has no
|
|
123
|
+
* live conversation to address a component from. Present so the structural contract holds
|
|
124
|
+
* — the SDK takes this client by shape, so a missing method is a crash, not a type error.
|
|
125
|
+
*/
|
|
126
|
+
peekDefinition(_uri: string): UnoverseDefinition | null {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
116
130
|
async readTheme(name = "light"): Promise<ResolvedTheme> {
|
|
117
131
|
const r = await fetch(`/local/theme?ref=${encodeURIComponent(name)}`);
|
|
118
132
|
if (!r.ok) throw new Error(`theme ${name}: ${r.status}`);
|
package/vendor/sdk/appEngine.tsx
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* vendored SDK (./lib), not the npm package.
|
|
10
10
|
*/
|
|
11
11
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
12
|
+
import { postToEmbedHost } from "./mcpAppBridge";
|
|
12
13
|
import {
|
|
13
14
|
UnoverseClient,
|
|
14
15
|
ComponentStore,
|
|
@@ -112,7 +113,7 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
112
113
|
setAnalyticsDebug(!!analyticsDebug);
|
|
113
114
|
setAnalyticsContext({ surface, conversation_id: conversationId, user_id: userId });
|
|
114
115
|
setAnalyticsSink((event) => {
|
|
115
|
-
|
|
116
|
+
postToEmbedHost({ type: "unoverse:event", ...event });
|
|
116
117
|
});
|
|
117
118
|
return () => setAnalyticsSink(null);
|
|
118
119
|
}, [surface, conversationId, userId, analyticsDebug]);
|
|
@@ -124,7 +125,7 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
124
125
|
const w = computeAppWidth(resolveActiveLayout(def, store), store, theme?.appSize, def);
|
|
125
126
|
if (w && w !== lastWidthRef.current) {
|
|
126
127
|
lastWidthRef.current = w;
|
|
127
|
-
|
|
128
|
+
postToEmbedHost({ type: "unoverse:size", width: w });
|
|
128
129
|
}
|
|
129
130
|
};
|
|
130
131
|
report();
|
|
@@ -153,10 +154,7 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
153
154
|
// reads "offline" forever while this session streams (observed live). Report the
|
|
154
155
|
// truth the same way size is reported; hosts that don't listen lose nothing.
|
|
155
156
|
useEffect(() => {
|
|
156
|
-
|
|
157
|
-
{ type: "unoverse:status", ready: !!conn.isReady, connected: !!conn.isConnected },
|
|
158
|
-
"*",
|
|
159
|
-
);
|
|
157
|
+
postToEmbedHost({ type: "unoverse:status", ready: !!conn.isReady, connected: !!conn.isConnected });
|
|
160
158
|
}, [conn.isReady, conn.isConnected]);
|
|
161
159
|
|
|
162
160
|
// VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
|
|
@@ -198,7 +196,7 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
198
196
|
// The app owns its close chrome (its own top-right X). Tell the embedding host to
|
|
199
197
|
// dismiss the surface (twin of `unoverse:config`/`unoverse:size`). The host decides
|
|
200
198
|
// what "close" means for its frame (slide the drawer shut); we don't assume.
|
|
201
|
-
|
|
199
|
+
postToEmbedHost({ type: "unoverse:close" });
|
|
202
200
|
return;
|
|
203
201
|
}
|
|
204
202
|
if (t === "sendMessage") {
|
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* gateway relay and no Redis in the component path.
|
|
13
13
|
*/
|
|
14
14
|
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
15
|
-
import {
|
|
15
|
+
import { emitAnalytics } from "./core/analytics";
|
|
16
|
+
import { applyServerMessage, resolveLatch, type ComponentStore, type ServerMessage } from "./core";
|
|
16
17
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
17
18
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
18
19
|
// Interpreter-based JSON-schema validator (no `new Function`). The SDK's default Ajv
|
|
@@ -169,6 +170,16 @@ export function useUnoverseConnection(
|
|
|
169
170
|
setIsReady(true);
|
|
170
171
|
return;
|
|
171
172
|
}
|
|
173
|
+
// A manifest-declared analytics moment, resolved SERVER-side beside the lifecycle
|
|
174
|
+
// hook that shares it (componentLifecycle.ts). The SDK adds nothing and decides
|
|
175
|
+
// nothing here: it hands the event to the same sink every node-attached emit uses,
|
|
176
|
+
// and the host realm delivers or drops it. News, never instruction — it does not
|
|
177
|
+
// touch the store.
|
|
178
|
+
const evt = msg as unknown as { type?: string; event?: string; params?: Record<string, unknown> };
|
|
179
|
+
if (evt.type === "ANALYTICS_EVENT") {
|
|
180
|
+
if (typeof evt.event === "string" && evt.event) emitAnalytics(evt.event, evt.params ?? {});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
172
183
|
applyServerMessage(store, msg, seenRef.current);
|
|
173
184
|
};
|
|
174
185
|
|
|
@@ -270,7 +281,52 @@ export function useUnoverseConnection(
|
|
|
270
281
|
// WORKFLOW_STARTED. startResponse is idempotent — the later WORKFLOW_STARTED
|
|
271
282
|
// (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId).
|
|
272
283
|
store.startResponse(turn);
|
|
273
|
-
|
|
284
|
+
/**
|
|
285
|
+
* THE LATCH RIDES THE ORDINARY SEND (docs/MCP_COMPLETE_GUIDE.md §The Component Latch).
|
|
286
|
+
*
|
|
287
|
+
* Resolved HERE rather than in a host, because both hosts call this one function and
|
|
288
|
+
* two hosts each deciding "what happens on send" is how the composer regression
|
|
289
|
+
* happened. The user turn is still minted normally — the guest said something and it
|
|
290
|
+
* belongs in the timeline; the latch only tells the workflow WHERE the answer lands.
|
|
291
|
+
*
|
|
292
|
+
* `state` is the component's live slice, so the workflow is told what the page
|
|
293
|
+
* currently holds without a round trip, and it stays right after local `setValue`
|
|
294
|
+
* writes the server never saw. Sent as one `latch` object so an unlatched send is
|
|
295
|
+
* byte-for-byte what it was before.
|
|
296
|
+
*/
|
|
297
|
+
/**
|
|
298
|
+
* UNFINISHED, and deliberately shipped inert rather than guessed at.
|
|
299
|
+
*
|
|
300
|
+
* `peekDefinition` is the SYNCHRONOUS cached read the send path needs (a send cannot
|
|
301
|
+
* await a resources/read), and it lives on `UnoverseClient` — the wrapper holding the
|
|
302
|
+
* definition cache, constructed in appEngine.tsx. THIS file holds a raw MCP `Client`
|
|
303
|
+
* (line 137), which has no cache and no such method, so the lookup finds nothing here.
|
|
304
|
+
*
|
|
305
|
+
* Optional-called through a narrow type so the shape is honest: null is already a
|
|
306
|
+
* legitimate answer meaning "no opinion", `resolveLatch` is not called for it, and an
|
|
307
|
+
* unlatched send is byte-for-byte what it was before. The day this ref carries the
|
|
308
|
+
* wrapper, latching starts working with no change here.
|
|
309
|
+
*/
|
|
310
|
+
const cache = clientRef.current as unknown as
|
|
311
|
+
| { peekDefinition?: (uri: string) => Parameters<typeof resolveLatch>[0] }
|
|
312
|
+
| null;
|
|
313
|
+
const tmpl = store.getActiveTemplate().name;
|
|
314
|
+
const def = tmpl ? (cache?.peekDefinition?.(`unoverse://templates/${tmpl}`) ?? null) : null;
|
|
315
|
+
const held = def ? resolveLatch(def, store) : null;
|
|
316
|
+
trigger(
|
|
317
|
+
held
|
|
318
|
+
? {
|
|
319
|
+
message,
|
|
320
|
+
latch: {
|
|
321
|
+
chatId: held.chatId,
|
|
322
|
+
nodeId: held.nodeId,
|
|
323
|
+
view: held.view,
|
|
324
|
+
state: store.get(held.chatId, held.nodeId) ?? {},
|
|
325
|
+
},
|
|
326
|
+
}
|
|
327
|
+
: { message },
|
|
328
|
+
turn,
|
|
329
|
+
);
|
|
274
330
|
},
|
|
275
331
|
[mintTurn, store, trigger],
|
|
276
332
|
);
|
|
@@ -125,6 +125,19 @@ export class UnoverseClient {
|
|
|
125
125
|
return this.withSession(() => this.client.callTool({ name, arguments: args }));
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
/**
|
|
129
|
+
* The cached definition for a URI, or null — SYNCHRONOUS, and it never fetches.
|
|
130
|
+
*
|
|
131
|
+
* For derivations that must answer INSIDE an event, where awaiting is not an option: the
|
|
132
|
+
* latch resolves on send (`sendMessage`), and a send cannot wait on a resources/read.
|
|
133
|
+
* Safe because the only caller asks about the template it is already rendering, which
|
|
134
|
+
* means `readDefinition` resolved before the first frame. Null is a legitimate answer
|
|
135
|
+
* (nothing rendered yet) and every caller must treat it as "no opinion", never as "no".
|
|
136
|
+
*/
|
|
137
|
+
peekDefinition(uri: string): UnoverseDefinition | null {
|
|
138
|
+
return this.cache.get(uri) ?? null;
|
|
139
|
+
}
|
|
140
|
+
|
|
128
141
|
/** resources/read a definition by URI (e.g. unoverse://components/card). Cached. */
|
|
129
142
|
async readDefinition(uri: string): Promise<UnoverseDefinition> {
|
|
130
143
|
const cached = this.cache.get(uri);
|
|
@@ -242,6 +242,61 @@ export function resolveActiveView(
|
|
|
242
242
|
return { view, byGuest };
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
/**
|
|
246
|
+
* THE LATCH (docs/MCP_COMPLETE_GUIDE.md §The Component Latch) — which component the
|
|
247
|
+
* conversation is currently ADDRESSED AT, or none.
|
|
248
|
+
*
|
|
249
|
+
* DERIVED, never stored, for the same reason `lifecycle` is (STATE_MODEL §3): nothing
|
|
250
|
+
* registers a latch and nothing unregisters one. A component becomes latchable by carrying
|
|
251
|
+
* `latch` on its slice (seeded from its manifest with the render); it stops holding the
|
|
252
|
+
* latch when its slice evicts or leaves the surface. There is no set, no stack, and no
|
|
253
|
+
* "latch this one too" — this function returns ONE holder or null, so two latches are not
|
|
254
|
+
* a bug to prevent but a state that cannot be represented.
|
|
255
|
+
*
|
|
256
|
+
* SAME WALK AS `resolveActiveView`, deliberately: the ladder is the ladder. A latchable
|
|
257
|
+
* slice must be sitting in one of the template's declared states to hold the latch, and
|
|
258
|
+
* the highest-ranked one wins, ties going to the most recent write. So a page retracted to
|
|
259
|
+
* `inline` (superseded by an arrival, released by `cancelBelow`, closed by the guest)
|
|
260
|
+
* silently stops holding it rather than leaving a pill pointing at something off-surface.
|
|
261
|
+
*
|
|
262
|
+
* Returns the FULL key. `chatId` alone reaches the turn, not the component inside it: a
|
|
263
|
+
* turn carrying two components would hydrate the wrong one.
|
|
264
|
+
*/
|
|
265
|
+
export function resolveLatch(
|
|
266
|
+
def: UnoverseDefinition | null | undefined,
|
|
267
|
+
store: ComponentStore,
|
|
268
|
+
dismissed?: string,
|
|
269
|
+
): { key: string; chatId: string; nodeId: string; latch: Record<string, unknown>; view: string } | null {
|
|
270
|
+
const order = def?.stateOrder?.length ? def.stateOrder : undefined;
|
|
271
|
+
if (!order) return null; // no ladder, no ranking — abstain rather than guess a holder
|
|
272
|
+
// `key` is the component key this candidate came from, carried so the return can name the
|
|
273
|
+
// FULL key (line 269) rather than re-deriving it from chatId, which reaches the turn and
|
|
274
|
+
// not the component inside it.
|
|
275
|
+
let best:
|
|
276
|
+
| { key: string; chatId: string; nodeId: string; latch: Record<string, unknown>; view: string; rank: number; at: number }
|
|
277
|
+
| null = null;
|
|
278
|
+
for (const turn of store.getResponses())
|
|
279
|
+
for (const p of turn.components) {
|
|
280
|
+
// THE ✕ IS THE ONLY EXIT, and this is it: the guest dismissed THIS holder by key, so
|
|
281
|
+
// it is passed over and the walk continues to the next candidate. Keyed rather than a
|
|
282
|
+
// bare flag so the dismissal cannot outlive the thing dismissed — a different
|
|
283
|
+
// component taking the latch is a different key and is not affected.
|
|
284
|
+
if (dismissed && dismissed === p) continue;
|
|
285
|
+
const { chatId, nodeId } = store.split(p);
|
|
286
|
+
const slice = store.get(chatId, nodeId) as Record<string, unknown>;
|
|
287
|
+
const latch = slice?.latch;
|
|
288
|
+
if (!latch || typeof latch !== "object") continue;
|
|
289
|
+
const view = viewOf(slice);
|
|
290
|
+
if (!view) continue;
|
|
291
|
+
const rank = order.indexOf(view);
|
|
292
|
+
if (rank < 0) continue; // not on the surface (inline/retracted): holds nothing
|
|
293
|
+
const at = store.touchOf(p);
|
|
294
|
+
if (!best || rank < best.rank || (rank === best.rank && at > best.at))
|
|
295
|
+
best = { key: p, chatId, nodeId, latch: latch as Record<string, unknown>, view, rank, at };
|
|
296
|
+
}
|
|
297
|
+
return best ? { key: best.key, chatId: best.chatId, nodeId: best.nodeId, latch: best.latch, view: best.view } : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
245
300
|
/** Resolve a ComponentSlot's `select` against the store → ordered pointers. */
|
|
246
301
|
export function selectPointers(store: ComponentStore, node: UnoverseNode): string[] {
|
|
247
302
|
const sel = node.select ?? {};
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
propDefaults,
|
|
24
24
|
resolveActiveLayout,
|
|
25
25
|
resolveActiveView,
|
|
26
|
+
resolveLatch,
|
|
26
27
|
selectPointers,
|
|
27
28
|
viewOf,
|
|
28
29
|
type ComponentStore,
|
|
@@ -174,6 +175,52 @@ export function StreamedUnoverseTemplate({ client, store, uri, onAction, theme:
|
|
|
174
175
|
// only bumps when it changed something, so this settles in one extra pass.
|
|
175
176
|
}, [ladder.view, ladder.byGuest, def, store, structureVersion]);
|
|
176
177
|
|
|
178
|
+
/**
|
|
179
|
+
* THE LATCH, PUBLISHED FOR CHROME (docs/MCP_COMPLETE_GUIDE.md §The Component Latch).
|
|
180
|
+
*
|
|
181
|
+
* `resolveLatch` derives WHICH instance the conversation addresses; the composer's pill
|
|
182
|
+
* is ordinary rx chrome, so it needs those facts as template state to bind against. Same
|
|
183
|
+
* shape as the voice service (§4 "services feed the state"): a producer writes derived
|
|
184
|
+
* values into the normal bucket, and `core` learns no UI concept — nothing here knows a
|
|
185
|
+
* pill exists.
|
|
186
|
+
*
|
|
187
|
+
* `latchDismissed` is written as bare `true` by the ✕ (chrome cannot name a key it does
|
|
188
|
+
* not know) and NORMALISED here to the dismissed holder's key. Keyed, so the dismissal
|
|
189
|
+
* dies with the thing dismissed: a different component taking the latch is a different
|
|
190
|
+
* key, and its pill comes up as it should rather than being suppressed by a stale flag.
|
|
191
|
+
*/
|
|
192
|
+
const tstate = store.getTemplateState() as Record<string, unknown>;
|
|
193
|
+
const dismissedRaw = tstate.latchDismissed;
|
|
194
|
+
const held = def ? resolveLatch(def, store, typeof dismissedRaw === "string" ? dismissedRaw : undefined) : null;
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (dismissedRaw === true) {
|
|
197
|
+
// Resolve the bare flag against whoever holds it RIGHT NOW, then re-derive.
|
|
198
|
+
const current = def ? resolveLatch(def, store) : null;
|
|
199
|
+
store.mergeTemplateState({ latchDismissed: current ? current.key : "" });
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const l = held?.latch as { title?: unknown } | undefined;
|
|
203
|
+
const title = typeof l?.title === "string" ? l.title : "";
|
|
204
|
+
// `latched` is the DISCRIMINANT chrome branches on, exactly as the voice service
|
|
205
|
+
// publishes `callState`: ONE field for the axis, never a truthy title plus an inverse
|
|
206
|
+
// guard on its sibling, which is the boolean soup design/state.md rules out ("a case
|
|
207
|
+
// never re-guards its own discriminant"). Title and icon are the held case's DATA, not
|
|
208
|
+
// its condition — which is why they are separate keys and not the condition itself.
|
|
209
|
+
const latched = held != null;
|
|
210
|
+
if (tstate.latched !== latched || tstate.latchTitle !== title || tstate.latchKey !== (held?.key ?? ""))
|
|
211
|
+
store.mergeTemplateState({ latched, latchTitle: title, latchKey: held?.key ?? "" });
|
|
212
|
+
}, [
|
|
213
|
+
held?.key,
|
|
214
|
+
held?.latch,
|
|
215
|
+
dismissedRaw,
|
|
216
|
+
def,
|
|
217
|
+
store,
|
|
218
|
+
tstate.latched,
|
|
219
|
+
tstate.latchTitle,
|
|
220
|
+
tstate.latchKey,
|
|
221
|
+
structureVersion,
|
|
222
|
+
]);
|
|
223
|
+
|
|
177
224
|
// Loading/error hints carry NO styling — the SDK owns zero styles.
|
|
178
225
|
if (error) return <div>Unoverse error: {error}</div>;
|
|
179
226
|
if (!def || !theme) return <div>Loading {uri}…</div>;
|
package/vendor/sdk/lib/voice.tsx
CHANGED
|
@@ -52,12 +52,12 @@ export interface UseVoiceServiceConfig {
|
|
|
52
52
|
/** The single call-phase VALUE a voice template branches its states on — the standard voice
|
|
53
53
|
* state vocabulary (`visibleWhen: { field: "callState", eq: … }`). Derived from the neutral
|
|
54
54
|
* facts below so every voice template keys off one field, exactly like `mode` for focus. */
|
|
55
|
-
export type VoiceCallState = "idle" | "active" | "
|
|
55
|
+
export type VoiceCallState = "idle" | "active" | "speaking" | "listening" | "thinking";
|
|
56
56
|
|
|
57
57
|
/** NEUTRAL voice projections — flat, so the channel can spread them into the template scope. */
|
|
58
58
|
export interface VoiceServiceState {
|
|
59
59
|
connectionStatus: VoiceConnectionStatus;
|
|
60
|
-
/** The single phase the template's states key off (idle | active |
|
|
60
|
+
/** The single phase the template's states key off (idle | active | listening | thinking | speaking). */
|
|
61
61
|
callState: VoiceCallState;
|
|
62
62
|
isIdle: boolean;
|
|
63
63
|
isCallActive: boolean;
|
|
@@ -259,14 +259,28 @@ export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
|
|
|
259
259
|
() => ({
|
|
260
260
|
connectionStatus,
|
|
261
261
|
// The one phase the template branches on — derived from the neutral facts. Order matters:
|
|
262
|
-
// agent speaking wins over user (barge-in), both win over
|
|
262
|
+
// agent speaking wins over user (barge-in), both win over a lookup, and all three win
|
|
263
|
+
// over plain "active".
|
|
264
|
+
//
|
|
265
|
+
// `thinking` sits BELOW both speaking checks because someone actually talking is the more
|
|
266
|
+
// important fact: a lookup can still be running while the assistant narrates it ("let me
|
|
267
|
+
// check that for you"), and the surface should show the speech.
|
|
268
|
+
//
|
|
269
|
+
// It sits ABOVE "active" because that is the bug it fixes. A lookup takes 8 to 9 seconds,
|
|
270
|
+
// and for all of it callState read "active" — which every voice template draws as a calm
|
|
271
|
+
// halo meaning "the floor is yours". The orb is the only progress indicator a call has,
|
|
272
|
+
// and it was saying the opposite of what was happening. `isLookingUp` was already right
|
|
273
|
+
// here in this hook, feeding the small lookup caption; it simply never reached the one
|
|
274
|
+
// field the templates branch on.
|
|
263
275
|
callState: (!isCallActive
|
|
264
276
|
? "idle"
|
|
265
277
|
: isAssistantSpeaking
|
|
266
|
-
? "
|
|
278
|
+
? "speaking"
|
|
267
279
|
: isUserSpeaking
|
|
268
|
-
? "
|
|
269
|
-
:
|
|
280
|
+
? "listening"
|
|
281
|
+
: isLookingUp
|
|
282
|
+
? "thinking"
|
|
283
|
+
: "active") as VoiceCallState,
|
|
270
284
|
isIdle: !isCallActive,
|
|
271
285
|
isCallActive,
|
|
272
286
|
isConnecting: connectionStatus === "connecting",
|
package/vendor/sdk/main.tsx
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { createRoot, type Root } from "react-dom/client";
|
|
8
8
|
import { AppEngine, type AppConfig } from "./appEngine";
|
|
9
|
+
import { connectMcpAppHost } from "./mcpAppBridge";
|
|
9
10
|
|
|
10
11
|
type HostConfig = Omit<AppConfig, "getAccessToken"> & { token?: string };
|
|
11
12
|
|
|
@@ -65,51 +66,37 @@ window.addEventListener("message", (e) => {
|
|
|
65
66
|
if (msg?.type === "unoverse:config" && msg.config) apply(msg.config);
|
|
66
67
|
});
|
|
67
68
|
|
|
68
|
-
// ──
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
keys: Object.keys(oai),
|
|
85
|
-
toolOutput: out,
|
|
86
|
-
toolResponseMetadataKeys: Object.keys(meta),
|
|
87
|
-
hasToken: Boolean(meta["unoverse/token"]),
|
|
88
|
-
});
|
|
89
|
-
// serverUrl is SERVER-PROVIDED (the tool result's `_meta.unoverse/serverUrl` = the origin
|
|
90
|
-
// that served this bundle). NEVER hardcode/guess it — a baked URL ties the bundle to one
|
|
91
|
-
// environment and hides a missing config. If it's absent, fail loudly and don't mount.
|
|
92
|
-
const serverUrl = meta["unoverse/serverUrl"] as string;
|
|
69
|
+
// ── MCP Apps host bootstrap (SEP-1865, the STANDARD door) ──────────────────
|
|
70
|
+
// An MCP Apps host (ChatGPT, Claude Desktop, Goose, …) speaks JSON-RPC over postMessage:
|
|
71
|
+
// we send `ui/initialize`, the host pushes `ui/notifications/tool-result`, and the boot
|
|
72
|
+
// facts ride the result's `_meta` (`unoverse/serverUrl` + app + token — widget-only,
|
|
73
|
+
// never seen by the model) with `structuredContent` as the belt-and-braces copy.
|
|
74
|
+
// The legacy `window.openai` config path is REMOVED (2026-08-13): ChatGPT delivers
|
|
75
|
+
// `toolResponseMetadata` empty now, non-OpenAI hosts never had the global, and two boot
|
|
76
|
+
// paths for one host class meant every failure had two explanations. One standard door;
|
|
77
|
+
// our own hosts keep `unoverse:config` / `__UNOVERSE_APP_CONFIG` above.
|
|
78
|
+
connectMcpAppHost((r) => {
|
|
79
|
+
const meta = (r._meta ?? {}) as Record<string, unknown>;
|
|
80
|
+
const out = (r.structuredContent ?? {}) as Record<string, unknown>;
|
|
81
|
+
// serverUrl is SERVER-PROVIDED (`_meta` first, structuredContent as fallback). NEVER
|
|
82
|
+
// hardcode/guess it — a baked URL ties the bundle to one environment and hides a
|
|
83
|
+
// missing config. If it's absent, fail loudly and don't mount.
|
|
84
|
+
const serverUrl = (meta["unoverse/serverUrl"] as string) || (out.serverUrl as string);
|
|
93
85
|
const templateId = (out.app as string) || (meta["unoverse/app"] as string);
|
|
94
86
|
const conversationId = (out.conversationId as string) || (meta["unoverse/conversationId"] as string);
|
|
95
87
|
const token = (meta["unoverse/token"] as string) || undefined;
|
|
96
88
|
const userId = (meta["unoverse/userId"] as string) || undefined;
|
|
97
89
|
if (!templateId || !serverUrl) {
|
|
98
90
|
// eslint-disable-next-line no-console
|
|
99
|
-
console.warn("[unoverse:boot] cannot mount —
|
|
91
|
+
console.warn("[unoverse:boot] cannot mount — tool result carried no serverUrl/app", {
|
|
100
92
|
serverUrl,
|
|
101
93
|
templateId,
|
|
102
|
-
|
|
103
|
-
|
|
94
|
+
structuredContent: out,
|
|
95
|
+
metaKeys: Object.keys(meta),
|
|
104
96
|
});
|
|
105
|
-
return
|
|
97
|
+
return;
|
|
106
98
|
}
|
|
107
99
|
// eslint-disable-next-line no-console
|
|
108
|
-
console.log("[unoverse:boot] mounting", { serverUrl, templateId, conversationId, userId, hasToken: Boolean(token) });
|
|
100
|
+
console.log("[unoverse:boot] mounting from MCP host", { serverUrl, templateId, conversationId, userId, hasToken: Boolean(token) });
|
|
109
101
|
apply({ serverUrl, templateId, conversationId, userId, token });
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
if (!bootstrapFromOpenAI()) {
|
|
113
|
-
// window.openai can be injected after the bundle loads — retry on its ready signal.
|
|
114
|
-
window.addEventListener("openai:set_globals", () => bootstrapFromOpenAI());
|
|
115
|
-
}
|
|
102
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP Apps host bridge (SEP-1865, spec 2026-01-26) — the STANDARD door.
|
|
3
|
+
*
|
|
4
|
+
* An MCP Apps host (ChatGPT, Claude Desktop, Goose, …) renders this bundle in a sandboxed
|
|
5
|
+
* iframe and speaks JSON-RPC over postMessage: the app sends `ui/initialize`, the host
|
|
6
|
+
* answers with `hostContext`, then pushes `ui/notifications/tool-input` and
|
|
7
|
+
* `ui/notifications/tool-result`. The RESULT notification carries the tool call's full
|
|
8
|
+
* `CallToolResult` — content, structuredContent and `_meta` — and `_meta` is where our
|
|
9
|
+
* server puts the boot facts (`unoverse/serverUrl`, `unoverse/app`, `unoverse/token`).
|
|
10
|
+
* This is the lane that still works everywhere: ChatGPT's legacy `window.openai` globals
|
|
11
|
+
* now arrive with `toolResponseMetadata` EMPTY (observed live 2026-08-13), and non-OpenAI
|
|
12
|
+
* hosts never had them.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately MINIMAL: initialize + the two tool notifications. No display-mode calls, no
|
|
15
|
+
* host-proxied tools/call — the SDK keeps its own /mcp + /stream connections (the CSP's
|
|
16
|
+
* connectDomains exist exactly for this). Extend only against the spec:
|
|
17
|
+
* https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Spec revision we speak. Hosts negotiate; the field is required on ui/initialize. */
|
|
21
|
+
const UI_PROTOCOL_VERSION = "2026-01-26";
|
|
22
|
+
|
|
23
|
+
type ToolResult = {
|
|
24
|
+
content?: unknown;
|
|
25
|
+
structuredContent?: Record<string, unknown>;
|
|
26
|
+
_meta?: Record<string, unknown>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type McpAppHost = {
|
|
30
|
+
/** True once the host answered ui/initialize — the signal that a STANDARD host owns this
|
|
31
|
+
* iframe, used to silence the `unoverse:*` embed-host messages (a standard host validates
|
|
32
|
+
* every inbound postMessage as JSON-RPC and logs ours as errors). */
|
|
33
|
+
connected: boolean;
|
|
34
|
+
hostContext?: Record<string, unknown>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Whether ANY MCP app host owns this iframe — standard bridge or legacy window.openai.
|
|
38
|
+
* The embed-host messages (`unoverse:status`/`size`/`event`/`close`) are for OUR OWN
|
|
39
|
+
* parent page and must not be posted at an MCP host. */
|
|
40
|
+
export function hasMcpHost(): boolean {
|
|
41
|
+
return bridge.connected || Boolean((window as { openai?: unknown }).openai);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const bridge: McpAppHost = { connected: false };
|
|
45
|
+
|
|
46
|
+
/** Post an `unoverse:*` message to OUR OWN embed host's parent page (status badge, size,
|
|
47
|
+
* close, analytics). At an MCP host these are noise — the sandbox validates every inbound
|
|
48
|
+
* postMessage as JSON-RPC and logs each of ours as an error (observed live in ChatGPT) —
|
|
49
|
+
* so the post is skipped entirely there. `window.openai` counts as host detection ONLY;
|
|
50
|
+
* no config is read from it (the legacy boot path was removed 2026-08-13). */
|
|
51
|
+
export function postToEmbedHost(msg: Record<string, unknown>): void {
|
|
52
|
+
if (hasMcpHost()) return;
|
|
53
|
+
window.parent?.postMessage(msg, "*");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Open the standard door: send ui/initialize, resolve boot facts from the first
|
|
58
|
+
* tool-result. `onToolResult` fires for EVERY result the host pushes (the first one
|
|
59
|
+
* carries the boot facts; later ones are re-renders of the same app instance).
|
|
60
|
+
*/
|
|
61
|
+
export function connectMcpAppHost(onToolResult: (r: ToolResult) => void): McpAppHost {
|
|
62
|
+
const INIT_ID = "unoverse-ui-init";
|
|
63
|
+
// DIAGNOSTIC-FIRST (main.tsx doctrine): we implement the SPEC's dialect, but the live
|
|
64
|
+
// hosts are the ground truth — log a compact line for EVERY inbound message and every
|
|
65
|
+
// handshake attempt so one browser reproduction shows exactly what the host speaks.
|
|
66
|
+
const log = (label: string, extra?: unknown) =>
|
|
67
|
+
// eslint-disable-next-line no-console
|
|
68
|
+
console.log(`[unoverse:bridge] ${label}`, extra ?? "");
|
|
69
|
+
window.addEventListener("message", (e: MessageEvent) => {
|
|
70
|
+
const m = e?.data as
|
|
71
|
+
| { jsonrpc?: string; id?: unknown; result?: unknown; error?: unknown; method?: string; params?: unknown }
|
|
72
|
+
| undefined;
|
|
73
|
+
if (m && typeof m === "object") {
|
|
74
|
+
log("inbound", {
|
|
75
|
+
jsonrpc: m.jsonrpc,
|
|
76
|
+
method: m.method,
|
|
77
|
+
id: m.id,
|
|
78
|
+
// FULL bodies, stringified: the first diagnostics run summarized these to booleans
|
|
79
|
+
// and the one fact that mattered — WHY the host rejected ui/initialize — was in the
|
|
80
|
+
// unprinted error object. Never summarize the field you are debugging.
|
|
81
|
+
result: m.result !== undefined ? JSON.stringify(m.result).slice(0, 600) : undefined,
|
|
82
|
+
error: m.error !== undefined ? JSON.stringify(m.error).slice(0, 600) : undefined,
|
|
83
|
+
params: m.params !== undefined ? JSON.stringify(m.params).slice(0, 300) : undefined,
|
|
84
|
+
ports: e.ports?.length ?? 0,
|
|
85
|
+
origin: e.origin,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (m?.jsonrpc !== "2.0") return; // not the host bridge (our own unoverse:config etc.)
|
|
89
|
+
if (m.id === INIT_ID && m.result) {
|
|
90
|
+
bridge.connected = true;
|
|
91
|
+
bridge.hostContext = (m.result as { hostContext?: Record<string, unknown> }).hostContext;
|
|
92
|
+
log("ui/initialize answered", bridge.hostContext);
|
|
93
|
+
// Complete the handshake — the reference App sends this notification after the
|
|
94
|
+
// result (ext-apps src/app.ts `connect`); hosts may hold tool-input/result until it.
|
|
95
|
+
window.parent?.postMessage({ jsonrpc: "2.0", method: "ui/notifications/initialized" }, "*");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Answer pings so the host's liveness check never times the app out.
|
|
99
|
+
if (m.method === "ping" && m.id !== undefined) {
|
|
100
|
+
(e.source as WindowProxy | null)?.postMessage({ jsonrpc: "2.0", id: m.id, result: {} }, "*");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (m.method === "ui/notifications/tool-result") {
|
|
104
|
+
// Per spec the result rides params (CallToolResult); tolerate a bare shape.
|
|
105
|
+
const p = (m.params ?? {}) as { result?: ToolResult } & ToolResult;
|
|
106
|
+
log("tool-result", { metaKeys: Object.keys((p.result ?? p)._meta ?? {}) });
|
|
107
|
+
onToolResult(p.result ?? p);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
const sendInit = () => {
|
|
111
|
+
if (bridge.connected) return;
|
|
112
|
+
log("ui/initialize →");
|
|
113
|
+
window.parent?.postMessage(
|
|
114
|
+
{
|
|
115
|
+
jsonrpc: "2.0",
|
|
116
|
+
id: INIT_ID,
|
|
117
|
+
method: "ui/initialize",
|
|
118
|
+
// Field names per the REFERENCE App (ext-apps src/app.ts), not the docs' prose:
|
|
119
|
+
// `appInfo` + `appCapabilities`. The first cut sent MCP-core's `clientInfo` +
|
|
120
|
+
// `capabilities` and ChatGPT's Zod schema answered -32603 "Invalid input".
|
|
121
|
+
params: {
|
|
122
|
+
protocolVersion: UI_PROTOCOL_VERSION,
|
|
123
|
+
appCapabilities: {},
|
|
124
|
+
appInfo: { name: "unoverse-websdk", version: "0.0.1" },
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
"*",
|
|
128
|
+
);
|
|
129
|
+
};
|
|
130
|
+
// The host's listener can attach after our module runs (web hosts boot a sandbox proxy
|
|
131
|
+
// first — `ui/notifications/sandbox-*`), so retry until answered.
|
|
132
|
+
sendInit();
|
|
133
|
+
for (const ms of [250, 1000, 3000]) setTimeout(sendInit, ms);
|
|
134
|
+
return bridge;
|
|
135
|
+
}
|