@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
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE PAGE'S OWN TEMPLATES (docs/unoverse/design/sdk/UNOVERSE_HOST_PAGE.md rule 3), plain DOM.
|
|
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 its own shadow root. One runtime, many shadow roots: one client, one
|
|
11
|
+
* 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 (the served
|
|
15
|
+
* `sections` and their `seeds`), then the template itself. Drawn bare, its parts' slots hold
|
|
16
|
+
* 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 reachable
|
|
19
|
+
* from here, and a part drawn into the page is as live as one drawn in the frame.
|
|
20
|
+
*/
|
|
21
|
+
import { ComponentStore } from "./lib/core/store";
|
|
22
|
+
import type { UnoverseDefinition } from "./lib/core/types";
|
|
23
|
+
import type { ActionHandler, ActionMeta } from "./lib/actions";
|
|
24
|
+
import { mountComponent, type ComponentMount, type DefinitionSource } from "./lib/dom/component";
|
|
25
|
+
import type { VoiceService } from "./lib/voiceService";
|
|
26
|
+
|
|
27
|
+
const MARK = "data-unoverse-interface";
|
|
28
|
+
const CHAT = "page";
|
|
29
|
+
const TEMPLATE_NODE = "~template";
|
|
30
|
+
|
|
31
|
+
/** The page this runtime serves, or null when there is none it may reach (an MCP host). */
|
|
32
|
+
function pageDocument(): Document | null {
|
|
33
|
+
try {
|
|
34
|
+
return window.parent !== window ? window.parent.document : null;
|
|
35
|
+
} catch {
|
|
36
|
+
return null; // a cross-origin host: there is no page of ours to draw into
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* WHICH TEMPLATE THE ARROW KEYS BELONG TO: exactly one. The one the visitor last pressed in;
|
|
42
|
+
* before any press, the one taking the most of the screen. Every template's state is its own,
|
|
43
|
+
* so a key must never move two.
|
|
44
|
+
*/
|
|
45
|
+
let engaged: HTMLElement | null = null;
|
|
46
|
+
function keyOwner(doc: Document): HTMLElement | null {
|
|
47
|
+
const full = doc.fullscreenElement as HTMLElement | null;
|
|
48
|
+
if (full?.hasAttribute(MARK)) return full; // a fullscreen template is the only one on screen
|
|
49
|
+
if (engaged && doc.contains(engaged)) return engaged;
|
|
50
|
+
const vh = doc.defaultView?.innerHeight ?? 0;
|
|
51
|
+
let best: HTMLElement | null = null;
|
|
52
|
+
let area = 0;
|
|
53
|
+
for (const el of doc.querySelectorAll<HTMLElement>(`[${MARK}]`)) {
|
|
54
|
+
const r = el.getBoundingClientRect();
|
|
55
|
+
const seen = Math.max(0, Math.min(r.bottom, vh) - Math.max(r.top, 0)) * r.width;
|
|
56
|
+
if (seen > area) (area = seen), (best = el);
|
|
57
|
+
}
|
|
58
|
+
return best;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A page template's address, from the name its element is marked with. */
|
|
62
|
+
const uriOf = (name: string) => (name.includes("://") ? name : `unoverse://templates/${name}`);
|
|
63
|
+
|
|
64
|
+
/** THE AVATARS: page templates whose steps are the call's phases (`listening`, `speaking`).
|
|
65
|
+
* They are how the page is spoken to, so they are never the task in view; they 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
|
+
type Section = { kind?: string; face?: string; ref?: string; preview?: string[]; seeds?: Record<string, Record<string, unknown>> };
|
|
84
|
+
type StateTree = { name: string; on?: string; initial?: string; states?: { name: string }[]; fullscreen?: boolean; arrows?: boolean }[];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* THE AVATAR'S SPOT WHILE A TEMPLATE IS FULLSCREEN. The browser shows only the fullscreen
|
|
88
|
+
* element, so the avatar's own element (in the site's navigation) is hidden. A fullscreen
|
|
89
|
+
* state's layout carries a `Place`; it draws this element, and the page's avatar draws there
|
|
90
|
+
* until the place goes. It is the same avatar, so it keeps following the call.
|
|
91
|
+
*/
|
|
92
|
+
class Dock {
|
|
93
|
+
readonly slot: HTMLDivElement;
|
|
94
|
+
at: HTMLElement | null = null;
|
|
95
|
+
private watchers = new Set<() => void>();
|
|
96
|
+
constructor(doc: Document) {
|
|
97
|
+
this.slot = doc.createElement("div");
|
|
98
|
+
}
|
|
99
|
+
/** Whether a place is drawing the slot now; checked after any template draws. */
|
|
100
|
+
check(): void {
|
|
101
|
+
queueMicrotask(() => {
|
|
102
|
+
const at = this.slot.isConnected ? this.slot : null;
|
|
103
|
+
if (at === this.at) return;
|
|
104
|
+
this.at = at;
|
|
105
|
+
this.watchers.forEach((f) => f());
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
watch(f: () => void): () => void {
|
|
109
|
+
this.watchers.add(f);
|
|
110
|
+
return () => void this.watchers.delete(f);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
interface PageTemplateOptions {
|
|
115
|
+
client: DefinitionSource;
|
|
116
|
+
uri: string;
|
|
117
|
+
el: HTMLElement;
|
|
118
|
+
voice: VoiceService;
|
|
119
|
+
appStore: ComponentStore;
|
|
120
|
+
dock: Dock;
|
|
121
|
+
waiting: () => boolean;
|
|
122
|
+
onBinding: (uri: string, binding: Binding) => void;
|
|
123
|
+
onCall: ActionHandler;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One marked template: read it, give it its parts, draw it. */
|
|
127
|
+
function mountPageTemplate(o: PageTemplateOptions): { refresh(): void; destroy(): void } {
|
|
128
|
+
const { client, uri, el, voice, appStore, dock } = o;
|
|
129
|
+
const store = new ComponentStore();
|
|
130
|
+
const stops: (() => void)[] = [];
|
|
131
|
+
let parts: string[] = [];
|
|
132
|
+
let def: { stateTree?: StateTree } | null = null;
|
|
133
|
+
let comp: ComponentMount | null = null;
|
|
134
|
+
let alive = true;
|
|
135
|
+
|
|
136
|
+
// Where the template draws: its element, or the dock while a fullscreen template holds one
|
|
137
|
+
// and this template is an avatar.
|
|
138
|
+
const place = () => {
|
|
139
|
+
if (!comp) return;
|
|
140
|
+
const into = dock.at && avatars.has(el) ? dock.at : el;
|
|
141
|
+
for (const n of comp.nodes) if (n.parentNode !== into) into.appendChild(n);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const ready = () => {
|
|
145
|
+
comp = mountComponent({ client, store, chatId: CHAT, nodeId: TEMPLATE_NODE, onAction: o.onCall, place: () => [dock.slot], appStore, doc: el.ownerDocument, arrowKeys: false });
|
|
146
|
+
place();
|
|
147
|
+
stops.push(dock.watch(place), store.subscribe(() => dock.check()));
|
|
148
|
+
dock.check();
|
|
149
|
+
|
|
150
|
+
// A TEMPLATE FOLLOWS ITS TASK. Whatever runs the task publishes where it is as
|
|
151
|
+
// `tasks.<its URI>` in the app state (SIGNAL.md 6c): this template moves to that step, and its
|
|
152
|
+
// `props`, keyed by part, merge into those parts. THE SAME TEMPLATE, STREAMED, hydrates this
|
|
153
|
+
// copy part by part; a copy still on its first step moves to the next as its values land.
|
|
154
|
+
let lastProps = "";
|
|
155
|
+
let lastStreamed = "";
|
|
156
|
+
const lastPart = new Map<string, string>();
|
|
157
|
+
const fillParts = (byPart: Record<string, unknown>) => {
|
|
158
|
+
for (const [part, values] of Object.entries(byPart)) {
|
|
159
|
+
if (!values || typeof values !== "object" || Array.isArray(values)) continue;
|
|
160
|
+
for (const nodeId of parts.filter((id) => id.startsWith(`${part}~`))) store.mergeComponentState(CHAT, nodeId, values as Record<string, unknown>);
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
const moveTo = (at: unknown) => {
|
|
164
|
+
if (typeof at !== "string") return;
|
|
165
|
+
const tree = def?.stateTree?.find((t) => t.on && t.states?.some((x) => x.name === at));
|
|
166
|
+
if (!tree?.on) return;
|
|
167
|
+
const tpl = store.get(CHAT, TEMPLATE_NODE) as Record<string, unknown> | undefined;
|
|
168
|
+
if (tpl?.[tree.on] !== at) store.mergeComponentState(CHAT, TEMPLATE_NODE, { [tree.on]: at });
|
|
169
|
+
};
|
|
170
|
+
const follow = () => {
|
|
171
|
+
const task = ((appStore.getAppState().tasks ?? {}) as Record<string, { step?: string; props?: Record<string, unknown> }>)[uri];
|
|
172
|
+
if (task?.props && typeof task.props === "object") {
|
|
173
|
+
const next = JSON.stringify(task.props);
|
|
174
|
+
if (next !== lastProps) {
|
|
175
|
+
lastProps = next;
|
|
176
|
+
fillParts(task.props);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
moveTo(task?.step);
|
|
180
|
+
for (const r of appStore.getResponses()) {
|
|
181
|
+
for (const pointer of r.components) {
|
|
182
|
+
const { chatId, nodeId } = appStore.split(pointer);
|
|
183
|
+
// A PART FOLLOWS THE COMPONENT OF ITS NAME, STREAMED (an Agent's agent-card); it keeps its own face.
|
|
184
|
+
const kind = (appStore.typeOf(pointer) ?? "").split("/").pop() ?? "";
|
|
185
|
+
if (kind && parts.some((id) => id.startsWith(`${kind}~`))) {
|
|
186
|
+
const { state: _face, ...values } = (appStore.get(chatId, nodeId) ?? {}) as Record<string, unknown>;
|
|
187
|
+
const next = JSON.stringify(values);
|
|
188
|
+
if (lastPart.get(kind) !== next) {
|
|
189
|
+
lastPart.set(kind, next);
|
|
190
|
+
fillParts({ [kind]: values });
|
|
191
|
+
}
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if ((appStore.getComponentUrl(chatId, nodeId) ?? "").split("#")[0] !== uri) continue;
|
|
195
|
+
const streamed = appStore.get(chatId, nodeId) ?? {};
|
|
196
|
+
const next = JSON.stringify(streamed);
|
|
197
|
+
if (next === lastStreamed) continue;
|
|
198
|
+
lastStreamed = next;
|
|
199
|
+
// A copy with no part filled yet changes nothing, so the template stays on its first step.
|
|
200
|
+
if (!parts.some((id) => { const v = (streamed as Record<string, unknown>)[id.split("~")[0]]; return !!v && typeof v === "object" && !Array.isArray(v); })) continue;
|
|
201
|
+
fillParts(streamed as Record<string, unknown>);
|
|
202
|
+
const tree = def?.stateTree?.find((t) => t.on && (t.states?.length ?? 0) > 1);
|
|
203
|
+
const tpl = store.get(CHAT, TEMPLATE_NODE) as Record<string, unknown> | undefined;
|
|
204
|
+
if (tree?.on && (tpl?.[tree.on] ?? tree.initial) === tree.initial) moveTo(tree.states![1].name);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
follow();
|
|
209
|
+
stops.push(appStore.subscribe(follow));
|
|
210
|
+
|
|
211
|
+
const doc = pageDocument();
|
|
212
|
+
if (doc) {
|
|
213
|
+
// THE ARROW KEYS STEP IT, on the PAGE (the keys are the page's), when its state says
|
|
214
|
+
// `arrows: true`. Typing never steps, and only the template that owns the keys moves.
|
|
215
|
+
const onKey = (e: KeyboardEvent) => {
|
|
216
|
+
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
|
217
|
+
if (keyOwner(doc) !== el) return;
|
|
218
|
+
const t = e.target as HTMLElement | null;
|
|
219
|
+
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
|
|
220
|
+
const tpl = store.get(CHAT, TEMPLATE_NODE) as Record<string, unknown> | undefined;
|
|
221
|
+
const face = String(tpl?.state ?? "");
|
|
222
|
+
const tree = def?.stateTree?.find((s) => s.name === face);
|
|
223
|
+
const steps = tree?.states?.map((s) => s.name) ?? [];
|
|
224
|
+
if (!tree?.on || !tree.arrows || steps.length < 2) return;
|
|
225
|
+
const cur = String(tpl?.[tree.on] ?? tree.initial ?? steps[0]);
|
|
226
|
+
const i = steps.indexOf(cur) + (e.key === "ArrowRight" ? 1 : -1);
|
|
227
|
+
if (i < 0 || i >= steps.length) return;
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
store.mergeComponentState(CHAT, TEMPLATE_NODE, { [tree.on]: steps[i] });
|
|
230
|
+
};
|
|
231
|
+
doc.addEventListener("keydown", onKey);
|
|
232
|
+
stops.push(() => doc.removeEventListener("keydown", onKey));
|
|
233
|
+
|
|
234
|
+
// FULLSCREEN IS A STATE. Entering a state marked `fullscreen` takes this element
|
|
235
|
+
// fullscreen, inside the store write the visitor's click made (a browser grants fullscreen
|
|
236
|
+
// only to a click); leaving it hands the screen back. Refused, or left with Esc, the
|
|
237
|
+
// template goes back to the state it came from.
|
|
238
|
+
const states = () => def?.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
|
+
stops.push(store.subscribe(sync));
|
|
261
|
+
doc.addEventListener("fullscreenchange", onChange);
|
|
262
|
+
stops.push(() => doc.removeEventListener("fullscreenchange", onChange));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// AN AVATAR FOLLOWS THE CALL: its steps are the call's phases, so its step is the call's
|
|
266
|
+
// state; `waiting` rides beside it (a task in view the call is not working yet).
|
|
267
|
+
if (avatars.has(el)) {
|
|
268
|
+
const tree = def?.stateTree?.find((t) => CALL_PHASES.every((p) => t.states?.some((x) => x.name === p)));
|
|
269
|
+
if (tree?.on) {
|
|
270
|
+
const on = tree.on;
|
|
271
|
+
let last = "";
|
|
272
|
+
const call = () => {
|
|
273
|
+
const next = JSON.stringify([voice.state.callState, o.waiting()]);
|
|
274
|
+
if (next === last) return;
|
|
275
|
+
last = next;
|
|
276
|
+
store.mergeComponentState(CHAT, TEMPLATE_NODE, { [on]: voice.state.callState, waiting: o.waiting() });
|
|
277
|
+
};
|
|
278
|
+
call();
|
|
279
|
+
stops.push(voice.subscribe(call));
|
|
280
|
+
refreshCall = call;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
let refreshCall = () => {};
|
|
285
|
+
|
|
286
|
+
client
|
|
287
|
+
.readDefinition(uri)
|
|
288
|
+
.then((raw) => {
|
|
289
|
+
if (!alive || !raw) return;
|
|
290
|
+
const d = raw as UnoverseDefinition & { defaultLayout?: string; layouts?: Record<string, unknown>; sections?: Record<string, Section[]>; binding?: Binding; stateTree?: StateTree };
|
|
291
|
+
if (d.binding?.workflow) o.onBinding(uri, d.binding);
|
|
292
|
+
def = d;
|
|
293
|
+
const base = d.defaultLayout ?? (d.layouts ? Object.keys(d.layouts)[0] : undefined);
|
|
294
|
+
parts = [];
|
|
295
|
+
for (const sec of (base && d.sections?.[base]) || []) {
|
|
296
|
+
const refs = sec.ref ? [sec.ref] : sec.kind === "director" ? (sec.preview ?? []) : [];
|
|
297
|
+
refs.forEach((ref, i) => {
|
|
298
|
+
const nodeId = `${sec.face}~${ref}~${i}`;
|
|
299
|
+
parts.push(nodeId);
|
|
300
|
+
store.apply({ type: "COMPONENT_INIT", chatId: CHAT, nodeId, component: { type: ref, props: sec.seeds?.[ref] ?? {} } });
|
|
301
|
+
if (sec.face) store.mergeComponentState(CHAT, nodeId, { state: sec.face });
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
const ref = uri.replace(/^unoverse:\/\/templates\//, "");
|
|
305
|
+
store.apply({ type: "COMPONENT_INIT", chatId: CHAT, nodeId: TEMPLATE_NODE, component: { type: ref, componentUrl: uri, props: {} } });
|
|
306
|
+
if (base) store.mergeComponentState(CHAT, TEMPLATE_NODE, { state: base });
|
|
307
|
+
if ((d.stateTree ?? []).some((t) => CALL_PHASES.every((p) => t.states?.some((x) => x.name === p)))) avatars.add(el);
|
|
308
|
+
ready();
|
|
309
|
+
})
|
|
310
|
+
.catch((e) => console.warn(`[unoverse] page template "${uri}" failed to load:`, e));
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
/** Something this template shows from outside it changed (whether a task waits). */
|
|
314
|
+
refresh: () => refreshCall(),
|
|
315
|
+
destroy() {
|
|
316
|
+
alive = false;
|
|
317
|
+
stops.forEach((s) => s());
|
|
318
|
+
comp?.destroy();
|
|
319
|
+
comp?.nodes.forEach((n) => n.parentNode?.removeChild(n));
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface PageInterfacesOptions {
|
|
325
|
+
org: string;
|
|
326
|
+
client: DefinitionSource;
|
|
327
|
+
voice: VoiceService;
|
|
328
|
+
appStore: ComponentStore;
|
|
329
|
+
toServer: (t: string, data: Record<string, unknown>, meta?: ActionMeta) => void;
|
|
330
|
+
startTask: (binding: Binding, task: string, submit?: Record<string, unknown>) => void;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* ONE APP STATE FOR THE WHOLE PAGE. Each template keeps its own slice in its own store, and all
|
|
335
|
+
* of them read and write the app's state in `appStore`, the store the call and the workflow
|
|
336
|
+
* write to. A name one template saves is a name every other template can show.
|
|
337
|
+
*/
|
|
338
|
+
export function mountPageInterfaces(o: PageInterfacesOptions): { destroy(): void } {
|
|
339
|
+
const doc = pageDocument();
|
|
340
|
+
if (!doc) return { destroy() {} };
|
|
341
|
+
const win = doc.defaultView;
|
|
342
|
+
const dock = new Dock(doc);
|
|
343
|
+
const mounted = new Map<HTMLElement, ReturnType<typeof mountPageTemplate>>();
|
|
344
|
+
// Each template's own workflow, as it loads; a template without one is never called.
|
|
345
|
+
const bindings: Record<string, Binding> = {};
|
|
346
|
+
// The tasks this page has started. Each starts once: a second press starts nothing.
|
|
347
|
+
const started = new Set<string>();
|
|
348
|
+
// The task in view, by its URI, measured again as the page scrolls or resizes.
|
|
349
|
+
let inView: string | null = null;
|
|
350
|
+
|
|
351
|
+
// THE AVATAR STARTS THE TASK'S WORKFLOW (UNOVERSE_HOST_PAGE.md rule 4), like any button. A task
|
|
352
|
+
// in view with a workflow, not already running, is WAITING: a press starts it at the
|
|
353
|
+
// template's trigger. Nothing in view, or nothing bound: nothing.
|
|
354
|
+
const target = () => (inView && bindings[inView]?.workflow ? inView : null);
|
|
355
|
+
const waiting = () => {
|
|
356
|
+
const t = target();
|
|
357
|
+
return !!t && !started.has(t);
|
|
358
|
+
};
|
|
359
|
+
const refreshAll = () => mounted.forEach((m) => m.refresh());
|
|
360
|
+
|
|
361
|
+
// A TEMPLATE'S OWN ACTIONS go where the app's go: its `submit` answers whoever opened the task;
|
|
362
|
+
// a template with its own workflow also has that workflow called with the answers (the
|
|
363
|
+
// task's last call, design/tasks.md "The submit"). The call's three are the page's.
|
|
364
|
+
const onCallFor = (uri: string): ActionHandler => (spec, data, meta) => {
|
|
365
|
+
const action = typeof spec === "string" ? spec : spec?.type;
|
|
366
|
+
if (action === "submit" || action === "cancel") {
|
|
367
|
+
o.toServer(action, data, meta);
|
|
368
|
+
const state = (meta?.state ?? data) as Record<string, unknown> | undefined;
|
|
369
|
+
if (action === "submit" && bindings[uri]?.workflow && state && typeof state === "object") o.startTask(bindings[uri], uri, state);
|
|
370
|
+
} else if (action === "startCall") {
|
|
371
|
+
const t = target();
|
|
372
|
+
if (!t || started.has(t)) return;
|
|
373
|
+
started.add(t);
|
|
374
|
+
refreshAll();
|
|
375
|
+
// The press is the click the browser needs before it plays a voice the workflow may have.
|
|
376
|
+
o.voice.unlock();
|
|
377
|
+
o.startTask(bindings[t], t);
|
|
378
|
+
} else if (action === "endCall") void o.voice.endCall();
|
|
379
|
+
else if (action === "toggleMute") o.voice.toggleMute();
|
|
380
|
+
// Another app on the page, opened or closed by its `<org>/<app>` (its own tag, EMBED_CLIENT.md).
|
|
381
|
+
else if (action === "toggleApp") {
|
|
382
|
+
const app = (data as { app?: unknown } | undefined)?.app;
|
|
383
|
+
if (typeof app === "string") (win as (Window & { unoverseApps?: Record<string, { toggle(): void }> }) | null)?.unoverseApps?.[app]?.toggle();
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const measure = () => {
|
|
388
|
+
const name = taskInView(doc)?.getAttribute(MARK);
|
|
389
|
+
const next = name ? uriOf(name) : null;
|
|
390
|
+
if (next === inView) return;
|
|
391
|
+
inView = next;
|
|
392
|
+
refreshAll();
|
|
393
|
+
};
|
|
394
|
+
const scan = () => {
|
|
395
|
+
// Only this app's own org's templates: another app on the page draws its own.
|
|
396
|
+
const found = [...doc.querySelectorAll<HTMLElement>(`[${MARK}]`)].filter((el) => el.getAttribute(MARK)?.startsWith(`${o.org}/`));
|
|
397
|
+
for (const [el, m] of mounted)
|
|
398
|
+
if (!found.includes(el)) {
|
|
399
|
+
m.destroy();
|
|
400
|
+
mounted.delete(el);
|
|
401
|
+
}
|
|
402
|
+
for (const el of found) {
|
|
403
|
+
if (mounted.has(el)) continue;
|
|
404
|
+
const name = el.getAttribute(MARK) ?? "";
|
|
405
|
+
if (!name) continue;
|
|
406
|
+
const uri = uriOf(name);
|
|
407
|
+
mounted.set(
|
|
408
|
+
el,
|
|
409
|
+
mountPageTemplate({
|
|
410
|
+
client: o.client,
|
|
411
|
+
uri,
|
|
412
|
+
el,
|
|
413
|
+
voice: o.voice,
|
|
414
|
+
appStore: o.appStore,
|
|
415
|
+
dock,
|
|
416
|
+
waiting,
|
|
417
|
+
onBinding: (u, b) => {
|
|
418
|
+
if (bindings[u]) return;
|
|
419
|
+
bindings[u] = b;
|
|
420
|
+
refreshAll();
|
|
421
|
+
},
|
|
422
|
+
onCall: onCallFor(uri),
|
|
423
|
+
}),
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
measure();
|
|
427
|
+
};
|
|
428
|
+
scan();
|
|
429
|
+
// The page may render its parts after this runtime starts (a client-rendered page), so it
|
|
430
|
+
// keeps looking; only a change in the marked set mounts or drops a template.
|
|
431
|
+
const watch = new MutationObserver(scan);
|
|
432
|
+
watch.observe(doc.body, { childList: true, subtree: true });
|
|
433
|
+
// A press inside a marked template makes it the one the keys move; outside every template
|
|
434
|
+
// the keys go back to the one most in view.
|
|
435
|
+
const onPress = (e: Event) => {
|
|
436
|
+
engaged = (e.composedPath().find((n) => n instanceof HTMLElement && n.hasAttribute(MARK)) as HTMLElement | undefined) ?? null;
|
|
437
|
+
};
|
|
438
|
+
doc.addEventListener("pointerdown", onPress, true);
|
|
439
|
+
win?.addEventListener("scroll", measure, { passive: true });
|
|
440
|
+
win?.addEventListener("resize", measure);
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
destroy() {
|
|
444
|
+
watch.disconnect();
|
|
445
|
+
doc.removeEventListener("pointerdown", onPress, true);
|
|
446
|
+
win?.removeEventListener("scroll", measure);
|
|
447
|
+
win?.removeEventListener("resize", measure);
|
|
448
|
+
mounted.forEach((m) => m.destroy());
|
|
449
|
+
mounted.clear();
|
|
450
|
+
},
|
|
451
|
+
};
|
|
452
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE RESPONSE VIEW, in plain DOM: what an UNNAMED result draws (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 }`, the
|
|
6
|
+
* component events the run produced. This view draws EXACTLY those: seed a fresh store with
|
|
7
|
+
* the events, resolve the theme, draw each instance with the standard component. No app
|
|
8
|
+
* mount, no home state, no auto-trigger: an app mounts only when something NAMES it.
|
|
9
|
+
*
|
|
10
|
+
* Definitions and theme resolve over the result's serverUrl (`/mcp` reads): the response is
|
|
11
|
+
* data; the vocabulary lives on the universe.
|
|
12
|
+
*/
|
|
13
|
+
import { UnoverseClient } from "./lib/core/client";
|
|
14
|
+
import { ComponentStore } from "./lib/core/store";
|
|
15
|
+
import { applyServerMessage } from "./lib/core/connection";
|
|
16
|
+
import { mountComponent, type ComponentMount } from "./lib/dom/component";
|
|
17
|
+
import { openConnection, type UnoverseConnection } from "./lib/dom/connection";
|
|
18
|
+
import { setChildren } from "./lib/dom/element";
|
|
19
|
+
import { fetchTheme } from "./lib/dom/theme";
|
|
20
|
+
import type { ResolvedTheme } from "./lib/core/types";
|
|
21
|
+
import { handAnswerToHost, hostMode, onDisplayMode, showAs } from "./mcpAppBridge";
|
|
22
|
+
|
|
23
|
+
export interface ResponseViewOptions {
|
|
24
|
+
serverUrl: string;
|
|
25
|
+
/** The run's component events (COMPONENT_INIT / COMPONENT_DATA), as collected server-side
|
|
26
|
+
* and carried in the result's `interface.components`. */
|
|
27
|
+
events: Record<string, unknown>[];
|
|
28
|
+
/** THE LIVE PAGE: with the caller's identity and a page handle, the view listens on
|
|
29
|
+
* /stream under that page's conversation, so a later `-update` call (which opens no
|
|
30
|
+
* window) hydrates THESE instances in place. docs/mcp/MCP_2026-07-28.md §3a. */
|
|
31
|
+
live?: { userId: string; conversationId: string; token?: string };
|
|
32
|
+
/** The client reading definitions; one per server by default. */
|
|
33
|
+
client?: UnoverseClient;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ResponseView {
|
|
37
|
+
destroy(): void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Draw a result's components into `el`. */
|
|
41
|
+
export function mountResponseView(el: HTMLElement, o: ResponseViewOptions): ResponseView {
|
|
42
|
+
const doc = el.ownerDocument;
|
|
43
|
+
const client = o.client ?? new UnoverseClient(`${o.serverUrl}/mcp`, {});
|
|
44
|
+
|
|
45
|
+
// Seed with the same pure mapper the live stream uses, so a seeded component is
|
|
46
|
+
// indistinguishable from a streamed one (merge-not-replace and all).
|
|
47
|
+
const store = new ComponentStore();
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
for (const e of o.events) {
|
|
50
|
+
try {
|
|
51
|
+
applyServerMessage(store, e as never, seen);
|
|
52
|
+
} catch {
|
|
53
|
+
/* one malformed event must not blank the whole response */
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// One card per INSTANCE, in arrival order. A PART LIVES IN ITS PAGE: the page's slot draws
|
|
58
|
+
// it from the store, so it never gets a card of its own here.
|
|
59
|
+
const instances: { chatId: string; nodeId: string }[] = [];
|
|
60
|
+
const keys = new Set<string>();
|
|
61
|
+
for (const e of o.events) {
|
|
62
|
+
const ev = e as { type?: string; chatId?: string; nodeId?: string; part?: boolean };
|
|
63
|
+
if (ev.type !== "COMPONENT_INIT" || !ev.chatId || !ev.nodeId || ev.part) continue;
|
|
64
|
+
const key = `${ev.chatId}:${ev.nodeId}`;
|
|
65
|
+
if (keys.has(key)) continue;
|
|
66
|
+
keys.add(key);
|
|
67
|
+
instances.push({ chatId: ev.chatId, nodeId: ev.nodeId });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Hydrate in place: pushes for this page's conversation merge into the seeded store.
|
|
71
|
+
const token = o.live?.token;
|
|
72
|
+
const conn: UnoverseConnection | null =
|
|
73
|
+
o.live?.userId && o.live?.conversationId
|
|
74
|
+
? openConnection(
|
|
75
|
+
{ apiUrl: o.serverUrl, streamUrl: o.serverUrl, getAccessToken: token ? async () => token : undefined },
|
|
76
|
+
{ workflowId: "", targetTriggerNode: "", userId: o.live.userId, conversationId: o.live.conversationId, chatId: o.live.conversationId },
|
|
77
|
+
store,
|
|
78
|
+
)
|
|
79
|
+
: null;
|
|
80
|
+
|
|
81
|
+
// THE HOST'S DISPLAY MODE IS THE TASK'S STATE (MCP Apps, Display Modes: the host decides
|
|
82
|
+
// the mode and tells the window each change, `host-context-changed` `displayMode`).
|
|
83
|
+
// 1. When the window connects, the task tells the host where it wants to be.
|
|
84
|
+
// 2. After that the host's mode is the truth: in the chat the task shows its `inline`
|
|
85
|
+
// face; in the panel it shows open, in the face it last had open.
|
|
86
|
+
// 3. Our own changes (a chip tapped, a ✕, a push from the server) ask the host for the
|
|
87
|
+
// mode their state needs, and the host's answer settles it.
|
|
88
|
+
// Outside an MCP Apps host there is no mode, and none of this runs.
|
|
89
|
+
const stateOf = (i: { chatId: string; nodeId: string }) => {
|
|
90
|
+
const s = store.get(i.chatId, i.nodeId) as Record<string, unknown> | undefined;
|
|
91
|
+
const view = s?.state ?? s?.initialView;
|
|
92
|
+
return typeof view === "string" ? view : undefined;
|
|
93
|
+
};
|
|
94
|
+
// The face each task last had open, to show again when the host opens it.
|
|
95
|
+
const openFace = new Map<string, string>();
|
|
96
|
+
const faceToOpen = (i: { chatId: string; nodeId: string }) => {
|
|
97
|
+
const last = openFace.get(`${i.chatId}:${i.nodeId}`);
|
|
98
|
+
if (last) return last;
|
|
99
|
+
const menu = (store.get(i.chatId, i.nodeId) as { publicStates?: unknown } | undefined)?.publicStates;
|
|
100
|
+
return Array.isArray(menu) ? (menu.find((v) => typeof v === "string" && v !== "inline") as string | undefined) : undefined;
|
|
101
|
+
};
|
|
102
|
+
const setState = (i: { chatId: string; nodeId: string }, state: string) =>
|
|
103
|
+
store.apply({ type: "COMPONENT_DATA", chatId: i.chatId, nodeId: i.nodeId, data: { state } } as never);
|
|
104
|
+
/** The host's mode, applied to the task: in the chat its `inline` face, in the panel open. */
|
|
105
|
+
const follow = (mode: string) => {
|
|
106
|
+
for (const i of instances) {
|
|
107
|
+
const v = stateOf(i);
|
|
108
|
+
if (mode !== "fullscreen" && v !== undefined && v !== "inline") setState(i, "inline");
|
|
109
|
+
if (mode === "fullscreen" && v === "inline") {
|
|
110
|
+
const face = faceToOpen(i);
|
|
111
|
+
if (face) setState(i, face);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
/** Ask the host for the mode the task's state needs; however the ask ends (granted,
|
|
116
|
+
* refused, failed, nothing to ask), the task then takes the mode the host is showing. */
|
|
117
|
+
const ask = () => {
|
|
118
|
+
const view = instances.map(stateOf).find((v) => v !== undefined);
|
|
119
|
+
if (view === undefined || hostMode() === null) return;
|
|
120
|
+
void showAs(view).finally(() => {
|
|
121
|
+
const mode = hostMode();
|
|
122
|
+
if (mode !== null) follow(mode);
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
let told = false;
|
|
126
|
+
const stopFollow = store.subscribe(() => {
|
|
127
|
+
for (const i of instances) {
|
|
128
|
+
const v = stateOf(i);
|
|
129
|
+
if (v && v !== "inline") openFace.set(`${i.chatId}:${i.nodeId}`, v);
|
|
130
|
+
}
|
|
131
|
+
if (told) ask();
|
|
132
|
+
});
|
|
133
|
+
const stopMode = onDisplayMode((mode) => {
|
|
134
|
+
if (hostMode() === null) return;
|
|
135
|
+
if (!told) {
|
|
136
|
+
told = true;
|
|
137
|
+
return ask();
|
|
138
|
+
}
|
|
139
|
+
follow(mode);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Fills the panel, fits its content in the chat (the bridge sizes the page by display
|
|
143
|
+
// mode). No padding: the component's own edge is the window's edge.
|
|
144
|
+
const box = doc.createElement("div");
|
|
145
|
+
Object.assign(box.style, { display: "flex", flexDirection: "column", gap: "12px", boxSizing: "border-box", height: "100%" });
|
|
146
|
+
const cards: ComponentMount[] = [];
|
|
147
|
+
const wrappers: HTMLDivElement[] = [];
|
|
148
|
+
let alive = true;
|
|
149
|
+
|
|
150
|
+
const draw = (theme: ResolvedTheme | undefined) => {
|
|
151
|
+
if (!alive) return;
|
|
152
|
+
instances.forEach((i, n) => {
|
|
153
|
+
const opts = {
|
|
154
|
+
client,
|
|
155
|
+
store,
|
|
156
|
+
chatId: i.chatId,
|
|
157
|
+
nodeId: i.nodeId,
|
|
158
|
+
theme,
|
|
159
|
+
doc,
|
|
160
|
+
// Steps run locally (`setValue` into this instance's slice). A SUBMIT inside an
|
|
161
|
+
// outside host goes back to the host's model: no server call waits for it there.
|
|
162
|
+
onAction: (action: unknown, data: Record<string, unknown>, meta?: { state?: Record<string, unknown> }) => {
|
|
163
|
+
const t = typeof action === "string" ? action : (action as { type?: string })?.type;
|
|
164
|
+
if (t === "submit") handAnswerToHost((meta?.state ?? data) as Record<string, unknown>);
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
if (cards[n]) return cards[n].update(opts as never);
|
|
168
|
+
const w = doc.createElement("div");
|
|
169
|
+
Object.assign(w.style, { flex: "1", minHeight: "0", display: "flex", flexDirection: "column" });
|
|
170
|
+
cards[n] = mountComponent(opts as never);
|
|
171
|
+
wrappers[n] = w;
|
|
172
|
+
setChildren(w, cards[n].nodes);
|
|
173
|
+
});
|
|
174
|
+
setChildren(box, wrappers);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
if (instances.length) {
|
|
178
|
+
el.appendChild(box);
|
|
179
|
+
draw(undefined);
|
|
180
|
+
fetchTheme(client, "light").then(draw, () => {});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
destroy() {
|
|
185
|
+
alive = false;
|
|
186
|
+
stopFollow();
|
|
187
|
+
stopMode();
|
|
188
|
+
conn?.close();
|
|
189
|
+
cards.forEach((c) => c.destroy());
|
|
190
|
+
box.remove();
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
package/vite.config.ts
CHANGED
|
@@ -66,8 +66,8 @@ export default defineConfig({
|
|
|
66
66
|
external: ["@unoverse-platform/plugin-base", "@unoverse-platform/marketplace"],
|
|
67
67
|
},
|
|
68
68
|
optimizeDeps: {
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
include: ["
|
|
69
|
+
// MCP SDK v2's client (Studio's host and the vendored webSDK) picks its bundled
|
|
70
|
+
// CfWorker validator in a browser, so it no longer pulls ajv in.
|
|
71
|
+
include: ["@modelcontextprotocol/client"],
|
|
72
72
|
},
|
|
73
73
|
});
|