@unoverse-platform/studio 0.1.100 → 0.1.102
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/local/sample-identity/brand.yaml +2 -0
- package/package.json +1 -1
- package/screens/TemplatesLibraryView.tsx +58 -98
- package/screens/TemplatesView.tsx +18 -2
- package/vendor/base/src/auth/auth.ts +7 -5
- package/vendor/base/src/auth/authConfig.ts +1 -1
- package/vendor/base/src/definitions/load/assets.ts +65 -0
- package/vendor/base/src/definitions/load/compile/component.ts +1 -0
- package/vendor/base/src/definitions/load/compile/index.ts +1 -1
- package/vendor/base/src/definitions/load/compile/sections.ts +26 -116
- package/vendor/base/src/definitions/load/compile/template.ts +41 -25
- package/vendor/base/src/definitions/load/expand/walk.ts +4 -1
- package/vendor/base/src/definitions/load/load.ts +6 -0
- package/vendor/base/src/definitions/model.ts +20 -41
- package/vendor/base/src/definitions/registry/homes.ts +9 -1
- package/vendor/base/src/lint/design/file.mjs +29 -5
- package/vendor/base/src/lint/design/vocabulary.mjs +1 -1
- package/vendor/base/src/lint/design/walk.mjs +31 -156
- package/vendor/base/src/manifests/executor/context.ts +2 -2
- package/vendor/base/src/manifests/executor/toolBridge.ts +3 -29
- package/vendor/base/src/manifests/runtime/duplex/audioLane.ts +35 -12
- package/vendor/base/src/manifests/runtime/duplex/session.ts +123 -48
- package/vendor/base/src/manifests/runtime/index.ts +1 -1
- package/vendor/base/src/platform/audioManager.ts +89 -14
- package/vendor/base/src/platform/clientTransport.ts +8 -0
- package/vendor/base/src/security/permissions.ts +2 -2
- package/vendor/sdk/appEngine.tsx +104 -46
- package/vendor/sdk/lib/UnoverseComponent.tsx +58 -5
- package/vendor/sdk/lib/app.tsx +9 -5
- package/vendor/sdk/lib/connection.tsx +15 -4
- package/vendor/sdk/lib/core/actions.ts +5 -2
- package/vendor/sdk/lib/index.ts +2 -2
- package/vendor/sdk/lib/primitives.tsx +38 -4
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +6 -1
- package/vendor/sdk/lib/voice.tsx +96 -13
- package/vendor/sdk/main.tsx +1 -1
- package/vendor/sdk/pageInterfaces.tsx +383 -0
package/vendor/sdk/appEngine.tsx
CHANGED
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
12
12
|
import { postToEmbedHost } from "./mcpAppBridge";
|
|
13
|
+
import { PageInterfaces, type Binding } from "./pageInterfaces";
|
|
13
14
|
import {
|
|
14
15
|
UnoverseClient,
|
|
15
16
|
ComponentStore,
|
|
16
17
|
StreamedUnoverseApp,
|
|
18
|
+
INERT_VOICE,
|
|
19
|
+
useVoiceService,
|
|
17
20
|
useUnoverseConnection,
|
|
18
21
|
useUnoverseTheme,
|
|
19
22
|
computeAppWidth,
|
|
@@ -23,6 +26,7 @@ import {
|
|
|
23
26
|
setAnalyticsDebug,
|
|
24
27
|
type UseVoiceServiceConfig,
|
|
25
28
|
type UnoverseDefinition,
|
|
29
|
+
type ActionMeta,
|
|
26
30
|
} from "./lib";
|
|
27
31
|
|
|
28
32
|
export interface AppConfig {
|
|
@@ -184,6 +188,48 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
184
188
|
return store.subscribe(report);
|
|
185
189
|
}, [store, def, theme]);
|
|
186
190
|
|
|
191
|
+
// VOICE (§2e-1): a `service: "voice"` app runs the native voice service — which now lives
|
|
192
|
+
// in the shared renderer (StreamedUnoverseApp), identical to Studio. The host only
|
|
193
|
+
// supplies the connection identity it already holds; startCall/endCall/toggleMute are
|
|
194
|
+
// answered inside the renderer, so this host wires NO voice buttons. Absent → inert.
|
|
195
|
+
const voiceConfig = useMemo<UseVoiceServiceConfig | undefined>(
|
|
196
|
+
() =>
|
|
197
|
+
// THE SERVICE NEEDS NO WORKFLOW OF ITS OWN. It is the page's microphone, speaker and
|
|
198
|
+
// audio lane; a call names the workflow it runs (a page template's binding,
|
|
199
|
+
// UNOVERSE_HOST_PAGE.md rule 4), and an app with a binding lends it as the default.
|
|
200
|
+
binding.service === "voice"
|
|
201
|
+
? {
|
|
202
|
+
// START_CALL REST triggers the workflow on the gateway (user token). The audio
|
|
203
|
+
// WS lane is derived by the SDK from the client's server origin (see the renderer)
|
|
204
|
+
// — the host passes NO wsUrl (its srcdoc `location.origin` is "null").
|
|
205
|
+
apiUrl: apiUrl ?? serverUrl,
|
|
206
|
+
getAccessToken: getAccessToken as UseVoiceServiceConfig["getAccessToken"],
|
|
207
|
+
session: {
|
|
208
|
+
userId: userId ?? "",
|
|
209
|
+
conversationId,
|
|
210
|
+
chatId: conversationId,
|
|
211
|
+
workflowId: binding.workflow ?? "",
|
|
212
|
+
targetTriggerNode: binding.trigger ?? "",
|
|
213
|
+
},
|
|
214
|
+
// Producer: writes callState/isMuted into TEMPLATE STATE so the voice states switch.
|
|
215
|
+
store,
|
|
216
|
+
}
|
|
217
|
+
: undefined,
|
|
218
|
+
[binding.service, binding.workflow, binding.trigger, apiUrl, serverUrl, getAccessToken, userId, conversationId, store],
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// ONE CALL FOR THE PAGE. The service runs here, once, so the app and the page's own
|
|
222
|
+
// templates (an avatar in the navigation) start, follow and end the same call.
|
|
223
|
+
const voice = useVoiceService(
|
|
224
|
+
voiceConfig ? { ...voiceConfig, wsUrl: client.serverOrigin.replace(/^http/, "ws") } : INERT_VOICE,
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// CONNECTED TO THE TASK UNTIL THE PAGE CLOSES. The avatar is a button that starts a task's
|
|
228
|
+
// workflow; from that press the page is in the task (its steps, its submit) until the task
|
|
229
|
+
// closes or the visitor leaves. Whether the workflow has a voice is its own business, so
|
|
230
|
+
// nothing here waits on a call (UNOVERSE_HOST_PAGE.md rule 4).
|
|
231
|
+
const [inTask, setInTask] = useState(false);
|
|
232
|
+
|
|
187
233
|
// SDK's native connection: opens /stream, register_session, feeds the store. Outbound
|
|
188
234
|
// sendMessage fires the workflow DIRECTLY (REST, user token) — no server broker.
|
|
189
235
|
const conn = useUnoverseConnection(
|
|
@@ -197,9 +243,16 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
197
243
|
appId,
|
|
198
244
|
},
|
|
199
245
|
store,
|
|
200
|
-
// Open
|
|
201
|
-
//
|
|
202
|
-
|
|
246
|
+
// Open once the user is known AND there is something to hear from: the app's own workflow,
|
|
247
|
+
// or a task the page is in. An app with no workflow (a website's page app) connects when the
|
|
248
|
+
// avatar starts a task and stays connected to it; nothing is held open while the visitor
|
|
249
|
+
// only reads.
|
|
250
|
+
{
|
|
251
|
+
enabled: !!userId && (!!binding.workflow || voice.state.isCallActive || inTask),
|
|
252
|
+
onAppSwitch: switchApp,
|
|
253
|
+
// A voice in the running workflow asks the page in: join its call, never start one.
|
|
254
|
+
onJoinCall: (workflowId) => void voice.joinCall(workflowId),
|
|
255
|
+
},
|
|
203
256
|
);
|
|
204
257
|
|
|
205
258
|
// STATUS TO THE HOST. In native-host mode the iframe owns the only real
|
|
@@ -225,39 +278,59 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
225
278
|
});
|
|
226
279
|
}, [conn.isReady, conn.isConnected, conn.refusal, failure]);
|
|
227
280
|
|
|
228
|
-
//
|
|
229
|
-
//
|
|
230
|
-
//
|
|
231
|
-
//
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
281
|
+
// THE SPEC'S ANSWERS, AND NOTHING ELSE (UNOVERSE_MCP_COMPLETE_GUIDE.md, "How the SDK answers an
|
|
282
|
+
// elicitation"): while the server is ELICITING on this session, a component's `submit` answers
|
|
283
|
+
// `accept` with its slice and its `cancel` answers `cancel`. Every other action (a state it
|
|
284
|
+
// enters, a step, a tab) is the component's own and goes to the server as usual. Without a
|
|
285
|
+
// pending elicitation, a submit falls back to the data-plane `user_action` waiter (a workflow
|
|
286
|
+
// node waiting on a component). One way for every component, the app's and the page's alike.
|
|
287
|
+
const toServer = (t: string, data: Record<string, unknown>, meta?: ActionMeta) => {
|
|
288
|
+
const state = (meta?.state ?? data) as Record<string, unknown>;
|
|
289
|
+
if ((t === "submit" || t === "cancel") && conn.resolveElicitation(state, t === "cancel" ? "cancel" : "accept")) return;
|
|
290
|
+
conn.sendAction(t, {
|
|
291
|
+
componentNodeId: meta?.nodeId,
|
|
292
|
+
componentType: meta?.type,
|
|
293
|
+
// The component's OWN chatId is the TURN id — the waiting node execution is
|
|
294
|
+
// keyed `<turn>:<nodeId>`, not the conversation-level chatId.
|
|
295
|
+
componentChatId: meta?.chatId,
|
|
296
|
+
componentState: state,
|
|
297
|
+
});
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// A PAGE TEMPLATE'S TASK, started from its avatar: the template's own workflow, run at its
|
|
301
|
+
// trigger over REST with the user's token, as any request to a workflow (§5a). `metadata.task`
|
|
302
|
+
// names the task its Task Runner loads. From here the page is in the task.
|
|
303
|
+
const startTask = async (b: Binding, task: string) => {
|
|
304
|
+
if (!b.workflow) return;
|
|
305
|
+
setInTask(true);
|
|
306
|
+
const token = getAccessToken ? await getAccessToken() : null;
|
|
307
|
+
fetch(`${apiUrl ?? serverUrl}/api/workflows/${b.workflow}/execute`, {
|
|
308
|
+
method: "POST",
|
|
309
|
+
headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
310
|
+
body: JSON.stringify({
|
|
311
|
+
input: {
|
|
312
|
+
message: "Start",
|
|
313
|
+
chatId: conversationId,
|
|
314
|
+
conversationId,
|
|
315
|
+
userId,
|
|
316
|
+
providerId: "gravity-ds",
|
|
317
|
+
metadata: { targetTriggerNode: b.trigger, workflowId: b.workflow, task },
|
|
318
|
+
},
|
|
319
|
+
conversationId,
|
|
320
|
+
}),
|
|
321
|
+
}).catch((e) => console.error("[unoverse] task start failed", e));
|
|
322
|
+
};
|
|
254
323
|
|
|
255
324
|
return (
|
|
325
|
+
<>
|
|
326
|
+
{/* The page's own templates, drawn into its elements by this one runtime (pageInterfaces.tsx). */}
|
|
327
|
+
<PageInterfaces org={appId.split("/")[0]} client={client} voice={voice} appStore={store} toServer={toServer} startTask={startTask} />
|
|
256
328
|
<StreamedUnoverseApp
|
|
257
329
|
client={client}
|
|
258
330
|
store={store}
|
|
259
331
|
uri={`unoverse://apps/${appId}/definition`}
|
|
260
332
|
voice={voiceConfig}
|
|
333
|
+
voiceService={voice}
|
|
261
334
|
onAction={(action, data, meta) => {
|
|
262
335
|
const t = typeof action === "string" ? action : (action as any)?.type;
|
|
263
336
|
if (t === "close") {
|
|
@@ -312,24 +385,9 @@ export function AppEngine(cfg: AppConfig) {
|
|
|
312
385
|
store.setDraft(typeof (data as any)?.text === "string" ? (data as any).text : "");
|
|
313
386
|
return;
|
|
314
387
|
}
|
|
315
|
-
|
|
316
|
-
// answers an elicitation"): while the server is ELICITING on this session, the
|
|
317
|
-
// component's `submit` answers `accept` with its slice and its `cancel` answers
|
|
318
|
-
// `cancel`. Every other action (a state it enters, a step, a tab) is the component's
|
|
319
|
-
// own and goes to the server as usual. Without a pending elicitation, a submit falls
|
|
320
|
-
// back to the data-plane `user_action` waiter (a workflow node waiting on a component).
|
|
321
|
-
if (!t) return;
|
|
322
|
-
const state = (meta?.state ?? data) as Record<string, unknown>;
|
|
323
|
-
if ((t === "submit" || t === "cancel") && conn.resolveElicitation(state, t === "cancel" ? "cancel" : "accept")) return;
|
|
324
|
-
conn.sendAction(t, {
|
|
325
|
-
componentNodeId: meta?.nodeId,
|
|
326
|
-
componentType: meta?.type,
|
|
327
|
-
// The component's OWN chatId is the TURN id — the waiting node execution is
|
|
328
|
-
// keyed `<turn>:<nodeId>`, not the conversation-level chatId.
|
|
329
|
-
componentChatId: meta?.chatId,
|
|
330
|
-
componentState: state,
|
|
331
|
-
});
|
|
388
|
+
if (t) toServer(t, data, meta);
|
|
332
389
|
}}
|
|
333
390
|
/>
|
|
391
|
+
</>
|
|
334
392
|
);
|
|
335
393
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* holding. It never mattered to the component: the same `renderNode` walks the same
|
|
14
14
|
* definition, and `setValue` writes the same instance either way.
|
|
15
15
|
*/
|
|
16
|
-
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties } from "react";
|
|
16
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore, type CSSProperties, type ReactNode } from "react";
|
|
17
17
|
import { dispatchAction, matches, type ComponentStore, type ResolvedTheme, type UnoverseClient, type UnoverseDefinition } from "./core";
|
|
18
18
|
import { renderNode, keyframesCss, type ActionHandler, type SlotResolver } from "./render";
|
|
19
19
|
import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
|
|
@@ -74,9 +74,22 @@ export interface UnoverseComponentProps {
|
|
|
74
74
|
* container. Flow/rail instances stay content-sized (never set there).
|
|
75
75
|
*/
|
|
76
76
|
fill?: boolean;
|
|
77
|
+
/**
|
|
78
|
+
* WHAT A `Place` IN THIS DEFINITION DRAWS. An app's places are filled by the screen
|
|
79
|
+
* (app.tsx); a place inside a component or template is filled by its host, which alone
|
|
80
|
+
* knows what belongs there (a fullscreen template's place holds the page's avatar).
|
|
81
|
+
* Without it a place draws nothing.
|
|
82
|
+
*/
|
|
83
|
+
place?: (name: string) => ReactNode;
|
|
84
|
+
/**
|
|
85
|
+
* THE APP'S STATE, when it lives in another store (live source only). A template a website
|
|
86
|
+
* page draws keeps its own slice in its own store, and shares one app state with the rest
|
|
87
|
+
* of the page: it reads that state beneath its own values, and `setAppValue` writes there.
|
|
88
|
+
*/
|
|
89
|
+
appStore?: ComponentStore;
|
|
77
90
|
}
|
|
78
91
|
|
|
79
|
-
export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, extraData, onAction, theme, isolate = true, fill = false }: UnoverseComponentProps) {
|
|
92
|
+
export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, extraData, onAction, theme, isolate = true, fill = false, place, appStore }: UnoverseComponentProps) {
|
|
80
93
|
const [def, setDef] = useState<UnoverseDefinition | null>(null);
|
|
81
94
|
const [error, setError] = useState<string | null>(null);
|
|
82
95
|
// A component's OWN view state (a tab, a step, an expanded face) moves by writing its own
|
|
@@ -88,6 +101,10 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
|
|
|
88
101
|
// LIVE SOURCE. The hook runs either way (hooks cannot be conditional) and no-ops without a
|
|
89
102
|
// store, so one component serves both paths without a wrapper deciding for it.
|
|
90
103
|
const live = useUnoverseInstance(store, chatId, nodeId);
|
|
104
|
+
const appState = useSyncExternalStore(
|
|
105
|
+
useCallback((cb: () => void) => (appStore ? appStore.subscribe(cb) : () => {}), [appStore]),
|
|
106
|
+
useCallback(() => appStore?.getAppState(), [appStore]),
|
|
107
|
+
);
|
|
91
108
|
// The SERVER-SENT address first: an org component's URI is org-qualified
|
|
92
109
|
// (`unoverse://components/<org>/<name>`) and the bare type cannot rebuild it once two
|
|
93
110
|
// orgs share a name. The rebuild-from-type stays as the fallback for events that
|
|
@@ -150,6 +167,7 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
|
|
|
150
167
|
store,
|
|
151
168
|
chatId: chatId ?? "",
|
|
152
169
|
nodeId: nodeId ?? "",
|
|
170
|
+
appStore,
|
|
153
171
|
// Server actions carry the INSTANCE context (which component + its current
|
|
154
172
|
// slice) so the channel can forward the submitted state with the action.
|
|
155
173
|
sendToServer: onAction
|
|
@@ -174,9 +192,41 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
|
|
|
174
192
|
}
|
|
175
193
|
onAction?.(action, payload, meta);
|
|
176
194
|
},
|
|
177
|
-
[store, chatId, nodeId, onAction, live.type],
|
|
195
|
+
[store, chatId, nodeId, onAction, live.type, appStore],
|
|
178
196
|
);
|
|
179
197
|
|
|
198
|
+
// ARROW KEYS STEP A TEMPLATE. A template's steps (the served stateTree: `on` + its
|
|
199
|
+
// substates) move with Left and Right while it holds the main place, through the same
|
|
200
|
+
// setValue its own Back and Next make, so the step is saved and reported exactly as a
|
|
201
|
+
// click would be. Forms (components) are left alone: their steps are confirmations.
|
|
202
|
+
// Typing in a field never steps.
|
|
203
|
+
const slice = store ? sourceData : localSlice;
|
|
204
|
+
useEffect(() => {
|
|
205
|
+
const d = def as (UnoverseDefinition & {
|
|
206
|
+
kind?: string;
|
|
207
|
+
stateTree?: { name: string; on?: string; states?: { name: string }[] }[];
|
|
208
|
+
statePlaces?: Record<string, string>;
|
|
209
|
+
}) | null;
|
|
210
|
+
if (d?.kind !== "template") return;
|
|
211
|
+
const face = String((slice as Record<string, unknown> | undefined)?.state ?? d.state?.state ?? "");
|
|
212
|
+
const tree = (d.stateTree ?? []).find((s) => s.name === face);
|
|
213
|
+
const steps = tree?.states?.map((s) => s.name) ?? [];
|
|
214
|
+
if (!tree?.on || steps.length < 2 || d.statePlaces?.[face] !== "main") return;
|
|
215
|
+
const on = tree.on;
|
|
216
|
+
const onKey = (e: KeyboardEvent) => {
|
|
217
|
+
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
|
|
218
|
+
const t = e.target as HTMLElement | null;
|
|
219
|
+
if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return;
|
|
220
|
+
const cur = String((slice as Record<string, unknown> | undefined)?.[on] ?? d.state?.[on] ?? 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
|
+
handleAction({ type: "setValue", values: [{ key: on, value: steps[i] }] }, {});
|
|
225
|
+
};
|
|
226
|
+
document.addEventListener("keydown", onKey);
|
|
227
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
228
|
+
}, [def, slice, handleAction]);
|
|
229
|
+
|
|
180
230
|
// Loading/error hints carry NO styling — the SDK owns zero styles (tokens or hints).
|
|
181
231
|
if (error) return <div>Unoverse error: {error}</div>;
|
|
182
232
|
if (store && !live.type) return <div>waiting for COMPONENT_INIT…</div>;
|
|
@@ -186,7 +236,7 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
|
|
|
186
236
|
// when its actions write them. Props stay the EXTERNAL contract.
|
|
187
237
|
// The local slice sits ON TOP: a value the component wrote itself is the current one.
|
|
188
238
|
const merged = store
|
|
189
|
-
? { ...propDefaults, ...(def?.state ?? {}), ...(sourceData ?? {}) }
|
|
239
|
+
? { ...propDefaults, ...(appState ?? {}), ...(def?.state ?? {}), ...(sourceData ?? {}) }
|
|
190
240
|
: { ...propDefaults, ...(def?.state ?? {}), ...(sourceData ?? {}), ...localSlice };
|
|
191
241
|
// Inject the served keyframes + apply the SERVED base render-root (theme.root — DS typography
|
|
192
242
|
// + font-smoothing). When ISOLATED, theme.root is applied to the shadow-root container inside
|
|
@@ -212,8 +262,10 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
|
|
|
212
262
|
// belongs to the app's timeline, which scopes it to a single turn's unclaimed
|
|
213
263
|
// components. Resolving it here too rendered every instance in the store a second time
|
|
214
264
|
// — the composed page appeared in its surface AND in the chat beside it.
|
|
215
|
-
const resolveSlot: SlotResolver | undefined = store && (def as { sections?: unknown }).sections
|
|
265
|
+
const resolveSlot: SlotResolver | undefined = (store && (def as { sections?: unknown }).sections) || place
|
|
216
266
|
? (node, key) => {
|
|
267
|
+
if (node.type === "Place") return place?.(String((node as { name?: unknown }).name ?? "")) ?? null;
|
|
268
|
+
if (!store) return null;
|
|
217
269
|
const claim = node.select?.where;
|
|
218
270
|
const pointers = store
|
|
219
271
|
.getResponses()
|
|
@@ -237,6 +289,7 @@ export function UnoverseComponent({ client, uri, data, store, chatId, nodeId, ex
|
|
|
237
289
|
nodeId={n}
|
|
238
290
|
onAction={onAction}
|
|
239
291
|
theme={activeTheme}
|
|
292
|
+
appStore={appStore}
|
|
240
293
|
/>
|
|
241
294
|
);
|
|
242
295
|
});
|
package/vendor/sdk/lib/app.tsx
CHANGED
|
@@ -104,7 +104,7 @@ function FlipList({ items }: { items: { key: string; node: ReactNode }[] }) {
|
|
|
104
104
|
}
|
|
105
105
|
import { useUnoverseTheme, ensureFontStylesheets } from "./theme";
|
|
106
106
|
import { IsolatedRoot } from "./isolate";
|
|
107
|
-
import { useVoiceService, type UseVoiceServiceConfig } from "./voice";
|
|
107
|
+
import { useVoiceService, type UseVoiceServiceConfig, type VoiceService } from "./voice";
|
|
108
108
|
|
|
109
109
|
export interface StreamedUnoverseAppProps {
|
|
110
110
|
client: UnoverseClient;
|
|
@@ -130,12 +130,15 @@ export interface StreamedUnoverseAppProps {
|
|
|
130
130
|
* shared home for the voice buttons: `startCall`/`endCall`/`toggleMute` are answered
|
|
131
131
|
* HERE, not hand-wired per host (that drift is what broke the demo's Start Call). */
|
|
132
132
|
voice?: UseVoiceServiceConfig;
|
|
133
|
+
/** The voice service itself, when the host already runs one to share with the page's own
|
|
134
|
+
* templates (appEngine). Given, the renderer uses it instead of starting a second. */
|
|
135
|
+
voiceService?: VoiceService;
|
|
133
136
|
}
|
|
134
137
|
|
|
135
138
|
/** Inert voice config for non-voice templates — the hook is called unconditionally
|
|
136
139
|
* (rules of hooks) but nothing opens until a `startCall` this template never fires.
|
|
137
140
|
* No `store` → not a producer, so it writes no callState into ordinary templates. */
|
|
138
|
-
const INERT_VOICE: UseVoiceServiceConfig = {
|
|
141
|
+
export const INERT_VOICE: UseVoiceServiceConfig = {
|
|
139
142
|
apiUrl: "",
|
|
140
143
|
session: { userId: "", conversationId: "", workflowId: "", targetTriggerNode: "" },
|
|
141
144
|
};
|
|
@@ -156,7 +159,7 @@ function collectSlotPointers(node: unknown, store: ComponentStore, scope?: Set<s
|
|
|
156
159
|
return [...new Set(out)];
|
|
157
160
|
}
|
|
158
161
|
|
|
159
|
-
export function StreamedUnoverseApp({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig, templateChrome }: StreamedUnoverseAppProps) {
|
|
162
|
+
export function StreamedUnoverseApp({ client, store, uri, onAction, theme: themeProp, props, isolate = true, voice: voiceConfig, voiceService, templateChrome }: StreamedUnoverseAppProps) {
|
|
160
163
|
const [def, setDef] = useState<UnoverseDefinition | null>(null);
|
|
161
164
|
const [error, setError] = useState<string | null>(null);
|
|
162
165
|
// MOUNTED TEMPLATES: an app entry with `use:` names a template definition; it is
|
|
@@ -167,9 +170,10 @@ export function StreamedUnoverseApp({ client, store, uri, onAction, theme: theme
|
|
|
167
170
|
// The audio WS lane travels WITH the SDK: derive it from the client's own server origin
|
|
168
171
|
// (same server that hosts /mcp + /stream + /ws/gravity), NEVER from the host — a host's
|
|
169
172
|
// `location.origin` is "null" in a sandboxed srcdoc iframe. Overrides any host wsUrl.
|
|
170
|
-
const
|
|
171
|
-
voiceConfig ? { ...voiceConfig, wsUrl: client.serverOrigin.replace(/^http/, "ws") } : INERT_VOICE,
|
|
173
|
+
const own = useVoiceService(
|
|
174
|
+
voiceConfig && !voiceService ? { ...voiceConfig, wsUrl: client.serverOrigin.replace(/^http/, "ws") } : INERT_VOICE,
|
|
172
175
|
);
|
|
176
|
+
const voice = voiceService ?? own;
|
|
173
177
|
// No theme passed → fetch from the server (the SDK owns zero token values). The
|
|
174
178
|
// theme REF comes from the definition itself: a template belongs to a client org,
|
|
175
179
|
// and that org names its theme — `<org>/light`. Wait for the def before fetching
|
|
@@ -80,7 +80,7 @@ const UNOVERSE_EVENT = "notifications/unoverse/event";
|
|
|
80
80
|
/**
|
|
81
81
|
* Guest identity — CONTAINED HERE so hosts never write guest logic. A token-less
|
|
82
82
|
* session is a GUEST to the platform's public-entry gate, which only admits
|
|
83
|
-
* `guest-`-prefixed ids (Run Authorization,
|
|
83
|
+
* `guest-`-prefixed ids (Run Authorization, docs/auth-security/IDENTITY.md). Minted once per
|
|
84
84
|
* browser and persisted so the visitor's conversation and memory cohere across
|
|
85
85
|
* reloads. A host that supplies its own guest id wins (two apps in one page share
|
|
86
86
|
* one visitor); a host with a token is untouched.
|
|
@@ -118,7 +118,7 @@ export function useUnoverseConnection(
|
|
|
118
118
|
config: UnoverseConnectionConfig,
|
|
119
119
|
session: UnoverseSessionParams,
|
|
120
120
|
store: ComponentStore,
|
|
121
|
-
options: { enabled?: boolean; onAppSwitch?: (app: string) => void } = {},
|
|
121
|
+
options: { enabled?: boolean; onAppSwitch?: (app: string) => void; onJoinCall?: (workflowId?: string) => void } = {},
|
|
122
122
|
): UnoverseConnection {
|
|
123
123
|
const enabled = options.enabled ?? true;
|
|
124
124
|
// Read through a ref so a host's inline arrow does not tear down the stream on
|
|
@@ -126,6 +126,8 @@ export function useUnoverseConnection(
|
|
|
126
126
|
const onAppSwitchRef = useRef(options.onAppSwitch);
|
|
127
127
|
onAppSwitchRef.current = options.onAppSwitch;
|
|
128
128
|
const onAppSwitch = (app: string) => onAppSwitchRef.current?.(app);
|
|
129
|
+
const onJoinCallRef = useRef(options.onJoinCall);
|
|
130
|
+
onJoinCallRef.current = options.onJoinCall;
|
|
129
131
|
// Read through a ref too: the server follows app switches itself, so a new app must not
|
|
130
132
|
// tear the stream down; the next registration simply names it.
|
|
131
133
|
const appIdRef = useRef(session.appId);
|
|
@@ -152,8 +154,10 @@ export function useUnoverseConnection(
|
|
|
152
154
|
// ---- connection lifecycle (MCP stream) ----
|
|
153
155
|
useEffect(() => {
|
|
154
156
|
if (!enabled) return; // mock mode — no live stream
|
|
155
|
-
|
|
156
|
-
|
|
157
|
+
// No workflowId is fine: a page app with none still hears its conversation (the server
|
|
158
|
+
// takes a signed-in session without one; a guest's still needs a public trigger).
|
|
159
|
+
if (!userId || !conversationId) {
|
|
160
|
+
console.warn("[unoverse] connection missing userId/conversationId");
|
|
157
161
|
return;
|
|
158
162
|
}
|
|
159
163
|
let cancelled = false;
|
|
@@ -267,6 +271,13 @@ export function useUnoverseConnection(
|
|
|
267
271
|
if (typeof next === "string" && next) onAppSwitch?.(next);
|
|
268
272
|
return;
|
|
269
273
|
}
|
|
274
|
+
// A VOICE IN THE WORKFLOW ASKS THE PAGE TO JOIN (VOICE_STREAMING_GUIDE.md, "Call
|
|
275
|
+
// Initialization"). The host's voice service joins; a host without one ignores it.
|
|
276
|
+
if (evt.type === "JOIN_CALL") {
|
|
277
|
+
const workflowId = (msg as unknown as { workflowId?: unknown }).workflowId;
|
|
278
|
+
onJoinCallRef.current?.(typeof workflowId === "string" ? workflowId : undefined);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
270
281
|
applyServerMessage(store, msg, seenRef.current);
|
|
271
282
|
};
|
|
272
283
|
|
|
@@ -19,6 +19,9 @@ export interface ActionContext {
|
|
|
19
19
|
nodeId: string;
|
|
20
20
|
/** Route a non-`setValue` action to the workflow (channel-provided, e.g. USER_ACTION). */
|
|
21
21
|
sendToServer?: (type: string, data: Record<string, unknown>) => void;
|
|
22
|
+
/** Where APP state lives, when it is not `store`: a template a website page draws keeps its
|
|
23
|
+
* own slice in its own store and shares the app's state with everything on the page. */
|
|
24
|
+
appStore?: ComponentStore;
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
/** Resolve a value: a `"{{path}}"` template reads from `scope` (dot-path); anything
|
|
@@ -86,7 +89,7 @@ export function dispatchAction(
|
|
|
86
89
|
// concept — only "write a key to component state" and "write a key to template state".
|
|
87
90
|
const patch: Record<string, unknown> = {};
|
|
88
91
|
for (const { key, value } of spec.values ?? []) patch[key] = resolveValue(value, scope);
|
|
89
|
-
ctx.store.mergeAppState(patch);
|
|
92
|
+
(ctx.appStore ?? ctx.store).mergeAppState(patch);
|
|
90
93
|
} else if (spec.type === "input") {
|
|
91
94
|
// A controlled Input changed — write its bound field back into the state bag that
|
|
92
95
|
// OWNS this tree (local two-way binding; every keystroke is local, never a server
|
|
@@ -98,7 +101,7 @@ export function dispatchAction(
|
|
|
98
101
|
const field = typeof scope.field === "string" ? scope.field : undefined;
|
|
99
102
|
if (field) {
|
|
100
103
|
if (ctx.nodeId) ctx.store.apply({ type: "COMPONENT_DATA", chatId: ctx.chatId, nodeId: ctx.nodeId, data: { [field]: scope.text } });
|
|
101
|
-
else ctx.store.mergeAppState({ [field]: scope.text });
|
|
104
|
+
else (ctx.appStore ?? ctx.store).mergeAppState({ [field]: scope.text });
|
|
102
105
|
}
|
|
103
106
|
} else {
|
|
104
107
|
// SERVER — narrow the payload by `send` if given, else forward the whole scope.
|
package/vendor/sdk/lib/index.ts
CHANGED
|
@@ -2,10 +2,10 @@ export { UnoverseComponent } from "./UnoverseComponent";
|
|
|
2
2
|
export type { UnoverseComponentProps } from "./UnoverseComponent";
|
|
3
3
|
export { StreamedUnoverseComponent, useUnoverseInstance } from "./streamed";
|
|
4
4
|
export type { StreamedUnoverseComponentProps } from "./streamed";
|
|
5
|
-
export { StreamedUnoverseApp, selectPointers, computeAppWidth, resolveActiveLayout } from "./app";
|
|
5
|
+
export { StreamedUnoverseApp, INERT_VOICE, selectPointers, computeAppWidth, resolveActiveLayout } from "./app";
|
|
6
6
|
export type { StreamedUnoverseAppProps } from "./app";
|
|
7
7
|
export { renderNode, styleToCss, keyframesCss } from "./render";
|
|
8
|
-
export type { ActionHandler, SlotResolver } from "./render";
|
|
8
|
+
export type { ActionHandler, ActionMeta, SlotResolver } from "./render";
|
|
9
9
|
export { useUnoverseTheme } from "./theme";
|
|
10
10
|
export { IsolatedRoot } from "./isolate";
|
|
11
11
|
export { useUnoverseConnection } from "./connection";
|
|
@@ -107,17 +107,28 @@ function nodeChrome(node: UnoverseNode, data: Record<string, unknown>, theme: Re
|
|
|
107
107
|
// reacts to the space the grid actually has, not the viewport. A component in a narrow
|
|
108
108
|
// panel on a wide screen stacks correctly, which a viewport breakpoint cannot do.
|
|
109
109
|
//
|
|
110
|
-
// The threshold is SERVED (`theme.grid.stackBelow`)
|
|
111
|
-
// `style.stackBelow`. The SDK holds no width of its own.
|
|
110
|
+
// The threshold is SERVED (`theme.grid.stackBelow`). The SDK holds no width of its own.
|
|
112
111
|
if (s?.span) {
|
|
113
|
-
const raw =
|
|
112
|
+
const raw = theme.grid?.stackBelow ?? "";
|
|
114
113
|
const w = theme.space[raw] ?? theme.layout?.[raw] ?? raw;
|
|
115
114
|
if (w) {
|
|
116
115
|
const cls = `uno-stack-${hashStr(String(w))}`;
|
|
117
116
|
classes.push(cls);
|
|
118
|
-
styleEls.push(<style key="span-stack">{`@container (max-width:${w}){.${cls}{grid-column:1 / -1 !important}}`}</style>);
|
|
117
|
+
styleEls.push(<style key="span-stack">{`@container (max-width:${w}){.${cls}:not(.uno-gridstack > *){grid-column:1 / -1 !important}}`}</style>);
|
|
119
118
|
}
|
|
120
119
|
}
|
|
120
|
+
// A GRID'S OWN `stackBelow` overrides the served threshold for that grid: below it, EVERY
|
|
121
|
+
// child takes the whole row, spanning or not, so a two-up list stacks as a 7/5 split does.
|
|
122
|
+
// The children query the grid itself (it is `container-type: inline-size`), and the
|
|
123
|
+
// served threshold above steps aside for them (`uno-gridstack`), so a grid may hold its
|
|
124
|
+
// shape longer as well as give it up sooner.
|
|
125
|
+
if (s?.columns && s.stackBelow) {
|
|
126
|
+
const raw = s.stackBelow as string;
|
|
127
|
+
const w = theme.space[raw] ?? theme.layout?.[raw] ?? raw;
|
|
128
|
+
const cls = `uno-gridstack-${hashStr(String(w))}`;
|
|
129
|
+
classes.push("uno-gridstack", cls);
|
|
130
|
+
styleEls.push(<style key="grid-stack">{`@container (max-width:${w}){.${cls} > *{grid-column:1 / -1 !important}}`}</style>);
|
|
131
|
+
}
|
|
121
132
|
// `columns` AS A BREAKPOINT MAP — the responsive grid: column counts per named band
|
|
122
133
|
// ({mobile: "1", tablet: "2", desktop: "3", full: "4"}), applied by CONTAINER width
|
|
123
134
|
// (the nearest `container` ancestor — a grid in a narrow panel stacks on a wide
|
|
@@ -183,6 +194,29 @@ export function BoxView({ node, data, theme, children, onAction }: { node: Unove
|
|
|
183
194
|
// A selection host must anchor its floating affordance; default to relative only when
|
|
184
195
|
// the definition declared no position of its own (sticky/absolute roots keep theirs).
|
|
185
196
|
const hostStyle = sel && !(style as CSSProperties).position ? ({ ...style, position: "relative" } as CSSProperties) : style;
|
|
197
|
+
// A STAGE (`style.stage: 1280x720`): the Box is drawn at that fixed size and scaled as one
|
|
198
|
+
// piece to the width it is given, the way slide tools render a slide. Its layout never
|
|
199
|
+
// reflows, so what is aligned stays aligned on every screen.
|
|
200
|
+
const stage = /^(\d+)x(\d+)$/.exec(String((node.style as { stage?: unknown } | undefined)?.stage ?? ""));
|
|
201
|
+
const stageW = stage ? Number(stage[1]) : 0;
|
|
202
|
+
const stageRef = useRef<HTMLDivElement>(null);
|
|
203
|
+
const [scale, setScale] = useState(1);
|
|
204
|
+
useLayoutEffect(() => {
|
|
205
|
+
const el = stageRef.current;
|
|
206
|
+
if (!stageW || !el) return;
|
|
207
|
+
const ro = new ResizeObserver(() => setScale(el.clientWidth / stageW));
|
|
208
|
+
ro.observe(el);
|
|
209
|
+
return () => ro.disconnect();
|
|
210
|
+
}, [stageW]);
|
|
211
|
+
if (stage)
|
|
212
|
+
return (
|
|
213
|
+
<div ref={stageRef} style={{ position: "relative", width: "100%", aspectRatio: `${stage[1]} / ${stage[2]}` }}>
|
|
214
|
+
<div className={className} style={{ ...hostStyle, position: "absolute", top: 0, left: 0, width: stageW, height: Number(stage[2]), transform: `scale(${scale})`, transformOrigin: "0 0" }} data-unoverse-id={inspectId(node)}>
|
|
215
|
+
{styleEls}
|
|
216
|
+
{children}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
186
220
|
return (
|
|
187
221
|
<div ref={scrollRef ?? revealRef ?? selRef} className={className} style={hostStyle} data-unoverse-id={inspectId(node)}>
|
|
188
222
|
{styleEls}
|
|
@@ -30,6 +30,8 @@ export interface UseAudioPlaybackReturn {
|
|
|
30
30
|
isPlaying: boolean;
|
|
31
31
|
/** Mark that no more chunks are coming — isPlaying clears when the last finishes. */
|
|
32
32
|
markAsLastChunk: () => void;
|
|
33
|
+
/** Wake the speaker, inside a click. */
|
|
34
|
+
unlock: () => void;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
interface QueuedAudio {
|
|
@@ -148,5 +150,8 @@ export function useAudioPlayback(): UseAudioPlaybackReturn {
|
|
|
148
150
|
};
|
|
149
151
|
}, [stopAll]);
|
|
150
152
|
|
|
151
|
-
|
|
153
|
+
// Wakes the speaker. Called inside a click, because a browser plays nothing it was not woken for.
|
|
154
|
+
const unlock = useCallback(() => void getAudioContext(), [getAudioContext]);
|
|
155
|
+
|
|
156
|
+
return { playAudio, stopAll, isPlaying, markAsLastChunk, unlock };
|
|
152
157
|
}
|