@unoverse-platform/studio 0.1.102 → 0.1.104
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 +4 -2
- package/screens/AtomsView.tsx +2 -1
- package/screens/ComponentsView.tsx +2 -1
- package/screens/NativeAppHost.tsx +1 -2
- package/screens/TemplatesLibraryView.tsx +14 -1
- package/screens/TemplatesView.tsx +2 -1
- package/screens/index.ts +4 -0
- package/screens/react/unoverse.tsx +281 -0
- package/vendor/base/src/agent-mcp/index.ts +1 -1
- package/vendor/base/src/agent-mcp/invoke.ts +6 -2
- package/vendor/base/src/agent-mcp/tools.ts +14 -5
- package/vendor/base/src/agent-mcp/types.ts +6 -0
- package/vendor/base/src/auth/auth.ts +90 -23
- package/vendor/base/src/definitions/load/compile/sections.ts +79 -0
- package/vendor/base/src/definitions/load/compile/template.ts +23 -2
- package/vendor/base/src/definitions/model.ts +9 -0
- package/vendor/base/src/definitions/registry/catalog.ts +6 -7
- package/vendor/base/src/executor.ts +25 -1
- package/vendor/base/src/manifests/executor/toolBridge.ts +25 -19
- package/vendor/base/src/manifests/runtime/channels/final.ts +4 -1
- package/vendor/base/src/manifests/runtime/duplex/session.ts +3 -9
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +28 -25
- package/vendor/base/src/platform/serviceCalls.ts +13 -0
- package/vendor/sdk/appEngine.ts +363 -0
- package/vendor/sdk/lib/actions.ts +36 -0
- package/vendor/sdk/lib/citations.ts +48 -0
- package/vendor/sdk/lib/core/apps.ts +18 -0
- package/vendor/sdk/lib/core/client.ts +3 -3
- package/vendor/sdk/lib/core/store.ts +6 -0
- package/vendor/sdk/lib/dom/app.ts +546 -0
- package/vendor/sdk/lib/dom/chrome.ts +125 -0
- package/vendor/sdk/lib/dom/component.ts +333 -0
- package/vendor/sdk/lib/dom/connection.ts +309 -0
- package/vendor/sdk/lib/dom/element.ts +69 -0
- package/vendor/sdk/lib/dom/isolate.ts +37 -0
- package/vendor/sdk/lib/dom/markdown.ts +388 -0
- package/vendor/sdk/lib/dom/markdownParse.ts +440 -0
- package/vendor/sdk/lib/dom/mounted.ts +29 -0
- package/vendor/sdk/lib/dom/primitives.ts +689 -0
- package/vendor/sdk/lib/dom/region.ts +195 -0
- package/vendor/sdk/lib/dom/render.ts +177 -0
- package/vendor/sdk/lib/dom/theme.ts +38 -0
- package/vendor/sdk/lib/dom/types.ts +36 -0
- package/vendor/sdk/lib/index.ts +26 -17
- package/vendor/sdk/lib/orb/canvas.ts +139 -0
- package/vendor/sdk/lib/orb/index.ts +8 -18
- package/vendor/sdk/lib/orb/presets.ts +1 -1
- package/vendor/sdk/lib/orb/{types.ts → states.ts} +0 -24
- package/vendor/sdk/lib/realtime/capture.ts +152 -0
- package/vendor/sdk/lib/realtime/playback.ts +129 -0
- package/vendor/sdk/lib/realtime/socket.ts +117 -0
- package/vendor/sdk/lib/style.ts +45 -37
- package/vendor/sdk/lib/voiceService.ts +344 -0
- package/vendor/sdk/main.ts +107 -0
- package/vendor/sdk/mcpAppBridge.ts +125 -102
- package/vendor/sdk/pageInterfaces.ts +452 -0
- package/vendor/sdk/responseView.ts +193 -0
- package/vite.config.ts +3 -3
- package/vendor/sdk/appEngine.tsx +0 -393
- package/vendor/sdk/lib/UnoverseComponent.tsx +0 -325
- package/vendor/sdk/lib/app.tsx +0 -730
- package/vendor/sdk/lib/connection.tsx +0 -433
- package/vendor/sdk/lib/isolate.tsx +0 -70
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +0 -131
- package/vendor/sdk/lib/orb/theme.ts +0 -83
- package/vendor/sdk/lib/primitives.tsx +0 -943
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +0 -237
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +0 -157
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +0 -164
- package/vendor/sdk/lib/render.tsx +0 -244
- package/vendor/sdk/lib/streamed.tsx +0 -30
- package/vendor/sdk/lib/theme.ts +0 -55
- package/vendor/sdk/lib/transition/MountArrangement.tsx +0 -71
- package/vendor/sdk/lib/transition/index.ts +0 -9
- package/vendor/sdk/lib/transition/viewTransition.ts +0 -29
- package/vendor/sdk/lib/voice.tsx +0 -422
- package/vendor/sdk/main.tsx +0 -125
- package/vendor/sdk/pageInterfaces.tsx +0 -383
- package/vendor/sdk/responseView.tsx +0 -82
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ONE COMPONENT, in plain DOM: reads a definition by URI from the server and draws it.
|
|
3
|
+
*
|
|
4
|
+
* One component, two data sources. A definition cannot tell which is drawing it:
|
|
5
|
+
*
|
|
6
|
+
* data source give it state lives in
|
|
7
|
+
* a plain object `uri` + `data` a local slice, here
|
|
8
|
+
* the live stream `store` + `chatId` + `nodeId` the store's slice
|
|
9
|
+
*
|
|
10
|
+
* It draws inside its own shadow root (./isolate) unless the host owns isolation.
|
|
11
|
+
*/
|
|
12
|
+
import type { ResolvedTheme, UnoverseDefinition, UnoverseNode } from "../core/types";
|
|
13
|
+
import type { ComponentStore } from "../core/store";
|
|
14
|
+
import { dispatchAction } from "../core/actions";
|
|
15
|
+
import { matches } from "../core/conditions";
|
|
16
|
+
import { heldPointer, stateOfUri } from "../core/apps";
|
|
17
|
+
import { keyframesCss } from "../style";
|
|
18
|
+
import type { ActionHandler } from "../actions";
|
|
19
|
+
import { setChildren, setStyle } from "./element";
|
|
20
|
+
import { isolatedRoot } from "./isolate";
|
|
21
|
+
import { mountQueue } from "./mounted";
|
|
22
|
+
import { regionView, type Leaf, type Region } from "./region";
|
|
23
|
+
import { ensureFontStylesheets, fetchTheme, type ThemeSource } from "./theme";
|
|
24
|
+
import type { Data } from "./types";
|
|
25
|
+
|
|
26
|
+
/** What reads definitions and themes: the MCP client, or anything answering the same calls. */
|
|
27
|
+
export interface DefinitionSource extends ThemeSource {
|
|
28
|
+
readDefinition(address: string): Promise<UnoverseDefinition>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ComponentOptions {
|
|
32
|
+
client: DefinitionSource;
|
|
33
|
+
/** STATIC source: the definition to draw, e.g. "unoverse://components/card". */
|
|
34
|
+
uri?: string;
|
|
35
|
+
/** STATIC source: instance data bound into the definition. */
|
|
36
|
+
data?: Data;
|
|
37
|
+
/** LIVE source: the store. With it, the address and data come from the instance. */
|
|
38
|
+
store?: ComponentStore;
|
|
39
|
+
chatId?: string;
|
|
40
|
+
nodeId?: string;
|
|
41
|
+
/** Neutral turn state merged into the data scope (e.g. `streaming`). Live source only. */
|
|
42
|
+
extraData?: Data;
|
|
43
|
+
onAction?: ActionHandler;
|
|
44
|
+
/** The resolved theme; omitted, it is fetched for the definition's org. */
|
|
45
|
+
theme?: ResolvedTheme;
|
|
46
|
+
/** Draw inside a shadow root (default true). False only for a host with its own isolation. */
|
|
47
|
+
isolate?: boolean;
|
|
48
|
+
/** A surface's single occupant fills the surface: the wrapper takes the frame's height. */
|
|
49
|
+
fill?: boolean;
|
|
50
|
+
/** What a `Place` in this definition draws; only its host knows. Without it, nothing. */
|
|
51
|
+
place?: (name: string) => Node[] | null;
|
|
52
|
+
/** The app's state, when it lives in another store (live source only). */
|
|
53
|
+
appStore?: ComponentStore;
|
|
54
|
+
doc?: Document;
|
|
55
|
+
/** False when the host steps the template with the arrow keys itself (a page, which picks
|
|
56
|
+
* the one template the keys move), so one press is never two steps. */
|
|
57
|
+
arrowKeys?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ComponentMount extends Leaf {
|
|
61
|
+
/** Change what the component is given; it draws again. */
|
|
62
|
+
update(next: Partial<ComponentOptions>): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const message = (doc: Document, text: string) => {
|
|
66
|
+
const d = doc.createElement("div");
|
|
67
|
+
d.textContent = text;
|
|
68
|
+
return d;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function mountComponent(options: ComponentOptions): ComponentMount {
|
|
72
|
+
let o: ComponentOptions = { ...options };
|
|
73
|
+
// Isolated unless a host says `false`: a prop left out arrives as `undefined` and keeps the default.
|
|
74
|
+
const isolated = () => o.isolate !== false;
|
|
75
|
+
const doc = o.doc ?? document;
|
|
76
|
+
let def: UnoverseDefinition | null = null;
|
|
77
|
+
let error: string | null = null;
|
|
78
|
+
let localSlice: Data = {};
|
|
79
|
+
let defUri: string | null = null;
|
|
80
|
+
let fetched: ResolvedTheme | null = null;
|
|
81
|
+
let themeName: string | null = null;
|
|
82
|
+
let alive = true;
|
|
83
|
+
let defaults: Data = {};
|
|
84
|
+
|
|
85
|
+
// The page elements: a message while loading, else the drawing (isolated or not).
|
|
86
|
+
let nodes: Node[] = [];
|
|
87
|
+
const iso = isolatedRoot(doc);
|
|
88
|
+
const plain = doc.createElement("div");
|
|
89
|
+
plain.style.display = "contents";
|
|
90
|
+
const keyframes = doc.createElement("style");
|
|
91
|
+
const mounted = mountQueue(() => (isolated() ? iso.host.isConnected : keyframes.isConnected));
|
|
92
|
+
const tree = regionView(doc, mounted);
|
|
93
|
+
|
|
94
|
+
// Snapshots of the store, so a change elsewhere in it does not draw this again.
|
|
95
|
+
let seen: unknown[] = [];
|
|
96
|
+
const snapshot = () =>
|
|
97
|
+
o.store ? [o.store.get(o.chatId ?? "", o.nodeId ?? ""), o.store.getType(o.chatId ?? "", o.nodeId ?? ""), o.store.getComponentUrl(o.chatId ?? "", o.nodeId ?? ""), o.appStore?.getAppState()] : [];
|
|
98
|
+
const changed = () => {
|
|
99
|
+
const now = snapshot();
|
|
100
|
+
const diff = now.some((v, i) => v !== seen[i]);
|
|
101
|
+
seen = now;
|
|
102
|
+
return diff;
|
|
103
|
+
};
|
|
104
|
+
let unsubscribe = () => {};
|
|
105
|
+
let unsubscribeApp = () => {};
|
|
106
|
+
const subscribe = () => {
|
|
107
|
+
unsubscribe();
|
|
108
|
+
unsubscribeApp();
|
|
109
|
+
unsubscribe = o.store ? o.store.subscribe(() => changed() && draw()) : () => {};
|
|
110
|
+
unsubscribeApp = o.appStore && o.appStore !== o.store ? o.appStore.subscribe(() => changed() && draw()) : () => {};
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const address = () => {
|
|
114
|
+
const { store, chatId = "", nodeId = "" } = o;
|
|
115
|
+
if (!store) return o.uri ?? null;
|
|
116
|
+
const url = store.getComponentUrl(chatId, nodeId);
|
|
117
|
+
const type = store.getType(chatId, nodeId);
|
|
118
|
+
return url ?? (type ? `unoverse://components/${type}` : null);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// ONE ACTION RULE, TWO OWNERS OF THE SLICE. `setValue` writes THIS instance's keys.
|
|
122
|
+
const handleAction: ActionHandler = (action, scope, meta) => {
|
|
123
|
+
const { store, chatId = "", nodeId = "", onAction, appStore } = o;
|
|
124
|
+
if (store) {
|
|
125
|
+
dispatchAction(action, scope, {
|
|
126
|
+
store,
|
|
127
|
+
chatId,
|
|
128
|
+
nodeId,
|
|
129
|
+
appStore,
|
|
130
|
+
// Server actions carry the INSTANCE context (which component, its current slice).
|
|
131
|
+
sendToServer: onAction ? (t, payload) => onAction(t, payload, { chatId, nodeId, type: store.getType(chatId, nodeId), state: store.get(chatId, nodeId) }) : undefined,
|
|
132
|
+
});
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
// No store, so the slice is local: a tab works in a preview with no state code around it.
|
|
136
|
+
if (action && typeof action === "object" && (action as { type?: string }).type === "setValue") {
|
|
137
|
+
const values = ((action as { values?: { key: string; value: unknown }[] }).values ?? []).filter((v) => v && typeof v.key === "string");
|
|
138
|
+
if (values.length) {
|
|
139
|
+
localSlice = { ...localSlice, ...Object.fromEntries(values.map((v) => [v.key, v.value])) };
|
|
140
|
+
draw();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
o.onAction?.(action, scope, meta);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// ARROW KEYS STEP A TEMPLATE while it holds the main place and its state says `arrows: true`,
|
|
147
|
+
// through the same setValue its own Back and Next make. Typing in a field never steps.
|
|
148
|
+
const onKey = (e: KeyboardEvent) => {
|
|
149
|
+
const d = def as (UnoverseDefinition & { kind?: string; stateTree?: { name: string; on?: string; arrows?: boolean; states?: { name: string }[] }[]; statePlaces?: Record<string, string> }) | null;
|
|
150
|
+
if (o.arrowKeys === false || d?.kind !== "template" || (e.key !== "ArrowRight" && e.key !== "ArrowLeft")) return;
|
|
151
|
+
const slice = (o.store ? sourceData() : localSlice) as Data | undefined;
|
|
152
|
+
const face = String(slice?.state ?? d.state?.state ?? "");
|
|
153
|
+
const branch = (d.stateTree ?? []).find((s) => s.name === face);
|
|
154
|
+
const steps = branch?.states?.map((s) => s.name) ?? [];
|
|
155
|
+
if (!branch?.on || !branch.arrows || steps.length < 2 || d.statePlaces?.[face] !== "main") return;
|
|
156
|
+
const t = e.target as HTMLElement | null;
|
|
157
|
+
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
|
|
158
|
+
const on = branch.on;
|
|
159
|
+
const cur = String(slice?.[on] ?? d.state?.[on] ?? steps[0]);
|
|
160
|
+
const i = steps.indexOf(cur) + (e.key === "ArrowRight" ? 1 : -1);
|
|
161
|
+
if (i < 0 || i >= steps.length) return;
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
handleAction({ type: "setValue", values: [{ key: on, value: steps[i] }] }, {});
|
|
164
|
+
};
|
|
165
|
+
doc.addEventListener("keydown", onKey);
|
|
166
|
+
|
|
167
|
+
const sourceData = (): Data | undefined => {
|
|
168
|
+
const { store, chatId = "", nodeId = "", extraData, data } = o;
|
|
169
|
+
if (store) {
|
|
170
|
+
const live = store.get(chatId, nodeId) as Data | undefined;
|
|
171
|
+
return extraData ? { ...(live ?? {}), ...extraData } : live;
|
|
172
|
+
}
|
|
173
|
+
const a = address();
|
|
174
|
+
const hashState = a ? stateOfUri(a).state : undefined;
|
|
175
|
+
return hashState && data?.state == null ? { state: hashState, ...(data ?? {}) } : data;
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const loadDefinition = (uri: string | null) => {
|
|
179
|
+
if (uri === defUri) return;
|
|
180
|
+
defUri = uri;
|
|
181
|
+
def = null;
|
|
182
|
+
error = null;
|
|
183
|
+
localSlice = {};
|
|
184
|
+
if (!uri) return;
|
|
185
|
+
const asked = uri;
|
|
186
|
+
o.client
|
|
187
|
+
.readDefinition(uri)
|
|
188
|
+
.then((d) => {
|
|
189
|
+
if (!alive || defUri !== asked) return;
|
|
190
|
+
def = d;
|
|
191
|
+
defaults = Object.fromEntries(Object.entries((d.props ?? {}) as Record<string, { default?: unknown }>).map(([k, v]) => [k, v?.default]));
|
|
192
|
+
draw();
|
|
193
|
+
})
|
|
194
|
+
.catch((e) => {
|
|
195
|
+
if (!alive || defUri !== asked) return;
|
|
196
|
+
error = String(e?.message ?? e);
|
|
197
|
+
draw();
|
|
198
|
+
});
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const loadTheme = () => {
|
|
202
|
+
if (o.theme || !def) return;
|
|
203
|
+
const name = def.org ? `${def.org}/light` : "light";
|
|
204
|
+
if (name === themeName) return;
|
|
205
|
+
themeName = name;
|
|
206
|
+
fetchTheme(o.client, name)
|
|
207
|
+
.then((t) => {
|
|
208
|
+
if (!alive || themeName !== name) return;
|
|
209
|
+
fetched = t;
|
|
210
|
+
draw();
|
|
211
|
+
})
|
|
212
|
+
.catch(() => {});
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
/** What a slot in this definition holds: a template's sections claim instances in the
|
|
216
|
+
* SAME store by the face they wear; a Place is filled by the host. TEMPLATES ONLY for
|
|
217
|
+
* claims: an ordinary component's flow slot belongs to the app's timeline. */
|
|
218
|
+
const slotFor = (theme: ResolvedTheme) => {
|
|
219
|
+
const { store, place } = o;
|
|
220
|
+
if (!((store && ((def as { sections?: unknown }).sections || (def as { held?: unknown }).held)) || place)) return undefined;
|
|
221
|
+
return (node: UnoverseNode, data: Data): Region => {
|
|
222
|
+
if (node.type === "Place") {
|
|
223
|
+
const name = String((node as { name?: unknown }).name ?? "");
|
|
224
|
+
const placed = place?.(name);
|
|
225
|
+
return placed ? { kind: "nodes", key: `place:${name}`, nodes: placed } : { kind: "none" };
|
|
226
|
+
}
|
|
227
|
+
if (!store) return { kind: "none" };
|
|
228
|
+
// WHAT A TEMPLATE HOLDS: this item's own instance, unless its state lifted it elsewhere.
|
|
229
|
+
if ((node as { hold?: unknown }).hold) {
|
|
230
|
+
const held = heldPointer(store, node, data ?? {}, { chatId: o.chatId ?? "", nodeId: o.nodeId ?? "" });
|
|
231
|
+
if (!held) return { kind: "none" };
|
|
232
|
+
const { chatId: c, nodeId: n } = store.split(held);
|
|
233
|
+
const opts = { client: o.client, store, chatId: c, nodeId: n, onAction: o.onAction, theme, appStore: o.appStore, doc };
|
|
234
|
+
return { kind: "list", key: "held", flip: false, items: [{ key: n, make: () => mountComponent(opts), refresh: (leaf: Leaf) => (leaf as ComponentMount).update(opts) }] };
|
|
235
|
+
}
|
|
236
|
+
const claim = node.select?.where;
|
|
237
|
+
const pointers = store
|
|
238
|
+
.getResponses()
|
|
239
|
+
.flatMap((r) => r.components)
|
|
240
|
+
.filter((p) => {
|
|
241
|
+
const { chatId: c, nodeId: n } = store.split(p);
|
|
242
|
+
if (n === o.nodeId) return false; // never claim the instance drawing the slot
|
|
243
|
+
return claim ? matches(claim, (store.get(c, n) ?? {}) as Data) : true;
|
|
244
|
+
})
|
|
245
|
+
.slice(0, node.select?.limit ?? undefined);
|
|
246
|
+
if (!pointers.length)
|
|
247
|
+
return node.fallback ? { kind: "tree", key: "fallback", node: node.fallback, data: {}, ctx: { theme, onAction: handleAction, defs: def!.defs } } : { kind: "none" };
|
|
248
|
+
return {
|
|
249
|
+
kind: "list",
|
|
250
|
+
key: "claimed",
|
|
251
|
+
flip: false,
|
|
252
|
+
items: pointers.map((p) => {
|
|
253
|
+
const { chatId: c, nodeId: n } = store.split(p);
|
|
254
|
+
const opts = { client: o.client, store, chatId: c, nodeId: n, onAction: o.onAction, theme, appStore: o.appStore, doc };
|
|
255
|
+
return { key: n, make: () => mountComponent(opts), refresh: (leaf: Leaf) => (leaf as ComponentMount).update(opts) };
|
|
256
|
+
}),
|
|
257
|
+
};
|
|
258
|
+
};
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// A component draws again on its own (a definition arriving, a store change), so it puts
|
|
262
|
+
// its new elements where the old ones stand: the host around it need not draw too.
|
|
263
|
+
const show = (next: Node[]) => {
|
|
264
|
+
const at = nodes[0];
|
|
265
|
+
const parent = at?.parentNode;
|
|
266
|
+
if (parent && (next.length !== nodes.length || next.some((n, i) => n !== nodes[i]))) {
|
|
267
|
+
for (const n of next) parent.insertBefore(n, at);
|
|
268
|
+
for (const n of nodes) if (!next.includes(n)) n.parentNode?.removeChild(n);
|
|
269
|
+
}
|
|
270
|
+
nodes = next;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
const draw = () => {
|
|
274
|
+
if (!alive) return;
|
|
275
|
+
seen = snapshot();
|
|
276
|
+
const a = address();
|
|
277
|
+
loadDefinition(a ? stateOfUri(a).uri : null);
|
|
278
|
+
loadTheme();
|
|
279
|
+
const theme = o.theme ?? fetched;
|
|
280
|
+
ensureFontStylesheets(theme, doc);
|
|
281
|
+
const say = (text: string) => {
|
|
282
|
+
tree.destroy();
|
|
283
|
+
show([message(doc, text)]);
|
|
284
|
+
};
|
|
285
|
+
// Loading and error hints carry NO styling: the SDK owns zero styles.
|
|
286
|
+
if (error) return say(`Unoverse error: ${error}`);
|
|
287
|
+
if (o.store && !o.store.getType(o.chatId ?? "", o.nodeId ?? "")) return say("waiting for COMPONENT_INIT…");
|
|
288
|
+
if (!def || !theme) return say(`Loading ${defUri ?? "component"}…`);
|
|
289
|
+
// Defaults beneath; the app's state, then the definition's own state, then the
|
|
290
|
+
// instance's values above; a value the component wrote itself on top (static source).
|
|
291
|
+
const merged = o.store
|
|
292
|
+
? { ...defaults, ...(o.appStore?.getAppState() ?? {}), ...(def.state ?? {}), ...(sourceData() ?? {}) }
|
|
293
|
+
: { ...defaults, ...(def.state ?? {}), ...(sourceData() ?? {}), ...localSlice };
|
|
294
|
+
const slot = slotFor(theme);
|
|
295
|
+
tree.set({ kind: "tree", key: `def:${defUri}`, node: def.root, data: merged, ctx: { theme, onAction: handleAction, defs: def.defs }, slot });
|
|
296
|
+
keyframes.textContent = keyframesCss(theme);
|
|
297
|
+
// The container mirrors the root's sizing intent: a root of `height: "full"` needs a
|
|
298
|
+
// definite chain, so it passes the host's height through; any other fits its content.
|
|
299
|
+
const fillsHost = o.fill || ((def.root as { style?: Data })?.style?.height ?? null) === "full";
|
|
300
|
+
const rootStyle = { ...(theme.root as Data), ...(fillsHost ? { height: "100%" } : {}), ...(o.fill ? { flex: "1 1 auto", minHeight: 0 } : {}) } as Record<string, string>;
|
|
301
|
+
if (isolated()) {
|
|
302
|
+
setStyle(iso.root, rootStyle);
|
|
303
|
+
setChildren(iso.root, [keyframes, ...tree.nodes]);
|
|
304
|
+
show([iso.host]);
|
|
305
|
+
} else {
|
|
306
|
+
setStyle(plain, { display: "contents", ...(theme.root as Record<string, string>) });
|
|
307
|
+
setChildren(plain, tree.nodes);
|
|
308
|
+
show([keyframes, plain]);
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
subscribe();
|
|
313
|
+
draw();
|
|
314
|
+
|
|
315
|
+
return {
|
|
316
|
+
get nodes() {
|
|
317
|
+
return nodes;
|
|
318
|
+
},
|
|
319
|
+
update(next) {
|
|
320
|
+
const storeChanged = (next.store !== undefined && next.store !== o.store) || (next.appStore !== undefined && next.appStore !== o.appStore);
|
|
321
|
+
o = { ...o, ...next };
|
|
322
|
+
if (storeChanged) subscribe();
|
|
323
|
+
draw();
|
|
324
|
+
},
|
|
325
|
+
destroy() {
|
|
326
|
+
alive = false;
|
|
327
|
+
unsubscribe();
|
|
328
|
+
unsubscribeApp();
|
|
329
|
+
doc.removeEventListener("keydown", onKey);
|
|
330
|
+
tree.destroy();
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE LIVE CONNECTION (the data plane), plain TypeScript: the workflow's MCP stream on the
|
|
3
|
+
* Unoverse server's `/stream` endpoint (Streamable-HTTP SSE). Inbound
|
|
4
|
+
* `notifications/unoverse/event` messages feed the one `ComponentStore` through the pure
|
|
5
|
+
* `applyServerMessage` mapper; outbound sends ride the same session as tool calls.
|
|
6
|
+
*
|
|
7
|
+
* Per UNOVERSE_MCP_APP_PROTOCOL.md §5/§5a/§5b: the component stream rides MCP (NOT the
|
|
8
|
+
* legacy `/ws/gravity` WS, which is reserved for live audio). The node executes in the same
|
|
9
|
+
* Unoverse process that serves this stream, so there's no gateway relay in the path.
|
|
10
|
+
*/
|
|
11
|
+
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
|
|
12
|
+
import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
|
|
13
|
+
import { emitAnalytics } from "../core/analytics";
|
|
14
|
+
import { applyServerMessage, type ServerMessage } from "../core/connection";
|
|
15
|
+
import type { ComponentStore } from "../core/store";
|
|
16
|
+
|
|
17
|
+
// Interpreter-based JSON-schema validator (no `new Function`): the default Ajv provider
|
|
18
|
+
// compiles with `new Function`, which ChatGPT's widget CSP (no unsafe-eval) blocks.
|
|
19
|
+
|
|
20
|
+
export interface UnoverseConnectionConfig {
|
|
21
|
+
/** Gravity API base, e.g. "http://localhost:4100". */
|
|
22
|
+
apiUrl: string;
|
|
23
|
+
/** Unoverse server base; the `/stream` MCP data-plane endpoint (§5b) is appended. */
|
|
24
|
+
streamUrl: string;
|
|
25
|
+
/** Optional JWT provider: the token rides the MCP transport fetch. */
|
|
26
|
+
getAccessToken?: () => Promise<string | null>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface UnoverseSessionParams {
|
|
30
|
+
workflowId: string;
|
|
31
|
+
targetTriggerNode: string;
|
|
32
|
+
userId: string;
|
|
33
|
+
conversationId: string;
|
|
34
|
+
/** Groups one request→response turn. Defaults to conversationId if omitted. */
|
|
35
|
+
chatId?: string;
|
|
36
|
+
/** The app this session shows (`<org>/<name>`). */
|
|
37
|
+
appId?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What a host reads: whether the stream is up, ready, or refused. */
|
|
41
|
+
export interface ConnectionStatus {
|
|
42
|
+
isConnected: boolean;
|
|
43
|
+
isReady: boolean;
|
|
44
|
+
/** The server's refusal text when register_session answered isError, null while
|
|
45
|
+
* unrefused. TERMINAL for the attempt: hosts surface it instead of "connecting" forever. */
|
|
46
|
+
refusal: string | null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ConnectionOptions {
|
|
50
|
+
/** An app swap is the host's call: the conversation survives it. */
|
|
51
|
+
onAppSwitch?: (app: string) => void;
|
|
52
|
+
/** A voice in the workflow asks the page to join (VOICE_STREAMING_GUIDE.md). */
|
|
53
|
+
onJoinCall?: (workflowId?: string) => void;
|
|
54
|
+
/** The app this session names when it (re)registers; read at each registration. */
|
|
55
|
+
appId?: () => string | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface UnoverseConnection {
|
|
59
|
+
status(): ConnectionStatus;
|
|
60
|
+
/** Called on every status change; returns the unsubscribe. */
|
|
61
|
+
subscribe(listener: () => void): () => void;
|
|
62
|
+
/** Composer target: optimistic user turn + the workflow trigger. */
|
|
63
|
+
sendMessage(text: string): void;
|
|
64
|
+
/** Fire the bound workflow WITHOUT a user turn (the "load → run the workflow" ping). */
|
|
65
|
+
trigger(input?: Record<string, unknown>, turnId?: string): void;
|
|
66
|
+
/** Other component actions (button clicks, etc.) → MCP `user_action` tool. */
|
|
67
|
+
sendAction(action: string, data: Record<string, unknown>): void;
|
|
68
|
+
/** Answer a pending ELICITATION with the submitted state; false when none is pending. */
|
|
69
|
+
resolveElicitation(state: Record<string, unknown>, action?: "accept" | "cancel"): boolean;
|
|
70
|
+
close(): void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The data-plane endpoint on the Unoverse server (§5b). */
|
|
74
|
+
const STREAM_ENDPOINT = "/stream";
|
|
75
|
+
/** Custom notification carrying one ServerMessage as `params` (§5b). */
|
|
76
|
+
const UNOVERSE_EVENT = "notifications/unoverse/event";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Guest identity, CONTAINED HERE so hosts never write guest logic. A token-less session is a
|
|
80
|
+
* GUEST to the public-entry gate, which only admits `guest-`-prefixed ids. Minted once per
|
|
81
|
+
* browser and persisted, so the visitor's conversation and memory cohere across reloads.
|
|
82
|
+
*/
|
|
83
|
+
const GUEST_KEY = "unoverse:guestId";
|
|
84
|
+
export function resolveGuestId(): string {
|
|
85
|
+
let id: string | null = null;
|
|
86
|
+
try {
|
|
87
|
+
id = localStorage.getItem(GUEST_KEY);
|
|
88
|
+
} catch {
|
|
89
|
+
/* storage blocked → per-load guest below */
|
|
90
|
+
}
|
|
91
|
+
if (!id || !/^guest[-:]/.test(id)) {
|
|
92
|
+
id = `guest-${crypto.randomUUID()}`;
|
|
93
|
+
try {
|
|
94
|
+
localStorage.setItem(GUEST_KEY, id);
|
|
95
|
+
} catch {
|
|
96
|
+
/* fine: the id just won't survive a reload */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return id;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* THE ONE IDENTITY RULE for every lane the SDK opens: with a token the host's userId passes
|
|
104
|
+
* through; without one a host-supplied guest id wins and anything else is replaced by the
|
|
105
|
+
* stored guest id. The data plane and the audio lane both call this.
|
|
106
|
+
*/
|
|
107
|
+
export function sessionUserId(userId: string | undefined, getAccessToken: unknown): string {
|
|
108
|
+
return getAccessToken || /^guest[-:]/.test(userId ?? "") ? (userId as string) : resolveGuestId();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Open the live connection; it reconnects on its own until `close()`. */
|
|
112
|
+
export function openConnection(config: UnoverseConnectionConfig, session: UnoverseSessionParams, store: ComponentStore, options: ConnectionOptions = {}): UnoverseConnection {
|
|
113
|
+
const { streamUrl, getAccessToken } = config;
|
|
114
|
+
const { workflowId, targetTriggerNode, conversationId } = session;
|
|
115
|
+
const userId = sessionUserId(session.userId, getAccessToken);
|
|
116
|
+
const chatId = session.chatId ?? conversationId;
|
|
117
|
+
|
|
118
|
+
let status: ConnectionStatus = { isConnected: false, isReady: false, refusal: null };
|
|
119
|
+
const listeners = new Set<() => void>();
|
|
120
|
+
const setStatus = (next: Partial<ConnectionStatus>) => {
|
|
121
|
+
const merged = { ...status, ...next };
|
|
122
|
+
if (merged.isConnected === status.isConnected && merged.isReady === status.isReady && merged.refusal === status.refusal) return;
|
|
123
|
+
status = merged;
|
|
124
|
+
listeners.forEach((l) => l());
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
let current: Client | null = null;
|
|
128
|
+
// First-seen component keys, kept ACROSS reconnects: a re-bind may re-push components
|
|
129
|
+
// this session already placed, and a repeat INIT on a seen key merges props instead.
|
|
130
|
+
const seen = new Set<string>();
|
|
131
|
+
// The pending elicitation's resolver (one at a time per session).
|
|
132
|
+
let pendingElicit: ((state: Record<string, unknown>, action: "accept" | "cancel") => void) | null = null;
|
|
133
|
+
let cancelled = false;
|
|
134
|
+
let attempt = 0;
|
|
135
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* SESSION-DEATH RECOVERY (the MCP spec's own rule): a session id the server no longer
|
|
139
|
+
* knows answers 404, and the client MUST start a NEW session. Any client error recycles
|
|
140
|
+
* the whole client and re-runs the connect sequence with backoff; register_session is
|
|
141
|
+
* idempotent and a re-bind emits SESSION_READY. Blunt on purpose: a spurious reconnect
|
|
142
|
+
* costs little, a missed session death is a dead screen.
|
|
143
|
+
*/
|
|
144
|
+
const recycle = (dead: Client, why: unknown) => {
|
|
145
|
+
// Only the CURRENT client may trigger recovery: a dying client's teardown fires strays.
|
|
146
|
+
if (cancelled || current !== dead) return;
|
|
147
|
+
current = null;
|
|
148
|
+
void dead.close().catch(() => {});
|
|
149
|
+
setStatus({ isConnected: false, isReady: false });
|
|
150
|
+
const delay = Math.min(15_000, 500 * 2 ** Math.min(attempt++, 5));
|
|
151
|
+
console.warn(`[unoverse] session lost — reconnecting in ${delay}ms (attempt ${attempt})`, why);
|
|
152
|
+
retryTimer = setTimeout(() => void connect(), delay);
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const connect = async () => {
|
|
156
|
+
if (cancelled) return;
|
|
157
|
+
// The ELICITATION capability (form mode), the native collection path (§3.3): the
|
|
158
|
+
// interactive component already on screen IS the form; its `submit` answers it.
|
|
159
|
+
const client = new Client({ name: "unoverse-channel", version: "0.0.1" }, { capabilities: { elicitation: {} }, jsonSchemaValidator: new CfWorkerJsonSchemaValidator() });
|
|
160
|
+
client.setRequestHandler("elicitation/create", async (req) => {
|
|
161
|
+
// The accept content MUST match the requestedSchema (flat primitives only): project
|
|
162
|
+
// the submitted state to the REQUESTED keys.
|
|
163
|
+
const requested = (req.params as unknown as { requestedSchema?: { properties?: Record<string, unknown> } }).requestedSchema?.properties ?? {};
|
|
164
|
+
return await new Promise((resolve) => {
|
|
165
|
+
pendingElicit = (state, action) => {
|
|
166
|
+
pendingElicit = null;
|
|
167
|
+
if (action === "cancel") return resolve({ action: "cancel" } as never);
|
|
168
|
+
const content: Record<string, unknown> = {};
|
|
169
|
+
for (const k of Object.keys(requested)) if (state[k] !== undefined) content[k] = state[k];
|
|
170
|
+
resolve({ action: "accept", content } as never);
|
|
171
|
+
};
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
// A failure of the standalone GET SSE lands only here; so does a session dying mid-life.
|
|
175
|
+
client.onerror = (e) => {
|
|
176
|
+
// TEARDOWN NOISE when the close was ours: quiet, no recovery.
|
|
177
|
+
if (cancelled || current !== client) {
|
|
178
|
+
// eslint-disable-next-line no-console
|
|
179
|
+
console.debug("[unoverse] client closed:", (e as Error)?.message ?? e);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
console.error("[unoverse] client error:", e);
|
|
183
|
+
recycle(client, e);
|
|
184
|
+
};
|
|
185
|
+
current = client;
|
|
186
|
+
|
|
187
|
+
client.fallbackNotificationHandler = async (notification) => {
|
|
188
|
+
if (notification.method !== UNOVERSE_EVENT) return;
|
|
189
|
+
const msg = (notification.params ?? {}) as unknown as ServerMessage;
|
|
190
|
+
const evt = msg as unknown as { type?: string; event?: string; params?: Record<string, unknown>; app?: unknown; workflowId?: unknown };
|
|
191
|
+
if (evt.type === "SESSION_READY") return setStatus({ isReady: true });
|
|
192
|
+
// A manifest-declared analytics moment, resolved server-side: handed to the sink, never the store.
|
|
193
|
+
if (evt.type === "ANALYTICS_EVENT") {
|
|
194
|
+
if (typeof evt.event === "string" && evt.event) emitAnalytics(evt.event, evt.params ?? {});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// AN APP SWAP IS THE HOST'S CALL: handed straight out, the store untouched.
|
|
198
|
+
if (evt.type === "APP_SWITCH") {
|
|
199
|
+
if (typeof evt.app === "string" && evt.app) options.onAppSwitch?.(evt.app);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// A VOICE IN THE WORKFLOW ASKS THE PAGE TO JOIN.
|
|
203
|
+
if (evt.type === "JOIN_CALL") {
|
|
204
|
+
options.onJoinCall?.(typeof evt.workflowId === "string" ? evt.workflowId : undefined);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
applyServerMessage(store, msg, seen);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// The bearer on every request (fresh per call → refresh-safe), and the ngrok bypass
|
|
211
|
+
// header for dev tunnels (hostname-gated, so prod requests are untouched).
|
|
212
|
+
const authFetch = async (url: string | URL, init?: RequestInit): Promise<Response> => {
|
|
213
|
+
const token = getAccessToken ? await getAccessToken() : undefined;
|
|
214
|
+
const headers = new Headers(init?.headers);
|
|
215
|
+
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
216
|
+
if (String(url).includes("ngrok")) headers.set("ngrok-skip-browser-warning", "true");
|
|
217
|
+
return fetch(url, { ...init, headers });
|
|
218
|
+
};
|
|
219
|
+
try {
|
|
220
|
+
await client.connect(new StreamableHTTPClientTransport(new URL(`${streamUrl}${STREAM_ENDPOINT}`), { fetch: authFetch }));
|
|
221
|
+
if (cancelled || current !== client) {
|
|
222
|
+
await client.close().catch(() => {});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
// A fresh attempt owns a fresh verdict.
|
|
226
|
+
setStatus({ isConnected: true, refusal: null });
|
|
227
|
+
const appId = options.appId?.();
|
|
228
|
+
const bind = await client.callTool({
|
|
229
|
+
name: "register_session",
|
|
230
|
+
arguments: { userId, conversationId, workflowId, targetTriggerNode, chatId, ...(appId ? { appId } : {}) },
|
|
231
|
+
});
|
|
232
|
+
// A refusal is an isError RESULT: surface it and STOP, never retry (the server has answered).
|
|
233
|
+
if ((bind as { isError?: boolean }).isError) {
|
|
234
|
+
const c = (bind as { content?: Array<{ type?: string; text?: string }> }).content;
|
|
235
|
+
const reason = c?.[0]?.text ?? "unauthorized";
|
|
236
|
+
console.error("[unoverse] session refused:", reason);
|
|
237
|
+
setStatus({ refusal: reason });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
// A completed re-bind is health: the next death starts its backoff fresh.
|
|
241
|
+
attempt = 0;
|
|
242
|
+
} catch (e) {
|
|
243
|
+
if (cancelled) return;
|
|
244
|
+
console.error("[unoverse] stream connect failed", e);
|
|
245
|
+
recycle(client, e);
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// TURN IDENTITY: `conversationId` names the conversation, `chatId` one turn. A fresh turn
|
|
250
|
+
// id per send; the `<conversationId>:t…` prefix keeps conversation-scoped waiters matching.
|
|
251
|
+
const mintTurn = () => `${chatId}:t${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`;
|
|
252
|
+
|
|
253
|
+
const trigger = (input?: Record<string, unknown>, turnId?: string) => {
|
|
254
|
+
const turn = turnId ?? mintTurn();
|
|
255
|
+
void (async () => {
|
|
256
|
+
// NATIVE MCP: the run fires as a `tools/call` on the live /stream session (§0.3).
|
|
257
|
+
const client = current;
|
|
258
|
+
if (!client) {
|
|
259
|
+
console.error("[unoverse] trigger dropped — MCP stream not connected yet");
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const inputEnvelope = { chatId: turn, conversationId, userId, providerId: "gravity-ds", metadata: { targetTriggerNode }, ...input };
|
|
263
|
+
try {
|
|
264
|
+
await client.callTool({ name: "trigger", arguments: { workflowId, conversationId, input: inputEnvelope } });
|
|
265
|
+
} catch (e) {
|
|
266
|
+
console.error("[unoverse] trigger failed", e);
|
|
267
|
+
}
|
|
268
|
+
})();
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
if (!userId || !conversationId) console.warn("[unoverse] connection missing userId/conversationId");
|
|
272
|
+
else void connect();
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
status: () => status,
|
|
276
|
+
subscribe(listener) {
|
|
277
|
+
listeners.add(listener);
|
|
278
|
+
return () => void listeners.delete(listener);
|
|
279
|
+
},
|
|
280
|
+
sendMessage(text) {
|
|
281
|
+
const message = text.trim();
|
|
282
|
+
if (!message) return;
|
|
283
|
+
const turn = mintTurn();
|
|
284
|
+
store.addUserMessage(turn, message); // optimistic user turn
|
|
285
|
+
// The assistant turn opens at once, so the thinking state shows on send; the later
|
|
286
|
+
// WORKFLOW_STARTED and first COMPONENT_INIT reuse this same turn.
|
|
287
|
+
store.startResponse(turn);
|
|
288
|
+
trigger({ message }, turn);
|
|
289
|
+
},
|
|
290
|
+
trigger,
|
|
291
|
+
sendAction(action, data) {
|
|
292
|
+
void current?.callTool({ name: "user_action", arguments: { action, data, userId, conversationId, chatId } }).catch((e) => console.error("[unoverse] user_action failed", e));
|
|
293
|
+
},
|
|
294
|
+
resolveElicitation(state, action = "accept") {
|
|
295
|
+
const pending = pendingElicit;
|
|
296
|
+
if (!pending) return false;
|
|
297
|
+
pending(state, action);
|
|
298
|
+
return true;
|
|
299
|
+
},
|
|
300
|
+
close() {
|
|
301
|
+
cancelled = true;
|
|
302
|
+
if (retryTimer !== null) clearTimeout(retryTimer);
|
|
303
|
+
setStatus({ isConnected: false, isReady: false, refusal: null });
|
|
304
|
+
void current?.close().catch(() => {});
|
|
305
|
+
current = null;
|
|
306
|
+
listeners.clear();
|
|
307
|
+
},
|
|
308
|
+
};
|
|
309
|
+
}
|