@unoverse-platform/studio 0.1.103 → 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 -395
- 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 -388
- package/vendor/sdk/responseView.tsx +0 -82
|
@@ -1,388 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE PAGE'S OWN TEMPLATES (docs/unoverse/design/sdk/UNOVERSE_HOST_PAGE.md rule 3).
|
|
3
|
-
*
|
|
4
|
-
* A website that is the app marks where its unoverse templates go:
|
|
5
|
-
*
|
|
6
|
-
* <div data-unoverse-interface="unoverse/presence-avatar"></div>
|
|
7
|
-
*
|
|
8
|
-
* This runtime is already loaded (the embed started the app with no chrome). It reads each
|
|
9
|
-
* marked template by URI over MCP, exactly as it reads the app, and draws it INTO the page's
|
|
10
|
-
* element, in a shadow DOM (UnoverseComponent's own IsolatedRoot). One runtime, many shadow
|
|
11
|
-
* roots: one client, one conversation, one sign-in for every part of the page.
|
|
12
|
-
*
|
|
13
|
-
* A template ARRIVES WITH ITS PAGE, as Studio previews it and as a delivery lands it: one
|
|
14
|
-
* instance per linked part (`static:`), at its face, with the part's own values
|
|
15
|
-
* (the served `sections` and their `seeds`), then the template itself. Drawn bare, its parts'
|
|
16
|
-
* slots hold nothing and only the frame shows.
|
|
17
|
-
*
|
|
18
|
-
* The frame shares the page's origin (EMBED_CLIENT.md §1), so the page's document is
|
|
19
|
-
* reachable from here; React portals listen for events on their own container, so a part
|
|
20
|
-
* drawn into the page is as live as one drawn in the frame.
|
|
21
|
-
*/
|
|
22
|
-
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
23
|
-
import { createPortal } from "react-dom";
|
|
24
|
-
import { ComponentStore, UnoverseComponent, type ActionHandler, type ActionMeta, type UnoverseClient, type UnoverseDefinition, type VoiceService } from "./lib";
|
|
25
|
-
|
|
26
|
-
const MARK = "data-unoverse-interface";
|
|
27
|
-
const CHAT = "page";
|
|
28
|
-
const TEMPLATE_NODE = "~template";
|
|
29
|
-
|
|
30
|
-
/** The page this runtime serves, or null when there is none it may reach (an MCP host). */
|
|
31
|
-
function pageDocument(): Document | null {
|
|
32
|
-
try {
|
|
33
|
-
return window.parent !== window ? window.parent.document : null;
|
|
34
|
-
} catch {
|
|
35
|
-
return null; // a cross-origin host: there is no page of ours to draw into
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* WHICH TEMPLATE THE ARROW KEYS BELONG TO: exactly one. The one the visitor last pressed in;
|
|
41
|
-
* before any press, the one taking the most of the screen. Every template's state is its
|
|
42
|
-
* own, so a key must never move two (the presentation's slides moved the avatar's steps).
|
|
43
|
-
*/
|
|
44
|
-
let engaged: HTMLElement | null = null;
|
|
45
|
-
function keyOwner(doc: Document): HTMLElement | null {
|
|
46
|
-
const full = doc.fullscreenElement as HTMLElement | null;
|
|
47
|
-
if (full?.hasAttribute(MARK)) return full; // a fullscreen template is the only one on screen
|
|
48
|
-
if (engaged && doc.contains(engaged)) return engaged;
|
|
49
|
-
const vh = doc.defaultView?.innerHeight ?? 0;
|
|
50
|
-
let best: HTMLElement | null = null;
|
|
51
|
-
let area = 0;
|
|
52
|
-
for (const el of doc.querySelectorAll<HTMLElement>(`[${MARK}]`)) {
|
|
53
|
-
const r = el.getBoundingClientRect();
|
|
54
|
-
const seen = Math.max(0, Math.min(r.bottom, vh) - Math.max(r.top, 0)) * r.width;
|
|
55
|
-
if (seen > area) (area = seen), (best = el);
|
|
56
|
-
}
|
|
57
|
-
return best;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** A page template's address, from the name its element is marked with. */
|
|
61
|
-
const uriOf = (name: string) => (name.includes("://") ? name : `unoverse://templates/${name}`);
|
|
62
|
-
|
|
63
|
-
/** THE AVATARS: page templates whose steps are the call's phases (`listening`, `speaking`).
|
|
64
|
-
* They are how the page is spoken to, so they are never the task in view; they are the ones
|
|
65
|
-
* that follow the call. */
|
|
66
|
-
const avatars = new Set<HTMLElement>();
|
|
67
|
-
const CALL_PHASES = ["listening", "speaking"];
|
|
68
|
-
|
|
69
|
-
/** The workflow a template names in its manifest (UNOVERSE_HOST_PAGE.md rule 4). */
|
|
70
|
-
export type Binding = { workflow?: string; trigger?: string };
|
|
71
|
-
|
|
72
|
-
/** THE TASK IN VIEW: the marked template, not an avatar, spanning the middle of the screen. */
|
|
73
|
-
function taskInView(doc: Document): HTMLElement | null {
|
|
74
|
-
const mid = (doc.defaultView?.innerHeight ?? 0) / 2;
|
|
75
|
-
for (const el of doc.querySelectorAll<HTMLElement>(`[${MARK}]`)) {
|
|
76
|
-
if (avatars.has(el)) continue;
|
|
77
|
-
const r = el.getBoundingClientRect();
|
|
78
|
-
if (r.top <= mid && r.bottom >= mid) return el;
|
|
79
|
-
}
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* THE AVATAR'S SPOT WHILE A TEMPLATE IS FULLSCREEN. The browser shows only the fullscreen
|
|
85
|
-
* element, so the avatar's own element (in the site's navigation) is hidden. A fullscreen
|
|
86
|
-
* state's layout carries a `Place`; it draws this element, and the page's avatar draws there
|
|
87
|
-
* until the place goes. It is the same avatar, so it keeps following the call.
|
|
88
|
-
*/
|
|
89
|
-
let dock: HTMLElement | null = null;
|
|
90
|
-
const dockWatchers = new Set<() => void>();
|
|
91
|
-
const setDock = (at: HTMLElement | null) => {
|
|
92
|
-
if (at === dock) return;
|
|
93
|
-
dock = at;
|
|
94
|
-
dockWatchers.forEach((f) => f());
|
|
95
|
-
};
|
|
96
|
-
const watchDock = (f: () => void) => {
|
|
97
|
-
dockWatchers.add(f);
|
|
98
|
-
return () => void dockWatchers.delete(f);
|
|
99
|
-
};
|
|
100
|
-
const drawDock = () => <div ref={setDock} />;
|
|
101
|
-
|
|
102
|
-
type Section = { kind?: string; face?: string; ref?: string; preview?: string[]; seeds?: Record<string, Record<string, unknown>> };
|
|
103
|
-
|
|
104
|
-
/** One marked template: read it, give it its parts, draw it. */
|
|
105
|
-
function PageTemplate({ client, uri, el, voice, waiting, onBinding, onCall, appStore }: { client: UnoverseClient; uri: string; el: HTMLElement; voice: VoiceService; waiting: boolean; onBinding: (uri: string, binding: Binding) => void; onCall: ActionHandler; appStore: ComponentStore }) {
|
|
106
|
-
const store = useMemo(() => new ComponentStore(), [uri]);
|
|
107
|
-
const [ready, setReady] = useState(false);
|
|
108
|
-
const partsRef = useRef<string[]>([]);
|
|
109
|
-
const defRef = useRef<{ stateTree?: { name: string; on?: string; initial?: string; states?: { name: string }[]; fullscreen?: boolean }[] } | null>(null);
|
|
110
|
-
|
|
111
|
-
useEffect(() => {
|
|
112
|
-
let alive = true;
|
|
113
|
-
client
|
|
114
|
-
.readDefinition(uri)
|
|
115
|
-
.then((def) => {
|
|
116
|
-
if (!alive || !def) return;
|
|
117
|
-
const d = def as UnoverseDefinition & { defaultLayout?: string; layouts?: Record<string, unknown>; sections?: Record<string, Section[]>; binding?: Binding };
|
|
118
|
-
if (d.binding?.workflow) onBinding(uri, d.binding);
|
|
119
|
-
defRef.current = d as typeof defRef.current;
|
|
120
|
-
const base = d.defaultLayout ?? (d.layouts ? Object.keys(d.layouts)[0] : undefined);
|
|
121
|
-
partsRef.current = [];
|
|
122
|
-
for (const sec of (base && d.sections?.[base]) || []) {
|
|
123
|
-
const parts = sec.ref ? [sec.ref] : sec.kind === "director" ? sec.preview ?? [] : [];
|
|
124
|
-
parts.forEach((ref, i) => {
|
|
125
|
-
const nodeId = `${sec.face}~${ref}~${i}`;
|
|
126
|
-
partsRef.current.push(nodeId);
|
|
127
|
-
store.apply({ type: "COMPONENT_INIT", chatId: CHAT, nodeId, component: { type: ref, props: sec.seeds?.[ref] ?? {} } });
|
|
128
|
-
if (sec.face) store.mergeComponentState(CHAT, nodeId, { state: sec.face });
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
const ref = uri.replace(/^unoverse:\/\/templates\//, "");
|
|
132
|
-
store.apply({ type: "COMPONENT_INIT", chatId: CHAT, nodeId: TEMPLATE_NODE, component: { type: ref, componentUrl: uri, props: {} } });
|
|
133
|
-
if (base) store.mergeComponentState(CHAT, TEMPLATE_NODE, { state: base });
|
|
134
|
-
if ((d.stateTree ?? []).some((t) => CALL_PHASES.every((p) => t.states?.some((x) => x.name === p)))) avatars.add(el);
|
|
135
|
-
setReady(true);
|
|
136
|
-
})
|
|
137
|
-
.catch((e) => console.warn(`[unoverse] page template "${uri}" failed to load:`, e));
|
|
138
|
-
return () => {
|
|
139
|
-
alive = false;
|
|
140
|
-
};
|
|
141
|
-
}, [client, uri, store]);
|
|
142
|
-
|
|
143
|
-
// A TEMPLATE FOLLOWS ITS TASK. Whatever runs the task publishes where it is as
|
|
144
|
-
// `tasks.<its URI>` in the app state (SIGNAL.md 6c); this template moves to that step, the
|
|
145
|
-
// same write its own Next makes, whether the move came from a voice or a chat.
|
|
146
|
-
// Its `props` too, keyed by part: `{ <part>: values }` merges into that part (a template's
|
|
147
|
-
// content lives in its parts), once per change, so a template already on the page is
|
|
148
|
-
// hydrated in place.
|
|
149
|
-
//
|
|
150
|
-
// THE SAME TEMPLATE, STREAMED. When a run renders this template, it streams into the
|
|
151
|
-
// client's store like any interface. It is already on the page, so it is not drawn again:
|
|
152
|
-
// this copy hydrates from it, each part's values onto that part. A copy still on its first
|
|
153
|
-
// step (the wait, such as `finding`) moves to the next step as its values land; nothing
|
|
154
|
-
// outside the template moves it.
|
|
155
|
-
useEffect(() => {
|
|
156
|
-
if (!ready) return;
|
|
157
|
-
let lastProps = "";
|
|
158
|
-
let lastStreamed = "";
|
|
159
|
-
const fillParts = (parts: Record<string, unknown>) => {
|
|
160
|
-
for (const [part, values] of Object.entries(parts)) {
|
|
161
|
-
if (!values || typeof values !== "object" || Array.isArray(values)) continue;
|
|
162
|
-
for (const nodeId of partsRef.current.filter((id) => id.startsWith(`${part}~`))) store.mergeComponentState(CHAT, nodeId, values as Record<string, unknown>);
|
|
163
|
-
}
|
|
164
|
-
};
|
|
165
|
-
const moveTo = (at: unknown) => {
|
|
166
|
-
if (typeof at !== "string") return;
|
|
167
|
-
const tree = defRef.current?.stateTree?.find((t) => t.on && t.states?.some((x) => x.name === at));
|
|
168
|
-
if (!tree?.on) return;
|
|
169
|
-
const tpl = store.get(CHAT, TEMPLATE_NODE) as Record<string, unknown> | undefined;
|
|
170
|
-
if (tpl?.[tree.on] !== at) store.mergeComponentState(CHAT, TEMPLATE_NODE, { [tree.on]: at });
|
|
171
|
-
};
|
|
172
|
-
const follow = () => {
|
|
173
|
-
const task = ((appStore.getAppState().tasks ?? {}) as Record<string, { step?: string; props?: Record<string, unknown> }>)[uri];
|
|
174
|
-
if (task?.props && typeof task.props === "object") {
|
|
175
|
-
const next = JSON.stringify(task.props);
|
|
176
|
-
if (next !== lastProps) {
|
|
177
|
-
lastProps = next;
|
|
178
|
-
fillParts(task.props);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
moveTo(task?.step);
|
|
182
|
-
for (const r of appStore.getResponses()) {
|
|
183
|
-
for (const pointer of r.components) {
|
|
184
|
-
const { chatId, nodeId } = appStore.split(pointer);
|
|
185
|
-
if ((appStore.getComponentUrl(chatId, nodeId) ?? "").split("#")[0] !== uri) continue;
|
|
186
|
-
const streamed = appStore.get(chatId, nodeId) ?? {};
|
|
187
|
-
const next = JSON.stringify(streamed);
|
|
188
|
-
if (next === lastStreamed) continue;
|
|
189
|
-
lastStreamed = next;
|
|
190
|
-
if (!streamed.state || typeof streamed.state !== "object") continue;
|
|
191
|
-
fillParts(streamed.state as Record<string, unknown>);
|
|
192
|
-
const tree = defRef.current?.stateTree?.find((t) => t.on && (t.states?.length ?? 0) > 1);
|
|
193
|
-
const tpl = store.get(CHAT, TEMPLATE_NODE) as Record<string, unknown> | undefined;
|
|
194
|
-
if (tree?.on && (tpl?.[tree.on] ?? tree.initial) === tree.initial) moveTo(tree.states![1].name);
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
};
|
|
198
|
-
follow();
|
|
199
|
-
return appStore.subscribe(follow);
|
|
200
|
-
}, [ready, appStore, store, uri]);
|
|
201
|
-
|
|
202
|
-
// THE ARROW KEYS STEP IT, on the PAGE. Left and Right move a template's steps (the served
|
|
203
|
-
// step tree: `on` + its substates), the same write its own Back and Next make. The keys are
|
|
204
|
-
// the page's, so they are heard on the page's document, not this frame's. Typing in a field
|
|
205
|
-
// never steps, and only the template that owns the keys moves (keyOwner).
|
|
206
|
-
useEffect(() => {
|
|
207
|
-
const doc = pageDocument();
|
|
208
|
-
if (!doc || !ready) return;
|
|
209
|
-
const onKey = (e: KeyboardEvent) => {
|
|
210
|
-
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
|
211
|
-
if (keyOwner(doc) !== el) return;
|
|
212
|
-
const t = e.target as HTMLElement | null;
|
|
213
|
-
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
|
|
214
|
-
const tpl = store.get(CHAT, TEMPLATE_NODE) as Record<string, unknown> | undefined;
|
|
215
|
-
const def = defRef.current;
|
|
216
|
-
const face = String(tpl?.state ?? "");
|
|
217
|
-
const tree = def?.stateTree?.find((s) => s.name === face);
|
|
218
|
-
const steps = tree?.states?.map((s) => s.name) ?? [];
|
|
219
|
-
if (!tree?.on || steps.length < 2) return;
|
|
220
|
-
const cur = String(tpl?.[tree.on] ?? tree.initial ?? steps[0]);
|
|
221
|
-
const i = steps.indexOf(cur) + (e.key === "ArrowRight" ? 1 : -1);
|
|
222
|
-
if (i < 0 || i >= steps.length) return;
|
|
223
|
-
e.preventDefault();
|
|
224
|
-
store.mergeComponentState(CHAT, TEMPLATE_NODE, { [tree.on]: steps[i] });
|
|
225
|
-
};
|
|
226
|
-
doc.addEventListener("keydown", onKey);
|
|
227
|
-
return () => doc.removeEventListener("keydown", onKey);
|
|
228
|
-
}, [ready, store, el]);
|
|
229
|
-
|
|
230
|
-
// FULLSCREEN IS A STATE. Entering a state marked `fullscreen` takes this template's element
|
|
231
|
-
// fullscreen; leaving it hands the screen back. The request is made inside the store write
|
|
232
|
-
// the visitor's click made (setValue notifies synchronously), because a browser grants
|
|
233
|
-
// fullscreen only to a click. Refused, or left with Esc, the template goes back to the
|
|
234
|
-
// state it came from. The step is one value across states, so the slide stays put.
|
|
235
|
-
useEffect(() => {
|
|
236
|
-
const doc = pageDocument();
|
|
237
|
-
if (!doc || !ready) return;
|
|
238
|
-
const states = () => defRef.current?.stateTree ?? [];
|
|
239
|
-
const isFull = (name: string) => states().some((t) => t.name === name && t.fullscreen);
|
|
240
|
-
const face = () => String((store.get(CHAT, TEMPLATE_NODE) as Record<string, unknown> | undefined)?.state ?? "");
|
|
241
|
-
let from = isFull(face()) ? (states().find((t) => !t.fullscreen)?.name ?? "") : face();
|
|
242
|
-
let asking = false;
|
|
243
|
-
const back = () => store.mergeComponentState(CHAT, TEMPLATE_NODE, { state: from });
|
|
244
|
-
const sync = () => {
|
|
245
|
-
const now = face();
|
|
246
|
-
if (!isFull(now)) {
|
|
247
|
-
from = now;
|
|
248
|
-
if (doc.fullscreenElement === el) void doc.exitFullscreen();
|
|
249
|
-
return;
|
|
250
|
-
}
|
|
251
|
-
if (doc.fullscreenElement === el || asking) return;
|
|
252
|
-
asking = true;
|
|
253
|
-
el.requestFullscreen()
|
|
254
|
-
.catch(back)
|
|
255
|
-
.finally(() => (asking = false));
|
|
256
|
-
};
|
|
257
|
-
const onChange = () => {
|
|
258
|
-
if (doc.fullscreenElement !== el && !asking && isFull(face())) back();
|
|
259
|
-
};
|
|
260
|
-
const stop = store.subscribe(sync);
|
|
261
|
-
doc.addEventListener("fullscreenchange", onChange);
|
|
262
|
-
return () => {
|
|
263
|
-
stop();
|
|
264
|
-
doc.removeEventListener("fullscreenchange", onChange);
|
|
265
|
-
};
|
|
266
|
-
}, [ready, store, el]);
|
|
267
|
-
|
|
268
|
-
// WHILE A TEMPLATE IS FULLSCREEN, AN AVATAR DRAWS IN ITS PLACE (drawDock).
|
|
269
|
-
const docked = useSyncExternalStore(watchDock, () => dock);
|
|
270
|
-
|
|
271
|
-
// AN AVATAR FOLLOWS THE CALL: its steps are the call's phases, so its step is the call's
|
|
272
|
-
// state. `waiting` rides beside it, on any step: a task in view that the call is not working
|
|
273
|
-
// yet. The avatar draws it as a pulse, and a press on the pulse starts or moves the call.
|
|
274
|
-
const callState = voice.state.callState;
|
|
275
|
-
useEffect(() => {
|
|
276
|
-
if (!ready || !avatars.has(el)) return;
|
|
277
|
-
const tree = defRef.current?.stateTree?.find((t) => CALL_PHASES.every((p) => t.states?.some((x) => x.name === p)));
|
|
278
|
-
if (!tree?.on) return;
|
|
279
|
-
store.mergeComponentState(CHAT, TEMPLATE_NODE, { [tree.on]: callState, waiting });
|
|
280
|
-
}, [ready, store, el, callState, waiting]);
|
|
281
|
-
|
|
282
|
-
if (!ready) return null;
|
|
283
|
-
const drawn = <UnoverseComponent client={client} store={store} chatId={CHAT} nodeId={TEMPLATE_NODE} onAction={onCall} place={drawDock} appStore={appStore} />;
|
|
284
|
-
return docked && avatars.has(el) ? createPortal(drawn, docked) : drawn;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/**
|
|
288
|
-
* ONE APP STATE FOR THE WHOLE PAGE. Each template keeps its own slice in its own store, and
|
|
289
|
-
* all of them read and write the app's state in `appStore`, the store the call and the
|
|
290
|
-
* workflow write to. A name one template saves is a name every other template can show.
|
|
291
|
-
*/
|
|
292
|
-
export function PageInterfaces({ org, client, voice, appStore, toServer, startTask }: { org: string; client: UnoverseClient; voice: VoiceService; appStore: ComponentStore; toServer: (t: string, data: Record<string, unknown>, meta?: ActionMeta) => void; startTask: (binding: Binding, task: string, submit?: Record<string, unknown>) => void }) {
|
|
293
|
-
const [targets, setTargets] = useState<HTMLElement[]>([]);
|
|
294
|
-
// The task in view, by its URI, measured again as the page scrolls or resizes.
|
|
295
|
-
const [inView, setInView] = useState<string | null>(null);
|
|
296
|
-
// Each template's own workflow, as it loads; a template without one is never called.
|
|
297
|
-
const [bindings, setBindings] = useState<Record<string, Binding>>({});
|
|
298
|
-
const onBinding = useCallback((uri: string, b: Binding) => setBindings((prev) => (prev[uri] ? prev : { ...prev, [uri]: b })), []);
|
|
299
|
-
// The tasks this page has started. Each starts once: a second press starts nothing. The ref
|
|
300
|
-
// holds it at once, so two quick presses are still one; the state redraws the avatar.
|
|
301
|
-
const started = useRef(new Set<string>());
|
|
302
|
-
const [, setStarted] = useState(0);
|
|
303
|
-
|
|
304
|
-
// THE AVATAR STARTS THE TASK'S WORKFLOW (UNOVERSE_HOST_PAGE.md rule 4), like any button. The
|
|
305
|
-
// page connects to nothing until it is pressed. A task in view with a workflow, that is not
|
|
306
|
-
// already running, is WAITING: a press starts its workflow at the template's trigger. A voice
|
|
307
|
-
// in that workflow is the workflow's own. Nothing in view, or nothing bound: nothing.
|
|
308
|
-
const target = inView && bindings[inView]?.workflow ? inView : null;
|
|
309
|
-
const waiting = !!target && !started.current.has(target);
|
|
310
|
-
// A TEMPLATE'S OWN ACTIONS go where the app's go: its `submit` answers whoever opened the task,
|
|
311
|
-
// exactly as in the app (appEngine `toServer`). A template with its own workflow also has
|
|
312
|
-
// that workflow called with the answers: the submit is the task's last call, and its Task
|
|
313
|
-
// Runner finishes it (design/tasks.md, "The submit"). The call's three are the page's.
|
|
314
|
-
const onCallFor = (uri: string): ActionHandler => (spec, data, meta) => {
|
|
315
|
-
const action = typeof spec === "string" ? spec : spec?.type;
|
|
316
|
-
if (action === "submit" || action === "cancel") {
|
|
317
|
-
toServer(action, data, meta);
|
|
318
|
-
const state = (meta?.state ?? data) as Record<string, unknown> | undefined;
|
|
319
|
-
if (action === "submit" && bindings[uri]?.workflow && state && typeof state === "object") startTask(bindings[uri], uri, state);
|
|
320
|
-
} else if (action === "startCall") {
|
|
321
|
-
if (!target || started.current.has(target)) return;
|
|
322
|
-
started.current.add(target);
|
|
323
|
-
setStarted((n) => n + 1);
|
|
324
|
-
// The press is the click the browser needs before it plays a voice the workflow may have.
|
|
325
|
-
voice.unlock();
|
|
326
|
-
startTask(bindings[target], target);
|
|
327
|
-
} else if (action === "endCall") {
|
|
328
|
-
void voice.endCall();
|
|
329
|
-
} else if (action === "toggleMute") voice.toggleMute();
|
|
330
|
-
// Another app on the page, opened or closed by its `<org>/<app>` (its own tag, EMBED_CLIENT.md).
|
|
331
|
-
else if (action === "toggleApp") {
|
|
332
|
-
const app = (data as { app?: unknown } | undefined)?.app;
|
|
333
|
-
if (typeof app === "string") (pageDocument()?.defaultView as (Window & { unoverseApps?: Record<string, { toggle(): void }> }) | undefined)?.unoverseApps?.[app]?.toggle();
|
|
334
|
-
}
|
|
335
|
-
};
|
|
336
|
-
|
|
337
|
-
useEffect(() => {
|
|
338
|
-
const doc = pageDocument();
|
|
339
|
-
const win = doc?.defaultView;
|
|
340
|
-
if (!doc || !win) return;
|
|
341
|
-
const measure = () => {
|
|
342
|
-
const name = taskInView(doc)?.getAttribute(MARK);
|
|
343
|
-
setInView(name ? uriOf(name) : null);
|
|
344
|
-
};
|
|
345
|
-
measure();
|
|
346
|
-
win.addEventListener("scroll", measure, { passive: true });
|
|
347
|
-
win.addEventListener("resize", measure);
|
|
348
|
-
return () => {
|
|
349
|
-
win.removeEventListener("scroll", measure);
|
|
350
|
-
win.removeEventListener("resize", measure);
|
|
351
|
-
};
|
|
352
|
-
}, [targets]);
|
|
353
|
-
|
|
354
|
-
useEffect(() => {
|
|
355
|
-
const doc = pageDocument();
|
|
356
|
-
if (!doc) return;
|
|
357
|
-
const scan = () => {
|
|
358
|
-
// Only this app's own org's templates: another app on the page draws its own.
|
|
359
|
-
const found = [...doc.querySelectorAll<HTMLElement>(`[${MARK}]`)].filter((el) => el.getAttribute(MARK)?.startsWith(`${org}/`));
|
|
360
|
-
setTargets((prev) => (prev.length === found.length && prev.every((el, i) => el === found[i]) ? prev : found));
|
|
361
|
-
};
|
|
362
|
-
scan();
|
|
363
|
-
// The page may render its parts after this runtime starts (a client-rendered page),
|
|
364
|
-
// so it keeps looking; only a change in the marked set re-renders.
|
|
365
|
-
const watch = new MutationObserver(scan);
|
|
366
|
-
watch.observe(doc.body, { childList: true, subtree: true });
|
|
367
|
-
// A press anywhere inside a marked template makes it the one the keys move; a press
|
|
368
|
-
// outside every template hands the keys back to the one most in view.
|
|
369
|
-
const onPress = (e: Event) => {
|
|
370
|
-
engaged = (e.composedPath().find((n) => n instanceof HTMLElement && n.hasAttribute(MARK)) as HTMLElement | undefined) ?? null;
|
|
371
|
-
};
|
|
372
|
-
doc.addEventListener("pointerdown", onPress, true);
|
|
373
|
-
return () => {
|
|
374
|
-
watch.disconnect();
|
|
375
|
-
doc.removeEventListener("pointerdown", onPress, true);
|
|
376
|
-
};
|
|
377
|
-
}, []);
|
|
378
|
-
|
|
379
|
-
return (
|
|
380
|
-
<>
|
|
381
|
-
{targets.map((el) => {
|
|
382
|
-
const name = el.getAttribute(MARK) ?? "";
|
|
383
|
-
if (!name) return null;
|
|
384
|
-
return createPortal(<PageTemplate client={client} uri={uriOf(name)} el={el} voice={voice} waiting={waiting} onBinding={onBinding} onCall={onCallFor(uriOf(name))} appStore={appStore} />, el, name);
|
|
385
|
-
})}
|
|
386
|
-
</>
|
|
387
|
-
);
|
|
388
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* THE RESPONSE VIEW — what an UNNAMED result renders (THE NAMING RULE,
|
|
3
|
-
* docs/mcp/UNOVERSE_MCP_APP_PROTOCOL § THE NAMING RULE).
|
|
4
|
-
*
|
|
5
|
-
* An MCP Action's result carries `interface: { kind: "components", components }` —
|
|
6
|
-
* the component events the run produced. This view renders EXACTLY those: seed a
|
|
7
|
-
* fresh store with the events, resolve the theme, paint each instance with the
|
|
8
|
-
* standard renderer. No app mount, no session registration, no home state, no
|
|
9
|
-
* auto-trigger — an app mounts only when something NAMES it (a channel, a row, or
|
|
10
|
-
* a result declaring `kind: "app"`).
|
|
11
|
-
*
|
|
12
|
-
* Definitions and theme still resolve over the result's serverUrl (`/mcp` reads) —
|
|
13
|
-
* the response is data; the rendering vocabulary lives on the universe.
|
|
14
|
-
*/
|
|
15
|
-
import { useMemo } from "react";
|
|
16
|
-
import {
|
|
17
|
-
UnoverseClient,
|
|
18
|
-
ComponentStore,
|
|
19
|
-
StreamedUnoverseComponent,
|
|
20
|
-
useUnoverseTheme,
|
|
21
|
-
applyServerMessage,
|
|
22
|
-
} from "./lib";
|
|
23
|
-
|
|
24
|
-
export interface ResponseViewProps {
|
|
25
|
-
serverUrl: string;
|
|
26
|
-
/** The run's component events (COMPONENT_INIT / COMPONENT_DATA), as collected
|
|
27
|
-
* server-side and carried in the result's `interface.components`. */
|
|
28
|
-
events: Record<string, unknown>[];
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function ResponseView({ serverUrl, events }: ResponseViewProps) {
|
|
32
|
-
const client = useMemo(() => new UnoverseClient(`${serverUrl}/mcp`, {}), [serverUrl]);
|
|
33
|
-
|
|
34
|
-
// Seed once per result: the same pure mapper the live stream uses, so a seeded
|
|
35
|
-
// component is indistinguishable from a streamed one (merge-not-replace and all).
|
|
36
|
-
const store = useMemo(() => {
|
|
37
|
-
const s = new ComponentStore();
|
|
38
|
-
const seen = new Set<string>();
|
|
39
|
-
for (const e of events) {
|
|
40
|
-
try {
|
|
41
|
-
applyServerMessage(s, e as never, seen);
|
|
42
|
-
} catch {
|
|
43
|
-
/* one malformed event must not blank the whole response */
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
return s;
|
|
47
|
-
}, [events]);
|
|
48
|
-
|
|
49
|
-
const theme = useUnoverseTheme(client);
|
|
50
|
-
|
|
51
|
-
// One card per INSTANCE, in arrival order (the events carry chatId+nodeId — the
|
|
52
|
-
// store's own keying — so no pointer parsing).
|
|
53
|
-
const instances = useMemo(() => {
|
|
54
|
-
const out: { chatId: string; nodeId: string }[] = [];
|
|
55
|
-
const seen = new Set<string>();
|
|
56
|
-
for (const e of events) {
|
|
57
|
-
const ev = e as { type?: string; chatId?: string; nodeId?: string };
|
|
58
|
-
if (ev.type !== "COMPONENT_INIT" || !ev.chatId || !ev.nodeId) continue;
|
|
59
|
-
const key = `${ev.chatId}:${ev.nodeId}`;
|
|
60
|
-
if (seen.has(key)) continue;
|
|
61
|
-
seen.add(key);
|
|
62
|
-
out.push({ chatId: ev.chatId, nodeId: ev.nodeId });
|
|
63
|
-
}
|
|
64
|
-
return out;
|
|
65
|
-
}, [events]);
|
|
66
|
-
|
|
67
|
-
if (!instances.length) return null;
|
|
68
|
-
return (
|
|
69
|
-
<div style={{ display: "flex", flexDirection: "column", gap: 12, padding: 12 }}>
|
|
70
|
-
{instances.map((i) => (
|
|
71
|
-
<StreamedUnoverseComponent
|
|
72
|
-
key={`${i.chatId}:${i.nodeId}`}
|
|
73
|
-
client={client}
|
|
74
|
-
store={store}
|
|
75
|
-
chatId={i.chatId}
|
|
76
|
-
nodeId={i.nodeId}
|
|
77
|
-
theme={theme ?? undefined}
|
|
78
|
-
/>
|
|
79
|
-
))}
|
|
80
|
-
</div>
|
|
81
|
-
);
|
|
82
|
-
}
|