@unoverse-platform/studio 0.1.0
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/bin/studio.mjs +124 -0
- package/index.html +12 -0
- package/local/catalog.ts +105 -0
- package/local/keys.ts +118 -0
- package/local/nodes.ts +77 -0
- package/local/plugin.ts +401 -0
- package/local/scaffold.d.mts +13 -0
- package/local/scaffold.mjs +85 -0
- package/package.json +43 -0
- package/public/logo.png +0 -0
- package/screens/AtomsView.tsx +206 -0
- package/screens/ComponentsView.tsx +551 -0
- package/screens/ConfigForm.tsx +217 -0
- package/screens/DesignSystemView.tsx +324 -0
- package/screens/IdentityHeader.tsx +88 -0
- package/screens/PromptBlocksView.tsx +137 -0
- package/screens/SkillsView.tsx +167 -0
- package/screens/TemplatesView.tsx +1005 -0
- package/screens/index.ts +26 -0
- package/screens/states.ts +44 -0
- package/src/App.tsx +252 -0
- package/src/ConnectUniverse.tsx +232 -0
- package/src/NewProject.tsx +89 -0
- package/src/NodeKeys.tsx +98 -0
- package/src/NodesView.tsx +443 -0
- package/src/PublishDialog.tsx +309 -0
- package/src/UniverseSession.tsx +121 -0
- package/src/host.ts +165 -0
- package/src/index.css +7 -0
- package/src/main.tsx +11 -0
- package/src/universes.ts +210 -0
- package/vendor/sdk/appEngine.tsx +219 -0
- package/vendor/sdk/lib/UnoverseComponent.tsx +120 -0
- package/vendor/sdk/lib/connection.tsx +302 -0
- package/vendor/sdk/lib/core/actions.ts +84 -0
- package/vendor/sdk/lib/core/client.ts +134 -0
- package/vendor/sdk/lib/core/conditions.ts +43 -0
- package/vendor/sdk/lib/core/connection.ts +142 -0
- package/vendor/sdk/lib/core/index.ts +32 -0
- package/vendor/sdk/lib/core/store.ts +455 -0
- package/vendor/sdk/lib/core/types.ts +194 -0
- package/vendor/sdk/lib/index.ts +59 -0
- package/vendor/sdk/lib/isolate.tsx +70 -0
- package/vendor/sdk/lib/primitives.tsx +453 -0
- package/vendor/sdk/lib/realtime/audioUtils.ts +40 -0
- package/vendor/sdk/lib/realtime/types.ts +45 -0
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +237 -0
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +145 -0
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +164 -0
- package/vendor/sdk/lib/render.tsx +178 -0
- package/vendor/sdk/lib/streamed.tsx +65 -0
- package/vendor/sdk/lib/style.ts +227 -0
- package/vendor/sdk/lib/template.tsx +521 -0
- package/vendor/sdk/lib/theme.ts +55 -0
- package/vendor/sdk/lib/voice.tsx +311 -0
- package/vendor/sdk/main.tsx +96 -0
- package/vite.config.ts +70 -0
package/src/universes.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The universes this developer can publish to.
|
|
3
|
+
*
|
|
4
|
+
* A UNIVERSE NAMES ITS OWN IDENTITY PROVIDER. Studio hardcodes no issuer and no vendor:
|
|
5
|
+
* it asks a universe at `/.well-known/unoverse-universe` and follows the answer, so
|
|
6
|
+
* Auth0, Okta, Entra and Keycloak all work with no change here. That is the whole reason
|
|
7
|
+
* login lives in Studio (a browser, where OIDC is a redirect) rather than in the CLI.
|
|
8
|
+
*
|
|
9
|
+
* THERE IS NO CENTRAL DIRECTORY, and there cannot be. Every universe is independent with
|
|
10
|
+
* its own database and its own IdP (docs/AUTH_TOKEN_FLOW.md: "OIDC-provider-agnostic").
|
|
11
|
+
* Nothing sits above them to enumerate. So "the universes I can deploy to" means the ones
|
|
12
|
+
* this developer has connected to, held here on their machine. That is the honest answer,
|
|
13
|
+
* not a placeholder for a registry.
|
|
14
|
+
*
|
|
15
|
+
* NO TOKEN IS EVER WRITTEN HERE. Only the URL, a label, and the public config. The access
|
|
16
|
+
* token stays wherever the OIDC library keeps it for the session. Local Studio already
|
|
17
|
+
* holds this line for credentials (`local/keys.ts`: "Studio therefore never WRITES a
|
|
18
|
+
* secret") and connecting to a universe must not be the thing that breaks it.
|
|
19
|
+
*
|
|
20
|
+
* See docs/architecture/LOCAL_STUDIO.md.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** What a universe publishes about itself. Mirrors `getPublicAuthConfig()` on the server. */
|
|
24
|
+
export interface UniverseConfig {
|
|
25
|
+
/** False means the universe is not gated at all: do not send anyone to log in. */
|
|
26
|
+
authEnabled: boolean;
|
|
27
|
+
issuer: string | null;
|
|
28
|
+
clientId: string | null;
|
|
29
|
+
audience: string | null;
|
|
30
|
+
/** The permission a token needs before this universe accepts a publish. */
|
|
31
|
+
publishPermission: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface Universe {
|
|
35
|
+
/** Origin, normalised. The identity of the entry: one row per universe. */
|
|
36
|
+
url: string;
|
|
37
|
+
/** What the developer calls it. Defaults to the host. */
|
|
38
|
+
label: string;
|
|
39
|
+
config: UniverseConfig;
|
|
40
|
+
/** When it was last reachable, ISO. Purely informational. */
|
|
41
|
+
connectedAt?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const STORAGE_KEY = "studio:universes";
|
|
45
|
+
|
|
46
|
+
/** The path a universe serves its public config on. */
|
|
47
|
+
export const DISCOVERY_PATH = "/.well-known/unoverse-universe";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Turn whatever a developer typed into an origin.
|
|
51
|
+
*
|
|
52
|
+
* People type `localhost:4105`, `example.com/`, and `https://example.com/canvas`. All
|
|
53
|
+
* three mean one universe, and storing them as three entries would let the same universe
|
|
54
|
+
* hold three different logins. So this is the identity function for the list.
|
|
55
|
+
*
|
|
56
|
+
* Bare hosts default to https, EXCEPT localhost and 127.0.0.1, which are overwhelmingly
|
|
57
|
+
* a developer's own machine over http and would otherwise fail confusingly.
|
|
58
|
+
*/
|
|
59
|
+
export function normaliseUrl(input: string): string {
|
|
60
|
+
const trimmed = (input ?? "").trim();
|
|
61
|
+
if (!trimmed) throw new Error("Enter a universe address");
|
|
62
|
+
|
|
63
|
+
const isLocal = /^(https?:\/\/)?(localhost|127\.0\.0\.1)(:|\/|$)/i.test(trimmed);
|
|
64
|
+
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `${isLocal ? "http" : "https"}://${trimmed}`;
|
|
65
|
+
|
|
66
|
+
let parsed: URL;
|
|
67
|
+
try {
|
|
68
|
+
parsed = new URL(withScheme);
|
|
69
|
+
} catch {
|
|
70
|
+
throw new Error(`"${input}" is not a valid address`);
|
|
71
|
+
}
|
|
72
|
+
return parsed.origin;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A readable default name: the host, without the port for a real domain. */
|
|
76
|
+
export function defaultLabel(url: string): string {
|
|
77
|
+
try {
|
|
78
|
+
return new URL(url).host;
|
|
79
|
+
} catch {
|
|
80
|
+
return url;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Ask a universe how to log in.
|
|
86
|
+
*
|
|
87
|
+
* Failure here is the common case, not the exception: wrong address, universe down, a
|
|
88
|
+
* plain website that is not a universe at all. Each gets a message a developer can act
|
|
89
|
+
* on, because "Failed to fetch" tells them nothing about which of the three it was.
|
|
90
|
+
*/
|
|
91
|
+
export async function discover(
|
|
92
|
+
url: string,
|
|
93
|
+
fetchImpl: typeof fetch = fetch,
|
|
94
|
+
): Promise<UniverseConfig> {
|
|
95
|
+
let response: Response;
|
|
96
|
+
try {
|
|
97
|
+
response = await fetchImpl(`${url}${DISCOVERY_PATH}`, { headers: { accept: "application/json" } });
|
|
98
|
+
} catch {
|
|
99
|
+
throw new Error(`Could not reach ${url}. Check the address, and that the universe is running.`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (response.status === 404) {
|
|
103
|
+
throw new Error(`Something answered at ${url}, but it is not a universe.`);
|
|
104
|
+
}
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
throw new Error(`${url} answered with an error (${response.status}).`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let body: unknown;
|
|
110
|
+
try {
|
|
111
|
+
body = await response.json();
|
|
112
|
+
} catch {
|
|
113
|
+
throw new Error(`${url} answered, but not like a universe. If it is one, it may need restarting.`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const cfg = body as Partial<UniverseConfig>;
|
|
117
|
+
// authEnabled is the field that decides whether we send someone to log in, so its
|
|
118
|
+
// absence is a broken response rather than a default worth guessing at.
|
|
119
|
+
if (typeof cfg?.authEnabled !== "boolean") {
|
|
120
|
+
throw new Error(`${url} answered, but not like a universe. If it is one, it may need restarting.`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
authEnabled: cfg.authEnabled,
|
|
125
|
+
issuer: cfg.issuer ?? null,
|
|
126
|
+
clientId: cfg.clientId ?? null,
|
|
127
|
+
audience: cfg.audience ?? null,
|
|
128
|
+
publishPermission: cfg.publishPermission ?? "marketplace:publish",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Can this universe actually be logged into?
|
|
134
|
+
*
|
|
135
|
+
* A universe can say `authEnabled: true` and still be missing an issuer or a client id,
|
|
136
|
+
* which is a misconfigured universe rather than a Studio problem. Saying so beats
|
|
137
|
+
* redirecting the browser to `undefined`.
|
|
138
|
+
*/
|
|
139
|
+
export function loginReady(config: UniverseConfig): { ok: true } | { ok: false; reason: string } {
|
|
140
|
+
if (!config.authEnabled) return { ok: false, reason: "This universe has authentication switched off." };
|
|
141
|
+
if (!config.issuer) return { ok: false, reason: "This universe has no identity provider configured." };
|
|
142
|
+
if (!config.clientId) return { ok: false, reason: "This universe has no client id configured for sign-in." };
|
|
143
|
+
return { ok: true };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Does this token allow publishing here?
|
|
148
|
+
*
|
|
149
|
+
* Checked so Studio can say "signed in, but not allowed to publish" up front, instead of
|
|
150
|
+
* letting someone build for an hour and fail on the push. Namespace-aware for the same
|
|
151
|
+
* reason the server is: an IdP may emit `permissions` under a namespaced claim, and a
|
|
152
|
+
* plain read silently returns nothing (docs/AUTH_TOKEN_FLOW.md).
|
|
153
|
+
*/
|
|
154
|
+
export function canPublish(claims: Record<string, unknown> | null | undefined, config: UniverseConfig): boolean {
|
|
155
|
+
if (!claims) return false;
|
|
156
|
+
const direct = claims.permissions;
|
|
157
|
+
const namespacedKey = Object.keys(claims).find(
|
|
158
|
+
(k) => k.endsWith("/permissions") || k.endsWith("/claims/permissions"),
|
|
159
|
+
);
|
|
160
|
+
const list = Array.isArray(direct) ? direct : namespacedKey && Array.isArray(claims[namespacedKey]) ? (claims[namespacedKey] as unknown[]) : [];
|
|
161
|
+
|
|
162
|
+
// TRIMMED, because a permission is typed into an identity provider by a person and a
|
|
163
|
+
// stray space is invisible everywhere it is displayed. One arrived as " marketplace:publish"
|
|
164
|
+
// and cost a long debugging session: the Auth0 screen showed it assigned, the token
|
|
165
|
+
// carried it, the label printed it, and the comparison correctly said no. Nothing about
|
|
166
|
+
// surrounding whitespace is ever meaningful in a permission name.
|
|
167
|
+
const wanted = String(config.publishPermission).trim();
|
|
168
|
+
return list.some((p) => String(p).trim() === wanted);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── The stored list ───────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
type Store = Pick<Storage, "getItem" | "setItem">;
|
|
174
|
+
|
|
175
|
+
function store(): Store | null {
|
|
176
|
+
try {
|
|
177
|
+
return typeof localStorage === "undefined" ? null : localStorage;
|
|
178
|
+
} catch {
|
|
179
|
+
return null; // storage blocked (private mode, embedded contexts)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Every universe this developer has connected to. Never throws: a corrupt entry is
|
|
184
|
+
* dropped rather than taking the whole list, and the app down with it. */
|
|
185
|
+
export function loadUniverses(storage: Store | null = store()): Universe[] {
|
|
186
|
+
const raw = storage?.getItem(STORAGE_KEY);
|
|
187
|
+
if (!raw) return [];
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(raw);
|
|
190
|
+
if (!Array.isArray(parsed)) return [];
|
|
191
|
+
return parsed.filter((u): u is Universe => typeof u?.url === "string" && typeof u?.config?.authEnabled === "boolean");
|
|
192
|
+
} catch {
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Add or update one, keyed on url so reconnecting refreshes rather than duplicates. */
|
|
198
|
+
export function saveUniverse(universe: Universe, storage: Store | null = store()): Universe[] {
|
|
199
|
+
const next = [...loadUniverses(storage).filter((u) => u.url !== universe.url), universe].sort((a, b) =>
|
|
200
|
+
a.label.localeCompare(b.label),
|
|
201
|
+
);
|
|
202
|
+
storage?.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
203
|
+
return next;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function removeUniverse(url: string, storage: Store | null = store()): Universe[] {
|
|
207
|
+
const next = loadUniverses(storage).filter((u) => u.url !== url);
|
|
208
|
+
storage?.setItem(STORAGE_KEY, JSON.stringify(next));
|
|
209
|
+
return next;
|
|
210
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The native app ENGINE (§6d) — surface-agnostic. It:
|
|
3
|
+
* - reads its OWN workflow binding from its manifest (§4b: "App supplies workflow, trigger"),
|
|
4
|
+
* - opens the SDK's native connection (`/stream` register_session → store),
|
|
5
|
+
* - fires the workflow DIRECTLY via `conn.sendMessage` → REST `{apiUrl}/api/workflows/:id/execute`
|
|
6
|
+
* with the USER's token (§5a: "direct channel → Gravity backend — never brokered").
|
|
7
|
+
*
|
|
8
|
+
* The server tool only LOADS this app; it never fires or brokers a token. Uses the LOCAL
|
|
9
|
+
* vendored SDK (./lib), not the npm package.
|
|
10
|
+
*/
|
|
11
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
12
|
+
import {
|
|
13
|
+
UnoverseClient,
|
|
14
|
+
ComponentStore,
|
|
15
|
+
StreamedUnoverseTemplate,
|
|
16
|
+
useUnoverseConnection,
|
|
17
|
+
useUnoverseTheme,
|
|
18
|
+
computeAppWidth,
|
|
19
|
+
resolveActiveLayout,
|
|
20
|
+
type UseVoiceServiceConfig,
|
|
21
|
+
} from "./lib";
|
|
22
|
+
|
|
23
|
+
export interface AppConfig {
|
|
24
|
+
serverUrl: string;
|
|
25
|
+
apiUrl?: string;
|
|
26
|
+
templateId: string;
|
|
27
|
+
getAccessToken?: () => Promise<string | null> | string | null;
|
|
28
|
+
userId?: string;
|
|
29
|
+
conversationId: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function AppEngine(cfg: AppConfig) {
|
|
33
|
+
const { serverUrl, apiUrl, templateId, getAccessToken, userId, conversationId } = cfg;
|
|
34
|
+
|
|
35
|
+
const store = useMemo(() => new ComponentStore(), []);
|
|
36
|
+
const client = useMemo(
|
|
37
|
+
() => new UnoverseClient(`${serverUrl}/mcp`, { getAccessToken: getAccessToken as any }),
|
|
38
|
+
[serverUrl],
|
|
39
|
+
);
|
|
40
|
+
const [binding, setBinding] = useState<{ workflow?: string; trigger?: string; service?: string }>({});
|
|
41
|
+
// STATE-OWNED WIDTH (rx data): the layout root's `appWidth` = the core surface's
|
|
42
|
+
// constant width; an occupied panel slot's `appWidth` adds to it (computeAppWidth).
|
|
43
|
+
// No declared width → the host keeps its own default. There is no other path.
|
|
44
|
+
const [def, setDef] = useState<{ root?: unknown; org?: string; layouts?: Record<string, unknown>; defaultLayout?: string } | null>(null);
|
|
45
|
+
const lastWidthRef = useRef<string | undefined>(undefined);
|
|
46
|
+
// Named app sizes resolve via the served theme (theme.appSize), like every token.
|
|
47
|
+
const theme = useUnoverseTheme(client, def ? (def.org ? `${def.org}/light` : "light") : null);
|
|
48
|
+
|
|
49
|
+
// The app owns which workflow it runs — read it from its manifest (§4b), not from config.
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
let alive = true;
|
|
52
|
+
client
|
|
53
|
+
.connect()
|
|
54
|
+
.then(() => (client as any).client.readResource({ uri: `unoverse://apps/${templateId.toLowerCase()}` }))
|
|
55
|
+
.then((res: any) => {
|
|
56
|
+
const parsed = res?.contents?.[0]?.text ? JSON.parse(res.contents[0].text) : null;
|
|
57
|
+
const m = parsed?.manifest ?? parsed ?? {};
|
|
58
|
+
const b = m.binding ?? {};
|
|
59
|
+
// `service` (e.g. "voice") is the app's own declaration — it selects whether the
|
|
60
|
+
// renderer runs a native service. Read it here, next to the binding, and forward it.
|
|
61
|
+
if (alive) setBinding({ workflow: b.workflow, trigger: b.trigger, service: m.service });
|
|
62
|
+
})
|
|
63
|
+
.catch((e) => console.error("[app] manifest read failed:", e));
|
|
64
|
+
// The width lives in the template DATA (root/slot `appWidth`), so read the
|
|
65
|
+
// definition too — same resource the renderer loads.
|
|
66
|
+
client
|
|
67
|
+
.connect()
|
|
68
|
+
.then(() => client.readDefinition(`unoverse://templates/${templateId}`))
|
|
69
|
+
.then((d) => {
|
|
70
|
+
const t = d as { root?: unknown; org?: string; layouts?: Record<string, unknown>; defaultLayout?: string } | null;
|
|
71
|
+
if (alive) setDef(t ? { root: t.root, org: t.org, layouts: t.layouts, defaultLayout: t.defaultLayout } : null);
|
|
72
|
+
})
|
|
73
|
+
.catch((e) => console.error("[app] template read failed:", e));
|
|
74
|
+
return () => {
|
|
75
|
+
alive = false;
|
|
76
|
+
};
|
|
77
|
+
}, [client, templateId]);
|
|
78
|
+
|
|
79
|
+
// Report the app's width UP to the embedding host (iframe boundary → the twin of the
|
|
80
|
+
// inbound `unoverse:config` channel), re-reporting whenever a panel surface opens or
|
|
81
|
+
// closes: the app grows by the panel's declared width, then shrinks back. The host
|
|
82
|
+
// animates the resize. Only posts on an ACTUAL width change (guard).
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
const report = () => {
|
|
85
|
+
// NAME-SYNC: width is computed off the ACTIVE layout (multi-layout templates
|
|
86
|
+
// swap whole arrangements); single-layout defs resolve to their root.
|
|
87
|
+
const w = computeAppWidth(resolveActiveLayout(def as never, store), store, theme?.appSize);
|
|
88
|
+
if (w && w !== lastWidthRef.current) {
|
|
89
|
+
lastWidthRef.current = w;
|
|
90
|
+
window.parent?.postMessage({ type: "unoverse:size", width: w }, "*");
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
report();
|
|
94
|
+
return store.subscribe(report);
|
|
95
|
+
}, [store, def, theme]);
|
|
96
|
+
|
|
97
|
+
// SDK's native connection: opens /stream, register_session, feeds the store. Outbound
|
|
98
|
+
// sendMessage fires the workflow DIRECTLY (REST, user token) — no server broker.
|
|
99
|
+
const conn = useUnoverseConnection(
|
|
100
|
+
{ apiUrl: apiUrl ?? serverUrl, streamUrl: serverUrl, getAccessToken: getAccessToken as any },
|
|
101
|
+
{
|
|
102
|
+
workflowId: binding.workflow ?? "",
|
|
103
|
+
targetTriggerNode: binding.trigger ?? "",
|
|
104
|
+
userId: userId ?? "",
|
|
105
|
+
conversationId,
|
|
106
|
+
chatId: conversationId,
|
|
107
|
+
},
|
|
108
|
+
store,
|
|
109
|
+
// Open only once BOTH are ready: the user identity AND the manifest binding (read async).
|
|
110
|
+
// Avoids the transient "connection missing workflowId/userId/conversationId" warning.
|
|
111
|
+
{ enabled: !!userId && !!binding.workflow },
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
|
|
115
|
+
// in the shared renderer (StreamedUnoverseTemplate), identical to Studio. The host only
|
|
116
|
+
// supplies the connection identity it already holds; startCall/endCall/toggleMute are
|
|
117
|
+
// answered inside the renderer, so this host wires NO voice buttons. Absent → inert.
|
|
118
|
+
const voiceConfig = useMemo<UseVoiceServiceConfig | undefined>(
|
|
119
|
+
() =>
|
|
120
|
+
binding.service === "voice" && binding.workflow
|
|
121
|
+
? {
|
|
122
|
+
// START_CALL REST triggers the workflow on the gateway (user token). The audio
|
|
123
|
+
// WS lane is derived by the SDK from the client's server origin (see the renderer)
|
|
124
|
+
// — the host passes NO wsUrl (its srcdoc `location.origin` is "null").
|
|
125
|
+
apiUrl: apiUrl ?? serverUrl,
|
|
126
|
+
getAccessToken: getAccessToken as UseVoiceServiceConfig["getAccessToken"],
|
|
127
|
+
session: {
|
|
128
|
+
userId: userId ?? "",
|
|
129
|
+
conversationId,
|
|
130
|
+
chatId: conversationId,
|
|
131
|
+
workflowId: binding.workflow,
|
|
132
|
+
targetTriggerNode: binding.trigger ?? "",
|
|
133
|
+
},
|
|
134
|
+
// Producer: writes callState/isMuted into TEMPLATE STATE so the voice states switch.
|
|
135
|
+
store,
|
|
136
|
+
}
|
|
137
|
+
: undefined,
|
|
138
|
+
[binding.service, binding.workflow, binding.trigger, apiUrl, serverUrl, getAccessToken, userId, conversationId, store],
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
return (
|
|
142
|
+
<StreamedUnoverseTemplate
|
|
143
|
+
client={client}
|
|
144
|
+
store={store}
|
|
145
|
+
uri={`unoverse://templates/${templateId}`}
|
|
146
|
+
voice={voiceConfig}
|
|
147
|
+
onAction={(action, data, meta) => {
|
|
148
|
+
const t = typeof action === "string" ? action : (action as any)?.type;
|
|
149
|
+
if (t === "close") {
|
|
150
|
+
// The app owns its close chrome (its own top-right X). Tell the embedding host to
|
|
151
|
+
// dismiss the surface (twin of `unoverse:config`/`unoverse:size`). The host decides
|
|
152
|
+
// what "close" means for its frame (slide the drawer shut); we don't assume.
|
|
153
|
+
window.parent?.postMessage({ type: "unoverse:close" }, "*");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (t === "sendMessage") {
|
|
157
|
+
// A suggestion that NAMES AN APP launches it instead of sending its text: read
|
|
158
|
+
// that app's manifest (the binding lives there — §4b, never in template data)
|
|
159
|
+
// and fire its trigger. The `text` field stays as the fallback for channels
|
|
160
|
+
// without launch support. Same-workflow apps only for now: the connection
|
|
161
|
+
// posts to the BOUND workflow id, so a foreign-workflow app logs and falls
|
|
162
|
+
// through to its trigger on the bound workflow.
|
|
163
|
+
const app = typeof (data as any)?.app === "string" ? (data as any).app : "";
|
|
164
|
+
if (app) {
|
|
165
|
+
(client as any).client
|
|
166
|
+
.readResource({ uri: `unoverse://apps/${app.toLowerCase()}` })
|
|
167
|
+
.then((res: any) => {
|
|
168
|
+
const parsed = res?.contents?.[0]?.text ? JSON.parse(res.contents[0].text) : null;
|
|
169
|
+
const b = parsed?.manifest?.binding ?? parsed?.binding ?? {};
|
|
170
|
+
if (!b.trigger) return console.error("[app] launch failed: no binding on", app);
|
|
171
|
+
if (b.workflow && binding.workflow && b.workflow !== binding.workflow)
|
|
172
|
+
console.warn("[app] launch:", app, "binds a different workflow — firing its trigger on the bound workflow");
|
|
173
|
+
// LOADING STATE: write the app's declared defaultState (open NAME; `mode` =
|
|
174
|
+
// pre-rename alias) into template state NOW — e.g. a "focus" app pre-opens
|
|
175
|
+
// the focus surface, whose slot shows its fallback (skeleton) until the
|
|
176
|
+
// workflow streams the component in. Same template-state key the node's
|
|
177
|
+
// TEMPLATE_DATA emit writes; no new vocabulary. "template" apps ARE the
|
|
178
|
+
// surface — nothing to write.
|
|
179
|
+
const declared = parsed?.manifest?.defaultState ?? parsed?.manifest?.mode ?? parsed?.defaultState ?? parsed?.mode;
|
|
180
|
+
if (declared && declared !== "template") store.mergeTemplateState({ defaultState: declared });
|
|
181
|
+
conn.trigger({ app, metadata: { targetTriggerNode: b.trigger } });
|
|
182
|
+
})
|
|
183
|
+
.catch((e: any) => console.error("[app] launch failed:", e));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
// Composer submit is generic: Enter carries `text`; a send BUTTON carries none,
|
|
187
|
+
// so fall back to the shared draft the store already holds (same as Studio).
|
|
188
|
+
const text = (typeof (data as any)?.text === "string" && (data as any).text) || store.getDraft();
|
|
189
|
+
if (text.trim()) {
|
|
190
|
+
conn.sendMessage(text);
|
|
191
|
+
store.setDraft(""); // clear the composer after sending (the Input binds `draft`)
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// Controlled composer: the Input emits "input" on every keystroke → the shared
|
|
196
|
+
// draft (local, never a server round-trip). A send button / Enter emits "sendMessage".
|
|
197
|
+
if (t === "input") {
|
|
198
|
+
store.setDraft(typeof (data as any)?.text === "string" ? (data as any).text : "");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// Any other named server action (submit, selectCourse…): if the server is
|
|
202
|
+
// ELICITING on this session (the native §3.3 path — an app tool call is
|
|
203
|
+
// waiting), the submitted state ANSWERS the elicitation; one accept then
|
|
204
|
+
// completes the waiting node AND the held tool call server-side. Otherwise
|
|
205
|
+
// fall back to the data-plane `user_action` submit (waiter transport).
|
|
206
|
+
if (!t) return;
|
|
207
|
+
const state = (meta?.state ?? data) as Record<string, unknown>;
|
|
208
|
+
if (conn.resolveElicitation(state)) return;
|
|
209
|
+
conn.sendAction(t, {
|
|
210
|
+
componentNodeId: meta?.nodeId,
|
|
211
|
+
// The component's OWN chatId is the TURN id — the waiting node execution is
|
|
212
|
+
// keyed `<turn>:<nodeId>`, not the conversation-level chatId.
|
|
213
|
+
componentChatId: meta?.chatId,
|
|
214
|
+
componentState: state,
|
|
215
|
+
});
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <UnoverseComponent> — reads a definition by URI from the Unoverse MCP server
|
|
3
|
+
* (via ./core) and renders it natively with the given data.
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect, useMemo, useState, type CSSProperties } from "react";
|
|
6
|
+
import type { ResolvedTheme, UnoverseClient, UnoverseDefinition } from "./core";
|
|
7
|
+
import { renderNode, keyframesCss, type ActionHandler } from "./render";
|
|
8
|
+
import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
|
|
9
|
+
import { IsolatedRoot } from "./isolate";
|
|
10
|
+
|
|
11
|
+
export interface UnoverseComponentProps {
|
|
12
|
+
client: UnoverseClient;
|
|
13
|
+
/** e.g. "unoverse://components/Card" */
|
|
14
|
+
uri: string;
|
|
15
|
+
/** instance data bound into the definition (the streamed props) */
|
|
16
|
+
data?: Record<string, unknown>;
|
|
17
|
+
onAction?: ActionHandler;
|
|
18
|
+
/**
|
|
19
|
+
* Resolved theme. Optional — if omitted, the SDK FETCHES it from the server
|
|
20
|
+
* (`unoverse://theme/light`). The SDK bundles no tokens. Pass one to override
|
|
21
|
+
* (e.g. the workbench's light/dark toggle); swap it to restyle, zero def changes.
|
|
22
|
+
*/
|
|
23
|
+
theme?: ResolvedTheme;
|
|
24
|
+
/**
|
|
25
|
+
* Render inside a Shadow DOM for full CSS isolation (default true). The SDK owns the
|
|
26
|
+
* isolation boundary so every consumer is protected identically; the canvas/channel no
|
|
27
|
+
* longer wrap it themselves. Set false only for a host that manages its own isolation.
|
|
28
|
+
*/
|
|
29
|
+
isolate?: boolean;
|
|
30
|
+
/**
|
|
31
|
+
* A SURFACE'S SINGLE OCCUPANT FILLS THE SURFACE (docs/design/05): set by a limit-1
|
|
32
|
+
* reaction surface so the instance wrapper takes the frame's full height. The
|
|
33
|
+
* component still owns its face — this only gives its `height: "full"` a definite
|
|
34
|
+
* container. Flow/rail instances stay content-sized (never set there).
|
|
35
|
+
*/
|
|
36
|
+
fill?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function UnoverseComponent({ client, uri, data, onAction, theme, isolate = true, fill = false }: UnoverseComponentProps) {
|
|
40
|
+
const [def, setDef] = useState<UnoverseDefinition | null>(null);
|
|
41
|
+
const [error, setError] = useState<string | null>(null);
|
|
42
|
+
// No theme passed → fetch it from the server (the SDK owns zero token values). The
|
|
43
|
+
// theme REF comes from the definition itself: a template's `org` names its client's
|
|
44
|
+
// theme (`<org>/light`); a universal component previews under the default set
|
|
45
|
+
// ("light" — channels pass their org's theme explicitly). Wait for the def before
|
|
46
|
+
// fetching (null = no fetch) so the wrong theme never flashes.
|
|
47
|
+
const fetched = useUnoverseTheme(client, theme ? null : def ? (def.org ? `${def.org}/light` : "light") : null);
|
|
48
|
+
const activeTheme = theme ?? fetched;
|
|
49
|
+
// Register the theme's webfonts (served DATA) at document level — see ensureFontStylesheets.
|
|
50
|
+
useEffect(() => ensureFontStylesheets(activeTheme), [activeTheme]);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
let alive = true;
|
|
54
|
+
setDef(null);
|
|
55
|
+
setError(null);
|
|
56
|
+
client
|
|
57
|
+
.readDefinition(uri)
|
|
58
|
+
.then((d) => alive && setDef(d))
|
|
59
|
+
.catch((e) => alive && setError(String(e?.message ?? e)));
|
|
60
|
+
return () => {
|
|
61
|
+
alive = false;
|
|
62
|
+
};
|
|
63
|
+
}, [client, uri]);
|
|
64
|
+
|
|
65
|
+
// Fill props the instance didn't provide from the definition's defaults; instance `data`
|
|
66
|
+
// overrides. This is what lets a partially-emitted component still render its declared
|
|
67
|
+
// baseline — e.g. StreamingText's `progressText` default ("Thinking…") shows while the node
|
|
68
|
+
// streams and hasn't emitted a progress line. IMPORTANT: a def `default` must therefore be
|
|
69
|
+
// a sensible RUNTIME fallback, NOT a demo placeholder — a demo-y default (e.g. a markdown
|
|
70
|
+
// showcase on `text`) would mask an empty stream. Keep demo content out of prop defaults.
|
|
71
|
+
// Memoized on the def — a streaming component re-renders per chunk; the defaults don't move.
|
|
72
|
+
const propDefaults = useMemo(
|
|
73
|
+
() =>
|
|
74
|
+
Object.fromEntries(
|
|
75
|
+
Object.entries((def?.props ?? {}) as Record<string, { default?: unknown }>).map(([k, v]) => [k, v?.default]),
|
|
76
|
+
),
|
|
77
|
+
[def],
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
// Loading/error hints carry NO styling — the SDK owns zero styles (tokens or hints).
|
|
81
|
+
if (error) return <div>Unoverse error: {error}</div>;
|
|
82
|
+
if (!def || !activeTheme) return <div>Loading {uri}…</div>;
|
|
83
|
+
// Internal state initial values (def.state) sit beneath the live slice — the
|
|
84
|
+
// microapp's own keys (step/phase/…) start at their authored values and move only
|
|
85
|
+
// when its actions write them. Props stay the EXTERNAL contract.
|
|
86
|
+
const merged = { ...propDefaults, ...(def?.state ?? {}), ...(data ?? {}) };
|
|
87
|
+
// Inject the served keyframes + apply the SERVED base render-root (theme.root — DS typography
|
|
88
|
+
// + font-smoothing). When ISOLATED, theme.root is applied to the shadow-root container inside
|
|
89
|
+
// IsolatedRoot (a REAL box — reliable font cascade in the shadow tree). When NOT isolated the
|
|
90
|
+
// host owns isolation, so apply it here on a `display:contents` wrapper (out of layout while
|
|
91
|
+
// its inherited props cascade) — the design-system baseline either way.
|
|
92
|
+
const keyframes = <style>{keyframesCss(activeTheme)}</style>;
|
|
93
|
+
const tree = renderNode(def.root, merged, onAction, activeTheme);
|
|
94
|
+
// The isolation container MIRRORS the component root's declared sizing intent (§4a:
|
|
95
|
+
// the component owns its size; the host responds). A root that declares
|
|
96
|
+
// `height: "full"` needs a definite chain — the container passes the host's height
|
|
97
|
+
// through (100%). Any other root keeps an auto container → fit-to-content, the §5a
|
|
98
|
+
// default (a card in a timeline must NOT fill). Without this, a full-height
|
|
99
|
+
// component's 100% resolves against an auto box and silently collapses.
|
|
100
|
+
const fillsHost = fill || ((def.root as { style?: Record<string, unknown> })?.style?.height ?? null) === "full";
|
|
101
|
+
const isolatedRootStyle = {
|
|
102
|
+
...(activeTheme.root as CSSProperties),
|
|
103
|
+
...(fillsHost ? { height: "100%" } : {}),
|
|
104
|
+
...(fill ? { flex: "1 1 auto", minHeight: 0 } : {}),
|
|
105
|
+
};
|
|
106
|
+
if (isolate) {
|
|
107
|
+
return (
|
|
108
|
+
<IsolatedRoot rootStyle={isolatedRootStyle}>
|
|
109
|
+
{keyframes}
|
|
110
|
+
{tree}
|
|
111
|
+
</IsolatedRoot>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return (
|
|
115
|
+
<>
|
|
116
|
+
{keyframes}
|
|
117
|
+
<div style={{ display: "contents", ...(activeTheme.root as CSSProperties) }}>{tree}</div>
|
|
118
|
+
</>
|
|
119
|
+
);
|
|
120
|
+
}
|