@unoverse-platform/studio 0.1.103 → 0.1.104
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/screens/AtomsView.tsx +2 -1
- package/screens/ComponentsView.tsx +2 -1
- package/screens/NativeAppHost.tsx +1 -2
- package/screens/TemplatesLibraryView.tsx +14 -1
- package/screens/TemplatesView.tsx +2 -1
- package/screens/index.ts +4 -0
- package/screens/react/unoverse.tsx +281 -0
- package/vendor/base/src/agent-mcp/index.ts +1 -1
- package/vendor/base/src/agent-mcp/invoke.ts +6 -2
- package/vendor/base/src/agent-mcp/tools.ts +14 -5
- package/vendor/base/src/agent-mcp/types.ts +6 -0
- package/vendor/base/src/auth/auth.ts +90 -23
- package/vendor/base/src/definitions/load/compile/sections.ts +79 -0
- package/vendor/base/src/definitions/load/compile/template.ts +23 -2
- package/vendor/base/src/definitions/model.ts +9 -0
- package/vendor/base/src/definitions/registry/catalog.ts +6 -7
- package/vendor/base/src/executor.ts +25 -1
- package/vendor/base/src/manifests/executor/toolBridge.ts +25 -19
- package/vendor/base/src/manifests/runtime/channels/final.ts +4 -1
- package/vendor/base/src/manifests/runtime/duplex/session.ts +3 -9
- package/vendor/base/src/manifests/runtime/tools/toolloop.ts +28 -25
- package/vendor/base/src/platform/serviceCalls.ts +13 -0
- package/vendor/sdk/appEngine.ts +363 -0
- package/vendor/sdk/lib/actions.ts +36 -0
- package/vendor/sdk/lib/citations.ts +48 -0
- package/vendor/sdk/lib/core/apps.ts +18 -0
- package/vendor/sdk/lib/core/client.ts +3 -3
- package/vendor/sdk/lib/core/store.ts +6 -0
- package/vendor/sdk/lib/dom/app.ts +546 -0
- package/vendor/sdk/lib/dom/chrome.ts +125 -0
- package/vendor/sdk/lib/dom/component.ts +333 -0
- package/vendor/sdk/lib/dom/connection.ts +309 -0
- package/vendor/sdk/lib/dom/element.ts +69 -0
- package/vendor/sdk/lib/dom/isolate.ts +37 -0
- package/vendor/sdk/lib/dom/markdown.ts +388 -0
- package/vendor/sdk/lib/dom/markdownParse.ts +440 -0
- package/vendor/sdk/lib/dom/mounted.ts +29 -0
- package/vendor/sdk/lib/dom/primitives.ts +689 -0
- package/vendor/sdk/lib/dom/region.ts +195 -0
- package/vendor/sdk/lib/dom/render.ts +177 -0
- package/vendor/sdk/lib/dom/theme.ts +38 -0
- package/vendor/sdk/lib/dom/types.ts +36 -0
- package/vendor/sdk/lib/index.ts +26 -17
- package/vendor/sdk/lib/orb/canvas.ts +139 -0
- package/vendor/sdk/lib/orb/index.ts +8 -18
- package/vendor/sdk/lib/orb/presets.ts +1 -1
- package/vendor/sdk/lib/orb/{types.ts → states.ts} +0 -24
- package/vendor/sdk/lib/realtime/capture.ts +152 -0
- package/vendor/sdk/lib/realtime/playback.ts +129 -0
- package/vendor/sdk/lib/realtime/socket.ts +117 -0
- package/vendor/sdk/lib/style.ts +45 -37
- package/vendor/sdk/lib/voiceService.ts +344 -0
- package/vendor/sdk/main.ts +107 -0
- package/vendor/sdk/mcpAppBridge.ts +125 -102
- package/vendor/sdk/pageInterfaces.ts +452 -0
- package/vendor/sdk/responseView.ts +193 -0
- package/vite.config.ts +3 -3
- package/vendor/sdk/appEngine.tsx +0 -395
- package/vendor/sdk/lib/UnoverseComponent.tsx +0 -325
- package/vendor/sdk/lib/app.tsx +0 -730
- package/vendor/sdk/lib/connection.tsx +0 -433
- package/vendor/sdk/lib/isolate.tsx +0 -70
- package/vendor/sdk/lib/orb/ThinkingOrb.tsx +0 -131
- package/vendor/sdk/lib/orb/theme.ts +0 -83
- package/vendor/sdk/lib/primitives.tsx +0 -943
- package/vendor/sdk/lib/realtime/useAudioCapture.ts +0 -237
- package/vendor/sdk/lib/realtime/useAudioPlayback.ts +0 -157
- package/vendor/sdk/lib/realtime/useRealtimeWebSocket.ts +0 -164
- package/vendor/sdk/lib/render.tsx +0 -244
- package/vendor/sdk/lib/streamed.tsx +0 -30
- package/vendor/sdk/lib/theme.ts +0 -55
- package/vendor/sdk/lib/transition/MountArrangement.tsx +0 -71
- package/vendor/sdk/lib/transition/index.ts +0 -9
- package/vendor/sdk/lib/transition/viewTransition.ts +0 -29
- package/vendor/sdk/lib/voice.tsx +0 -422
- package/vendor/sdk/main.tsx +0 -125
- package/vendor/sdk/pageInterfaces.tsx +0 -388
- package/vendor/sdk/responseView.tsx +0 -82
|
@@ -1,433 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* useUnoverseConnection — the CHANNEL's data-plane connector (MCP stream).
|
|
3
|
-
*
|
|
4
|
-
* Opens the live workflow connection to the Unoverse server's `/stream` MCP endpoint
|
|
5
|
-
* (Streamable-HTTP SSE) for the inbound component stream, and REST `…/execute` for
|
|
6
|
-
* sending messages. Inbound `notifications/unoverse/event` messages feed the single
|
|
7
|
-
* `ComponentStore` via the pure `applyServerMessage` mapper.
|
|
8
|
-
*
|
|
9
|
-
* Per UNOVERSE_MCP_APP_PROTOCOL.md §5/§5a/§5b: the component stream rides MCP
|
|
10
|
-
* (NOT the legacy `/ws/gravity` WS — that's reserved for live audio). The node
|
|
11
|
-
* executes in the same Unoverse process that serves this stream, so there's no
|
|
12
|
-
* gateway relay and no Redis in the component path.
|
|
13
|
-
*/
|
|
14
|
-
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
15
|
-
import { emitAnalytics } from "./core/analytics";
|
|
16
|
-
import { applyServerMessage, viewOf, type ComponentStore, type ServerMessage } from "./core";
|
|
17
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
18
|
-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
19
|
-
// Interpreter-based JSON-schema validator (no `new Function`). The SDK's default Ajv
|
|
20
|
-
// provider compiles schemas with `new Function`, which ChatGPT's widget CSP (no
|
|
21
|
-
// unsafe-eval) blocks — that crashed the in-iframe MCP client on boot and left the
|
|
22
|
-
// widget blank. CfWorker is the SDK's own edge/no-eval provider.
|
|
23
|
-
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
|
|
24
|
-
import { ElicitRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
25
|
-
|
|
26
|
-
export interface UnoverseConnectionConfig {
|
|
27
|
-
/** Gravity API base, e.g. "http://localhost:4100". REST execute lives here. */
|
|
28
|
-
apiUrl: string;
|
|
29
|
-
/** Unoverse server base, e.g. "http://localhost:4105" (or the workbench origin in
|
|
30
|
-
* dev). The `/stream` MCP data-plane endpoint (§5b) is appended. */
|
|
31
|
-
streamUrl: string;
|
|
32
|
-
/** Optional JWT provider — the token rides the MCP transport fetch + REST Authorization header. */
|
|
33
|
-
getAccessToken?: () => Promise<string | null>;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface UnoverseSessionParams {
|
|
37
|
-
workflowId: string;
|
|
38
|
-
targetTriggerNode: string;
|
|
39
|
-
userId: string;
|
|
40
|
-
conversationId: string;
|
|
41
|
-
/** Groups one request→response turn. Defaults to conversationId if omitted. */
|
|
42
|
-
chatId?: string;
|
|
43
|
-
/** The app this session shows (`<org>/<name>`). The server draws an app that declares places
|
|
44
|
-
* from its screen document (docs/unoverse/design/screen/UNOVERSE_SCREEN.md). */
|
|
45
|
-
appId?: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export interface UnoverseConnection {
|
|
49
|
-
isConnected: boolean;
|
|
50
|
-
isReady: boolean;
|
|
51
|
-
/** The server's refusal text when register_session answered isError (public-entry
|
|
52
|
-
* gate, requires.role, stale token) — null while unrefused. A refusal is TERMINAL
|
|
53
|
-
* for the attempt (the server has answered; retrying the same identity loops), so
|
|
54
|
-
* hosts must surface this instead of showing "connecting" forever. Cleared when a
|
|
55
|
-
* new connect sequence starts. */
|
|
56
|
-
refusal: string | null;
|
|
57
|
-
/** Composer target: optimistic user turn + REST workflow trigger. */
|
|
58
|
-
sendMessage: (text: string) => void;
|
|
59
|
-
/** Fire the bound workflow WITHOUT a user turn — the "load → run the workflow" ping
|
|
60
|
-
* (its component streams back into the shell). `input` shapes the execute payload per
|
|
61
|
-
* the app's `inputSchema`; omit for a bare trigger. (§5b REST `/execute`; the future
|
|
62
|
-
* MCP-native form is a `tools/call` on the InputTrigger tool, same call site.) */
|
|
63
|
-
trigger: (input?: Record<string, unknown>) => void;
|
|
64
|
-
/** Other component actions (button clicks, etc.) → MCP `user_action` tool. */
|
|
65
|
-
sendAction: (action: string, data: Record<string, unknown>) => void;
|
|
66
|
-
/** Answer a pending ELICITATION with the submitted component state (the native
|
|
67
|
-
* collection path — §3.3). Returns false when no elicitation is pending (the
|
|
68
|
-
* channel then falls back to `sendAction`). */
|
|
69
|
-
resolveElicitation: (state: Record<string, unknown>, action?: "accept" | "cancel") => boolean;
|
|
70
|
-
/** The workflow-selected template (MCP app) name, or null. The channel renders
|
|
71
|
-
* `unoverse://apps/{activeApp}/definition` when set — the official resources/read load. */
|
|
72
|
-
activeApp: string | null;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** The data-plane endpoint on the Unoverse server (§5b). */
|
|
76
|
-
const STREAM_ENDPOINT = "/stream";
|
|
77
|
-
/** Custom notification carrying one ServerMessage as `params` (§5b). */
|
|
78
|
-
const UNOVERSE_EVENT = "notifications/unoverse/event";
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Guest identity — CONTAINED HERE so hosts never write guest logic. A token-less
|
|
82
|
-
* session is a GUEST to the platform's public-entry gate, which only admits
|
|
83
|
-
* `guest-`-prefixed ids (Run Authorization, docs/auth-security/IDENTITY.md). Minted once per
|
|
84
|
-
* browser and persisted so the visitor's conversation and memory cohere across
|
|
85
|
-
* reloads. A host that supplies its own guest id wins (two apps in one page share
|
|
86
|
-
* one visitor); a host with a token is untouched.
|
|
87
|
-
*/
|
|
88
|
-
const GUEST_KEY = "unoverse:guestId";
|
|
89
|
-
export function resolveGuestId(): string {
|
|
90
|
-
let id: string | null = null;
|
|
91
|
-
try {
|
|
92
|
-
id = localStorage.getItem(GUEST_KEY);
|
|
93
|
-
} catch {
|
|
94
|
-
/* storage blocked → per-load guest below */
|
|
95
|
-
}
|
|
96
|
-
if (!id || !/^guest[-:]/.test(id)) {
|
|
97
|
-
id = `guest-${crypto.randomUUID()}`;
|
|
98
|
-
try {
|
|
99
|
-
localStorage.setItem(GUEST_KEY, id);
|
|
100
|
-
} catch {
|
|
101
|
-
/* fine: the id just won't survive a reload */
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
return id;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* THE ONE IDENTITY RULE for every lane the SDK opens: with a token the host's userId passes
|
|
109
|
-
* through; without one the platform sees a guest and requires a guest-prefixed id, so a
|
|
110
|
-
* host-supplied guest id wins and anything else is replaced by the stored guest id. The data
|
|
111
|
-
* plane and the audio lane both call this, so a page never speaks with two names.
|
|
112
|
-
*/
|
|
113
|
-
export function sessionUserId(userId: string | undefined, getAccessToken: unknown): string {
|
|
114
|
-
return getAccessToken || /^guest[-:]/.test(userId ?? "") ? (userId as string) : resolveGuestId();
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
export function useUnoverseConnection(
|
|
118
|
-
config: UnoverseConnectionConfig,
|
|
119
|
-
session: UnoverseSessionParams,
|
|
120
|
-
store: ComponentStore,
|
|
121
|
-
options: { enabled?: boolean; onAppSwitch?: (app: string) => void; onJoinCall?: (workflowId?: string) => void } = {},
|
|
122
|
-
): UnoverseConnection {
|
|
123
|
-
const enabled = options.enabled ?? true;
|
|
124
|
-
// Read through a ref so a host's inline arrow does not tear down the stream on
|
|
125
|
-
// every render — the connection effect must not depend on the callback's identity.
|
|
126
|
-
const onAppSwitchRef = useRef(options.onAppSwitch);
|
|
127
|
-
onAppSwitchRef.current = options.onAppSwitch;
|
|
128
|
-
const onAppSwitch = (app: string) => onAppSwitchRef.current?.(app);
|
|
129
|
-
const onJoinCallRef = useRef(options.onJoinCall);
|
|
130
|
-
onJoinCallRef.current = options.onJoinCall;
|
|
131
|
-
// Read through a ref too: the server follows app switches itself, so a new app must not
|
|
132
|
-
// tear the stream down; the next registration simply names it.
|
|
133
|
-
const appIdRef = useRef(session.appId);
|
|
134
|
-
appIdRef.current = session.appId;
|
|
135
|
-
const { streamUrl, getAccessToken } = config;
|
|
136
|
-
const { workflowId, targetTriggerNode, conversationId } = session;
|
|
137
|
-
// No token ⇒ the platform sees a GUEST session and requires a guest-prefixed id —
|
|
138
|
-
// supply one automatically (host-supplied guest ids win; with a token the host's
|
|
139
|
-
// real userId passes through untouched). This is the whole guest story for a host.
|
|
140
|
-
const userId = sessionUserId(session.userId, getAccessToken);
|
|
141
|
-
const chatId = session.chatId ?? conversationId;
|
|
142
|
-
|
|
143
|
-
const [isConnected, setIsConnected] = useState(false);
|
|
144
|
-
const [isReady, setIsReady] = useState(false);
|
|
145
|
-
const [refusal, setRefusal] = useState<string | null>(null);
|
|
146
|
-
const clientRef = useRef<Client | null>(null);
|
|
147
|
-
// First-seen component keys for this connection (repeat INIT → props merge).
|
|
148
|
-
const seenRef = useRef<Set<string>>(new Set());
|
|
149
|
-
// The pending elicitation's resolver (one at a time per session) — set while the
|
|
150
|
-
// server is asking this session for structured input; the channel's submit branch
|
|
151
|
-
// answers it via `resolveElicitation`.
|
|
152
|
-
const pendingElicitRef = useRef<((state: Record<string, unknown>, action: "accept" | "cancel") => void) | null>(null);
|
|
153
|
-
|
|
154
|
-
// ---- connection lifecycle (MCP stream) ----
|
|
155
|
-
useEffect(() => {
|
|
156
|
-
if (!enabled) return; // mock mode — no live stream
|
|
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");
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
|
-
let cancelled = false;
|
|
164
|
-
let attempt = 0;
|
|
165
|
-
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
166
|
-
// Kept ACROSS reconnects on purpose: after a re-bind the server may re-push
|
|
167
|
-
// components this session already placed, and a repeat INIT on a seen key merges
|
|
168
|
-
// props instead of re-placing (applyServerMessage) — the screen never jumps.
|
|
169
|
-
seenRef.current = new Set();
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* SESSION-DEATH RECOVERY (the MCP spec's own rule): a session id the server no
|
|
173
|
-
* longer knows answers 404, and the client MUST start a NEW session rather than
|
|
174
|
-
* mourn the old one. Sessions are process memory server-side, so every restart —
|
|
175
|
-
* a dev edit, a prod deploy — orphans every open channel; before this, the error
|
|
176
|
-
* was logged once ("stream connect failed" / "client error") and the surface sat
|
|
177
|
-
* disconnected forever. Now ANY client error recycles the whole client and
|
|
178
|
-
* re-runs the connect sequence with backoff: register_session is idempotent, a
|
|
179
|
-
* re-bind emits SESSION_READY, and mount-reads re-hydrate state, so downstream
|
|
180
|
-
* already copes. Blunt on purpose — a transport error we fail to recognise as
|
|
181
|
-
* session death costs one spurious reconnect, while one we miss is a dead screen.
|
|
182
|
-
*/
|
|
183
|
-
const recycle = (dead: Client, why: unknown) => {
|
|
184
|
-
// Only the CURRENT client may trigger recovery: a dying client's teardown fires
|
|
185
|
-
// stray errors (observed: four AbortErrors in one restart) and each would
|
|
186
|
-
// otherwise schedule its own storm of reconnects.
|
|
187
|
-
if (cancelled || clientRef.current !== dead) return;
|
|
188
|
-
clientRef.current = null;
|
|
189
|
-
void dead.close().catch(() => {});
|
|
190
|
-
setIsConnected(false);
|
|
191
|
-
setIsReady(false);
|
|
192
|
-
const delay = Math.min(15_000, 500 * 2 ** Math.min(attempt++, 5));
|
|
193
|
-
console.warn(`[unoverse] session lost — reconnecting in ${delay}ms (attempt ${attempt})`, why);
|
|
194
|
-
retryTimer = setTimeout(() => void connect(), delay);
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
const connect = async () => {
|
|
198
|
-
if (cancelled) return;
|
|
199
|
-
// Declare the ELICITATION capability (form mode) — the native collection path
|
|
200
|
-
// (UNOVERSE_MCP_APP_PROTOCOL §3.3): the server pauses inside an app tool
|
|
201
|
-
// call and asks THIS session for the user's structured input. The interactive
|
|
202
|
-
// component already on screen IS the form; its `submit` answers the elicitation.
|
|
203
|
-
const client = new Client(
|
|
204
|
-
{ name: "unoverse-channel", version: "0.0.1" },
|
|
205
|
-
{ capabilities: { elicitation: {} }, jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
|
|
206
|
-
);
|
|
207
|
-
client.setRequestHandler(ElicitRequestSchema, async (req) => {
|
|
208
|
-
// Pend until the user submits the component (the channel calls resolveElicitation
|
|
209
|
-
// from its submit branch). The branded UI renders the request — never a generic form.
|
|
210
|
-
// The accept content MUST match the requestedSchema (spec: flat primitives only) —
|
|
211
|
-
// project the submitted component state to the REQUESTED keys, or the SDK rejects
|
|
212
|
-
// the response (view-state like a `courses` array fails the primitive union).
|
|
213
|
-
const requested =
|
|
214
|
-
(req.params as unknown as { requestedSchema?: { properties?: Record<string, unknown> } }).requestedSchema?.properties ?? {};
|
|
215
|
-
return await new Promise((resolve) => {
|
|
216
|
-
pendingElicitRef.current = (state: Record<string, unknown>, action: "accept" | "cancel") => {
|
|
217
|
-
pendingElicitRef.current = null;
|
|
218
|
-
if (action === "cancel") return resolve({ action: "cancel" } as never);
|
|
219
|
-
const content: Record<string, unknown> = {};
|
|
220
|
-
for (const k of Object.keys(requested)) if (state[k] !== undefined) content[k] = state[k];
|
|
221
|
-
resolve({ action: "accept", content } as never);
|
|
222
|
-
};
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
// The SDK fire-and-forgets the standalone GET SSE open on `initialized`; a failure
|
|
226
|
-
// lands ONLY here. This is also where a dead session surfaces mid-life (the SDK's
|
|
227
|
-
// own GET reconnect meets the restarted server's 404) — so it feeds recovery.
|
|
228
|
-
client.onerror = (e) => {
|
|
229
|
-
// TEARDOWN NOISE, not an error: closing a client aborts its SSE streams and the
|
|
230
|
-
// SDK reports every abort here. When the close was OURS (effect cleanup on an
|
|
231
|
-
// app switch, or a recycle that already replaced this client), a red error log
|
|
232
|
-
// reads as breakage — it made a clean app switch look like a failure (observed
|
|
233
|
-
// live 2026-08-16). Quiet log, no recovery; a live client's errors still recycle.
|
|
234
|
-
if (cancelled || clientRef.current !== client) {
|
|
235
|
-
// eslint-disable-next-line no-console
|
|
236
|
-
console.debug("[unoverse] client closed:", (e as Error)?.message ?? e);
|
|
237
|
-
return;
|
|
238
|
-
}
|
|
239
|
-
console.error("[unoverse] client error:", e);
|
|
240
|
-
recycle(client, e);
|
|
241
|
-
};
|
|
242
|
-
clientRef.current = client;
|
|
243
|
-
|
|
244
|
-
// The server pushes the live stream as `notifications/unoverse/event` over the
|
|
245
|
-
// GET SSE channel. Custom notification methods land on the fallback handler —
|
|
246
|
-
// route their `params` (one ServerMessage) into the store.
|
|
247
|
-
client.fallbackNotificationHandler = async (notification) => {
|
|
248
|
-
if (notification.method !== UNOVERSE_EVENT) return;
|
|
249
|
-
const msg = (notification.params ?? {}) as unknown as ServerMessage;
|
|
250
|
-
if ((msg as { type?: string }).type === "SESSION_READY") {
|
|
251
|
-
setIsReady(true);
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
// A manifest-declared analytics moment, resolved SERVER-side beside the lifecycle
|
|
255
|
-
// hook that shares it (componentLifecycle.ts). The SDK adds nothing and decides
|
|
256
|
-
// nothing here: it hands the event to the same sink every node-attached emit uses,
|
|
257
|
-
// and the host realm delivers or drops it. News, never instruction — it does not
|
|
258
|
-
// touch the store.
|
|
259
|
-
const evt = msg as unknown as { type?: string; event?: string; params?: Record<string, unknown> };
|
|
260
|
-
if (evt.type === "ANALYTICS_EVENT") {
|
|
261
|
-
if (typeof evt.event === "string" && evt.event) emitAnalytics(evt.event, evt.params ?? {});
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
// AN APP SWAP IS THE HOST'S CALL, not the renderer's. Handed straight out, like
|
|
265
|
-
// ANALYTICS_EVENT above and for the same reason: it does not touch the store —
|
|
266
|
-
// the conversation and everything already on screen survive a swap, which is the
|
|
267
|
-
// whole point of swapping rather than starting again. The host re-points its one
|
|
268
|
-
// engine (appEngine.tsx); a host that ignores this simply stays where it is.
|
|
269
|
-
if (evt.type === "APP_SWITCH") {
|
|
270
|
-
const next = (msg as unknown as { app?: unknown }).app;
|
|
271
|
-
if (typeof next === "string" && next) onAppSwitch?.(next);
|
|
272
|
-
return;
|
|
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
|
-
}
|
|
281
|
-
applyServerMessage(store, msg, seenRef.current);
|
|
282
|
-
};
|
|
283
|
-
|
|
284
|
-
// Inject the bearer on every underlying request (fresh per call → refresh-safe).
|
|
285
|
-
// ALWAYS wrapped: dev tunnels need the ngrok bypass header, token or not
|
|
286
|
-
// (ngrok free interstitial = 200 HTML no CORS = dead /stream in sandboxed
|
|
287
|
-
// hosts; header is hostname-gated so prod requests are untouched).
|
|
288
|
-
const authFetch = async (url: string | URL, init?: RequestInit): Promise<Response> => {
|
|
289
|
-
const token = getAccessToken ? await getAccessToken() : undefined;
|
|
290
|
-
const headers = new Headers(init?.headers);
|
|
291
|
-
if (token) headers.set("Authorization", `Bearer ${token}`);
|
|
292
|
-
if (String(url).includes("ngrok")) headers.set("ngrok-skip-browser-warning", "true");
|
|
293
|
-
return fetch(url, { ...init, headers });
|
|
294
|
-
};
|
|
295
|
-
try {
|
|
296
|
-
const transport = new StreamableHTTPClientTransport(
|
|
297
|
-
new URL(`${streamUrl}${STREAM_ENDPOINT}`),
|
|
298
|
-
{ fetch: authFetch },
|
|
299
|
-
);
|
|
300
|
-
await client.connect(transport);
|
|
301
|
-
if (cancelled || clientRef.current !== client) {
|
|
302
|
-
await client.close().catch(() => {});
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
setIsConnected(true);
|
|
306
|
-
setRefusal(null); // a fresh attempt owns a fresh verdict
|
|
307
|
-
// Bind this stream to the session — the server then pushes the live component
|
|
308
|
-
// stream and an immediate SESSION_READY.
|
|
309
|
-
const bind = await client.callTool({
|
|
310
|
-
name: "register_session",
|
|
311
|
-
arguments: { userId, conversationId, workflowId, targetTriggerNode, chatId, ...(appIdRef.current ? { appId: appIdRef.current } : {}) },
|
|
312
|
-
});
|
|
313
|
-
// A refusal (public-entry gate, requires.role, …) is an isError tool RESULT,
|
|
314
|
-
// not a thrown error — surface it and STOP, never retry: the server has
|
|
315
|
-
// answered, and re-asking with the same identity is a polite way to loop
|
|
316
|
-
// (the server logs the same refusal as REFUSED:). The reason is kept as state,
|
|
317
|
-
// not just logged: a console.error alone left the host chip on "Connecting…"
|
|
318
|
-
// forever with the cause visible to nobody (observed live 2026-08-16).
|
|
319
|
-
if ((bind as { isError?: boolean }).isError) {
|
|
320
|
-
const c = (bind as { content?: Array<{ type?: string; text?: string }> }).content;
|
|
321
|
-
const reason = c?.[0]?.text ?? "unauthorized";
|
|
322
|
-
console.error("[unoverse] session refused:", reason);
|
|
323
|
-
setRefusal(reason);
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
// A completed re-bind is health — the next death starts its backoff fresh.
|
|
327
|
-
attempt = 0;
|
|
328
|
-
} catch (e) {
|
|
329
|
-
if (cancelled) return;
|
|
330
|
-
console.error("[unoverse] stream connect failed", e);
|
|
331
|
-
recycle(client, e);
|
|
332
|
-
}
|
|
333
|
-
};
|
|
334
|
-
|
|
335
|
-
void connect();
|
|
336
|
-
|
|
337
|
-
return () => {
|
|
338
|
-
cancelled = true;
|
|
339
|
-
if (retryTimer !== null) clearTimeout(retryTimer);
|
|
340
|
-
setIsConnected(false);
|
|
341
|
-
setIsReady(false);
|
|
342
|
-
setRefusal(null);
|
|
343
|
-
void clientRef.current?.close().catch(() => {});
|
|
344
|
-
clientRef.current = null;
|
|
345
|
-
};
|
|
346
|
-
}, [enabled, streamUrl, workflowId, targetTriggerNode, userId, conversationId, chatId, getAccessToken, store]);
|
|
347
|
-
|
|
348
|
-
// ---- turn identity ----
|
|
349
|
-
// `conversationId` names the CONVERSATION (session addressing, agent memory,
|
|
350
|
-
// elicitation); `chatId` names ONE TURN (the store's response identity — every
|
|
351
|
-
// component of a turn keys `chatId:nodeId`). The channel MINTS a fresh turn id per
|
|
352
|
-
// outbound send; the `<conversationId>:t…` prefix keeps conversation-scoped waiter
|
|
353
|
-
// resolution (elicitation accept) matching turn-scoped node waiters.
|
|
354
|
-
const mintTurn = useCallback(
|
|
355
|
-
() => `${chatId}:t${Date.now().toString(36)}${Math.floor(Math.random() * 1296).toString(36)}`,
|
|
356
|
-
[chatId],
|
|
357
|
-
);
|
|
358
|
-
|
|
359
|
-
// ---- outbound: fire the bound workflow over REST (§5b /execute) ----
|
|
360
|
-
// No user turn — this is the raw trigger. `sendMessage` layers a user turn on top.
|
|
361
|
-
const trigger = useCallback(
|
|
362
|
-
(input?: Record<string, unknown>, turnId?: string) => {
|
|
363
|
-
const turn = turnId ?? mintTurn();
|
|
364
|
-
(async () => {
|
|
365
|
-
// NATIVE MCP: fire the run via a `tools/call` on the live /stream session — NOT
|
|
366
|
-
// the retired REST `/api/workflows/:id/execute` (UNOVERSE_MCP_APP_PROTOCOL
|
|
367
|
-
// §0.3). Fire-and-forget: the run's UI streams back over this same session; the
|
|
368
|
-
// caller's token rides the connection (server reads it from extra.authInfo).
|
|
369
|
-
const client = clientRef.current;
|
|
370
|
-
if (!client) {
|
|
371
|
-
console.error("[unoverse] trigger dropped — MCP stream not connected yet");
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
// Same `input` envelope the REST route received (server unwraps it 1:1).
|
|
375
|
-
const inputEnvelope = { chatId: turn, conversationId, userId, providerId: "gravity-ds", metadata: { targetTriggerNode }, ...input };
|
|
376
|
-
try {
|
|
377
|
-
await client.callTool({ name: "trigger", arguments: { workflowId, conversationId, input: inputEnvelope } });
|
|
378
|
-
} catch (e) {
|
|
379
|
-
console.error("[unoverse] trigger failed", e);
|
|
380
|
-
}
|
|
381
|
-
})();
|
|
382
|
-
},
|
|
383
|
-
[workflowId, targetTriggerNode, userId, conversationId, mintTurn],
|
|
384
|
-
);
|
|
385
|
-
|
|
386
|
-
// ---- outbound: send a message (optimistic user turn + trigger) ----
|
|
387
|
-
const sendMessage = useCallback(
|
|
388
|
-
(text: string) => {
|
|
389
|
-
const message = text.trim();
|
|
390
|
-
if (!message) return;
|
|
391
|
-
const turn = mintTurn();
|
|
392
|
-
store.addUserMessage(turn, message); // optimistic user turn
|
|
393
|
-
// The answer opens under the message that asked for it. What is on screen is the
|
|
394
|
-
// SERVER's fact (the standing set, the open pages); the send carries nothing about it.
|
|
395
|
-
const run = turn;
|
|
396
|
-
// Optimistically open the assistant turn too, so the avatar + thinking dots
|
|
397
|
-
// appear IMMEDIATELY on send instead of waiting for the backend's
|
|
398
|
-
// WORKFLOW_STARTED. startResponse is idempotent — the later WORKFLOW_STARTED
|
|
399
|
-
// (and the first COMPONENT_INIT) reuse this same turn (responseFor by chatId).
|
|
400
|
-
store.startResponse(run);
|
|
401
|
-
trigger({ message }, run);
|
|
402
|
-
},
|
|
403
|
-
[mintTurn, store, trigger],
|
|
404
|
-
);
|
|
405
|
-
|
|
406
|
-
// ---- outbound: generic component action → MCP `user_action` tool ----
|
|
407
|
-
const sendAction = useCallback(
|
|
408
|
-
(action: string, data: Record<string, unknown>) => {
|
|
409
|
-
void clientRef.current
|
|
410
|
-
?.callTool({ name: "user_action", arguments: { action, data, userId, conversationId, chatId } })
|
|
411
|
-
.catch((e) => console.error("[unoverse] user_action failed", e));
|
|
412
|
-
},
|
|
413
|
-
[userId, conversationId, chatId],
|
|
414
|
-
);
|
|
415
|
-
|
|
416
|
-
// The workflow-selected template, reactively (a primitive snapshot so the store
|
|
417
|
-
// subscription doesn't loop). The channel renders it via resources/read.
|
|
418
|
-
// Stable subscribe fn — an inline arrow re-subscribes every render.
|
|
419
|
-
const activeApp = useSyncExternalStore(
|
|
420
|
-
useCallback((cb) => store.subscribe(cb), [store]),
|
|
421
|
-
useCallback(() => store.getActiveApp().name, [store]),
|
|
422
|
-
);
|
|
423
|
-
|
|
424
|
-
// Answer a pending elicitation with the submitted component state (native path).
|
|
425
|
-
const resolveElicitation = useCallback((state: Record<string, unknown>, action: "accept" | "cancel" = "accept"): boolean => {
|
|
426
|
-
const pending = pendingElicitRef.current;
|
|
427
|
-
if (!pending) return false;
|
|
428
|
-
pending(state, action);
|
|
429
|
-
return true;
|
|
430
|
-
}, []);
|
|
431
|
-
|
|
432
|
-
return { isConnected, isReady, refusal, sendMessage, trigger, sendAction, resolveElicitation, activeApp };
|
|
433
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* <IsolatedRoot> — mounts `children` inside an open Shadow DOM for complete CSS isolation.
|
|
3
|
-
*
|
|
4
|
-
* The SDK is the renderer for EVERY consumer (the canvas node preview, the live channel,
|
|
5
|
-
* the workbench, external host apps), so the isolation boundary lives HERE — once — rather
|
|
6
|
-
* than being bolted onto each host (today only the canvas wraps; the channel renders
|
|
7
|
-
* unprotected, so page styles and component styles leak both ways).
|
|
8
|
-
*
|
|
9
|
-
* Why a portal, not a nested React root: `createPortal` renders into the shadow root while
|
|
10
|
-
* keeping the SAME React tree/context, so the SDK's client/store/theme context still flows
|
|
11
|
-
* to the rendered components. A separate `createRoot` would sever that.
|
|
12
|
-
*
|
|
13
|
-
* Web-only — this file lives in `unoverse-react`, never in the framework-agnostic core
|
|
14
|
-
* (Flutter/Swift have no DOM). Token VALUES are already resolved inline by the renderer, so
|
|
15
|
-
* concrete styles work unchanged inside the shadow root; only inherited typography needs the
|
|
16
|
-
* reset below, after which the served `theme.root` is the sole typographic baseline.
|
|
17
|
-
*/
|
|
18
|
-
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
|
19
|
-
import { createPortal } from "react-dom";
|
|
20
|
-
|
|
21
|
-
// Shadow DOM blocks style SELECTORS but NOT inheritance: inherited properties (font, colour,
|
|
22
|
-
// line-height, letter-spacing) still pierce the boundary, and <button>/<input> fall back to
|
|
23
|
-
// the UA font. `:host { all: initial }` neutralizes the inherited cascade; `display: contents`
|
|
24
|
-
// keeps the host out of layout (the consumer owns the outer box). The themed child the SDK
|
|
25
|
-
// passes in (theme.root) then re-establishes the design-system typography.
|
|
26
|
-
const RESET_CSS = `
|
|
27
|
-
:host { all: initial; display: contents; }
|
|
28
|
-
.uno-isolate-root { box-sizing: border-box; }
|
|
29
|
-
.uno-isolate-root *, .uno-isolate-root *::before, .uno-isolate-root *::after { box-sizing: border-box; }
|
|
30
|
-
.uno-isolate-root button, .uno-isolate-root input, .uno-isolate-root textarea, .uno-isolate-root select {
|
|
31
|
-
font: inherit; letter-spacing: inherit; color: inherit; margin: 0;
|
|
32
|
-
}
|
|
33
|
-
/* Scrollbars hidden by default across every scroll container — scrolling still works,
|
|
34
|
-
the bar is just not painted. Generic chrome that design definitions can't author per-node (pseudo-
|
|
35
|
-
elements), so it lives with the structural reset. */
|
|
36
|
-
.uno-isolate-root * { scrollbar-width: none; }
|
|
37
|
-
.uno-isolate-root *::-webkit-scrollbar { width: 0; height: 0; display: none; }
|
|
38
|
-
`;
|
|
39
|
-
|
|
40
|
-
export function IsolatedRoot({ children, rootStyle }: { children: ReactNode; rootStyle?: CSSProperties }) {
|
|
41
|
-
const hostRef = useRef<HTMLDivElement>(null);
|
|
42
|
-
const [shadow, setShadow] = useState<ShadowRoot | null>(null);
|
|
43
|
-
|
|
44
|
-
useEffect(() => {
|
|
45
|
-
const host = hostRef.current;
|
|
46
|
-
if (!host) return;
|
|
47
|
-
// Reuse an existing shadow root across re-mounts (attachShadow throws if called twice).
|
|
48
|
-
setShadow(host.shadowRoot ?? host.attachShadow({ mode: "open" }));
|
|
49
|
-
}, []);
|
|
50
|
-
|
|
51
|
-
return (
|
|
52
|
-
<div ref={hostRef} style={{ display: "contents" }}>
|
|
53
|
-
{shadow &&
|
|
54
|
-
createPortal(
|
|
55
|
-
<>
|
|
56
|
-
<style>{RESET_CSS}</style>
|
|
57
|
-
{/* Apply the served DS render-root (theme.root: font family/size/weight/line-height/
|
|
58
|
-
colour + smoothing) DIRECTLY to this REAL container — not a `display:contents`
|
|
59
|
-
wrapper, whose inherited-font cascade is unreliable inside a shadow root. This is
|
|
60
|
-
the Unoverse equivalent of the legacy `.gravity-component` baseline, so every node
|
|
61
|
-
in the tree inherits the design-system typography. */}
|
|
62
|
-
<div className="uno-isolate-root" style={rootStyle}>
|
|
63
|
-
{children}
|
|
64
|
-
</div>
|
|
65
|
-
</>,
|
|
66
|
-
shadow as unknown as Element,
|
|
67
|
-
)}
|
|
68
|
-
</div>
|
|
69
|
-
);
|
|
70
|
-
}
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
// The ThinkingOrb component. One shared clock (performance.now) keeps
|
|
2
|
-
// every mounted orb in phase; each instance runs its own rAF loop but
|
|
3
|
-
// pauses automatically while offscreen (IntersectionObserver) or when
|
|
4
|
-
// the tab is hidden (visibilitychange). Reduced-motion users get a
|
|
5
|
-
// static representative frame that still follows the live theme.
|
|
6
|
-
|
|
7
|
-
import { useEffect, useRef } from 'react';
|
|
8
|
-
import { MODE_DRAWS } from './engine/registry';
|
|
9
|
-
import { setInk } from './engine/core';
|
|
10
|
-
import { resolvePreset } from './presets';
|
|
11
|
-
import { useReducedMotion, useResolvedDark } from './theme';
|
|
12
|
-
import type { OrbSize, ThinkingOrbProps } from './types';
|
|
13
|
-
|
|
14
|
-
const LABELS: Record<string, string> = {
|
|
15
|
-
working: 'Working…',
|
|
16
|
-
searching: 'Searching…',
|
|
17
|
-
solving: 'Solving…',
|
|
18
|
-
listening: 'Listening…',
|
|
19
|
-
connecting: 'Connecting…',
|
|
20
|
-
weaving: 'Weaving…',
|
|
21
|
-
composing: 'Composing…',
|
|
22
|
-
breathing: 'Thinking…',
|
|
23
|
-
shaping: 'Shaping…',
|
|
24
|
-
halo: 'Waiting…',
|
|
25
|
-
sonar: 'Listening…',
|
|
26
|
-
shimmer: 'Thinking…',
|
|
27
|
-
pulse: 'Responding…'
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
export function ThinkingOrb({
|
|
31
|
-
state = 'working',
|
|
32
|
-
size = 64,
|
|
33
|
-
theme = 'auto',
|
|
34
|
-
speed = 1,
|
|
35
|
-
paused = false,
|
|
36
|
-
ink = null,
|
|
37
|
-
style,
|
|
38
|
-
'aria-label': ariaLabel,
|
|
39
|
-
...rest
|
|
40
|
-
}: ThinkingOrbProps & { ink?: [number, number, number] | null }) {
|
|
41
|
-
const ref = useRef<HTMLCanvasElement | null>(null);
|
|
42
|
-
const dark = useResolvedDark(theme, ref);
|
|
43
|
-
const reduced = useReducedMotion();
|
|
44
|
-
|
|
45
|
-
useEffect(() => {
|
|
46
|
-
const canvas = ref.current;
|
|
47
|
-
if (!canvas) return;
|
|
48
|
-
const dpr = Math.min(2, (typeof devicePixelRatio !== 'undefined' && devicePixelRatio) || 1);
|
|
49
|
-
canvas.width = Math.round(size * dpr);
|
|
50
|
-
canvas.height = Math.round(size * dpr);
|
|
51
|
-
const ctx = canvas.getContext('2d');
|
|
52
|
-
if (!ctx) return;
|
|
53
|
-
|
|
54
|
-
// LOCAL EXTENSION (Unoverse): `size` is any positive number — the canvas and
|
|
55
|
-
// draw run at the true size (the engine's radiusScale compensates), while the
|
|
56
|
-
// TUNING snaps to the nearest of the two baked presets (20 inline / 64 avatar).
|
|
57
|
-
const preset: OrbSize = size <= 32 ? 20 : 64;
|
|
58
|
-
const { mode, speed: baseSpeed, opts } = resolvePreset(state, preset);
|
|
59
|
-
const draw = MODE_DRAWS[mode];
|
|
60
|
-
const effSpeed = baseSpeed * speed;
|
|
61
|
-
|
|
62
|
-
const frame = (tSec: number) => {
|
|
63
|
-
// Ink is a module-level current (frames are synchronous); set per frame so
|
|
64
|
-
// orbs with different brand tokens on one page never bleed into each other.
|
|
65
|
-
setInk(ink);
|
|
66
|
-
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
67
|
-
ctx.clearRect(0, 0, size, size);
|
|
68
|
-
draw(ctx, size, tSec, dark, opts);
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
// reduced motion → one static, deterministic frame
|
|
72
|
-
if (reduced) {
|
|
73
|
-
frame(0.6);
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
let raf = 0;
|
|
78
|
-
let running = false;
|
|
79
|
-
const loop = () => {
|
|
80
|
-
frame((performance.now() / 1000) * effSpeed);
|
|
81
|
-
if (running) raf = requestAnimationFrame(loop);
|
|
82
|
-
};
|
|
83
|
-
const start = () => {
|
|
84
|
-
if (running || paused) return;
|
|
85
|
-
running = true;
|
|
86
|
-
raf = requestAnimationFrame(loop);
|
|
87
|
-
};
|
|
88
|
-
const stop = () => {
|
|
89
|
-
running = false;
|
|
90
|
-
cancelAnimationFrame(raf);
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
// draw at least one frame even when paused/offscreen
|
|
94
|
-
frame((performance.now() / 1000) * effSpeed);
|
|
95
|
-
|
|
96
|
-
// pause offscreen + on hidden tabs — free when not visible
|
|
97
|
-
let visible = true;
|
|
98
|
-
const io =
|
|
99
|
-
typeof IntersectionObserver !== 'undefined'
|
|
100
|
-
? new IntersectionObserver(([entry]) => {
|
|
101
|
-
visible = entry.isIntersecting;
|
|
102
|
-
if (visible && document.visibilityState !== 'hidden') start();
|
|
103
|
-
else stop();
|
|
104
|
-
})
|
|
105
|
-
: null;
|
|
106
|
-
io?.observe(canvas);
|
|
107
|
-
const onVis = () => {
|
|
108
|
-
if (document.visibilityState === 'hidden') stop();
|
|
109
|
-
else if (visible) start();
|
|
110
|
-
};
|
|
111
|
-
document.addEventListener('visibilitychange', onVis);
|
|
112
|
-
if (!io) start();
|
|
113
|
-
|
|
114
|
-
return () => {
|
|
115
|
-
stop();
|
|
116
|
-
io?.disconnect();
|
|
117
|
-
document.removeEventListener('visibilitychange', onVis);
|
|
118
|
-
};
|
|
119
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps -- ink compared by value: a tuple is a fresh identity every render
|
|
120
|
-
}, [state, size, dark, speed, paused, reduced, ink?.join(",")]);
|
|
121
|
-
|
|
122
|
-
return (
|
|
123
|
-
<canvas
|
|
124
|
-
ref={ref}
|
|
125
|
-
role="img"
|
|
126
|
-
aria-label={ariaLabel ?? LABELS[state]}
|
|
127
|
-
style={{ width: size, height: size, display: 'block', ...style }}
|
|
128
|
-
{...rest}
|
|
129
|
-
/>
|
|
130
|
-
);
|
|
131
|
-
}
|