@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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useVoiceService — the Unoverse `voice` NATIVE SERVICE (UNOVERSE_SPEC §2e-1 Tier-3).
|
|
3
|
+
*
|
|
4
|
+
* Audio I/O is inherently native (getUserMedia / AudioContext / the /ws/gravity WS
|
|
5
|
+
* lane), so it cannot be expressed as definition data — it is the spec's sanctioned
|
|
6
|
+
* escape hatch: a fixed `{ state, actions }` capability the SDK provides and a template
|
|
7
|
+
* REFERENCES (`service: "voice"`), binding its neutral state + dispatching its actions.
|
|
8
|
+
*
|
|
9
|
+
* This composes the ported realtime hooks (capture / playback / the WS audio lane) +
|
|
10
|
+
* the orchestration from the legacy useVoiceCall + useAudioContext into ONE neutral
|
|
11
|
+
* projection. The channel spreads `state` into the template's data scope and routes the
|
|
12
|
+
* voice action names to the actions — see the workbench wiring. The SDK owns NO styles
|
|
13
|
+
* and exposes only NEUTRAL model facts here (never UX flags).
|
|
14
|
+
*
|
|
15
|
+
* Transport split (docs/UNOVERSE_MCP_TEMPLATE_PROTOCOL §5): the live PCM frames AND the
|
|
16
|
+
* synchronized audio-state control events both ride the /ws/gravity WS lane (they stay
|
|
17
|
+
* frame-synchronized); only structured component data is on MCP, untouched by voice.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { useState, useCallback, useEffect, useRef, useMemo } from "react";
|
|
21
|
+
import { useRealtimeWebSocket } from "./realtime/useRealtimeWebSocket";
|
|
22
|
+
import { useAudioCapture } from "./realtime/useAudioCapture";
|
|
23
|
+
import { useAudioPlayback } from "./realtime/useAudioPlayback";
|
|
24
|
+
import type { AudioStateEvent } from "./realtime/types";
|
|
25
|
+
import type { ComponentStore } from "./core";
|
|
26
|
+
|
|
27
|
+
export type VoiceConnectionStatus = "idle" | "connecting" | "connected" | "ended" | "error";
|
|
28
|
+
|
|
29
|
+
export interface VoiceSession {
|
|
30
|
+
userId: string;
|
|
31
|
+
conversationId: string;
|
|
32
|
+
chatId?: string;
|
|
33
|
+
workflowId: string;
|
|
34
|
+
targetTriggerNode: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UseVoiceServiceConfig {
|
|
38
|
+
/** Gateway REST base, e.g. http://localhost:4100 (the workflow `…/execute` host). */
|
|
39
|
+
apiUrl: string;
|
|
40
|
+
/** Audio WS base, e.g. ws://localhost:4100. Defaults to `apiUrl` with http→ws. */
|
|
41
|
+
wsUrl?: string;
|
|
42
|
+
/** Fresh access token provider (rides the WS lane + the REST Authorization header). */
|
|
43
|
+
getAccessToken?: () => Promise<string | null> | string | null;
|
|
44
|
+
session: VoiceSession;
|
|
45
|
+
/** The shared store. When given, the service acts as a PRODUCER (STATE_MODEL §4):
|
|
46
|
+
* every state change is merged into TEMPLATE STATE, so templates read voice exactly
|
|
47
|
+
* like any global key (`visibleWhen: { field: "callState", … }`) with zero host
|
|
48
|
+
* glue — the voice twin of `lifecycle`/`isThinking`/`isStreaming`. */
|
|
49
|
+
store?: ComponentStore;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The single call-phase VALUE a voice template branches its states on — the standard voice
|
|
53
|
+
* state vocabulary (`visibleWhen: { field: "callState", eq: … }`). Derived from the neutral
|
|
54
|
+
* facts below so every voice template keys off one field, exactly like `mode` for focus. */
|
|
55
|
+
export type VoiceCallState = "idle" | "active" | "agentSpeaking" | "userSpeaking";
|
|
56
|
+
|
|
57
|
+
/** NEUTRAL voice projections — flat, so the channel can spread them into the template scope. */
|
|
58
|
+
export interface VoiceServiceState {
|
|
59
|
+
connectionStatus: VoiceConnectionStatus;
|
|
60
|
+
/** The single phase the template's states key off (idle | active | agentSpeaking | userSpeaking). */
|
|
61
|
+
callState: VoiceCallState;
|
|
62
|
+
isIdle: boolean;
|
|
63
|
+
isCallActive: boolean;
|
|
64
|
+
isConnecting: boolean;
|
|
65
|
+
isConnected: boolean;
|
|
66
|
+
isAssistantSpeaking: boolean;
|
|
67
|
+
isUserSpeaking: boolean;
|
|
68
|
+
/** user speaking AND assistant not — mutual exclusion (the template can't express NOT). */
|
|
69
|
+
showUserSpeaking: boolean;
|
|
70
|
+
isMuted: boolean;
|
|
71
|
+
isLookingUp: boolean;
|
|
72
|
+
lookupLabel: string;
|
|
73
|
+
callDuration: number;
|
|
74
|
+
durationText: string;
|
|
75
|
+
statusText: string;
|
|
76
|
+
error: string | null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface VoiceService {
|
|
80
|
+
/** Spread into the template's data scope (channel overrides → rootData). */
|
|
81
|
+
state: VoiceServiceState;
|
|
82
|
+
startCall: () => Promise<void>;
|
|
83
|
+
endCall: () => Promise<void>;
|
|
84
|
+
toggleMute: () => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function formatDuration(totalSeconds: number): string {
|
|
88
|
+
const m = Math.floor(totalSeconds / 60)
|
|
89
|
+
.toString()
|
|
90
|
+
.padStart(2, "0");
|
|
91
|
+
const s = (totalSeconds % 60).toString().padStart(2, "0");
|
|
92
|
+
return `${m}:${s}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
|
|
96
|
+
const { apiUrl, session, getAccessToken } = config;
|
|
97
|
+
const wsUrl = config.wsUrl ?? apiUrl.replace(/^http/, "ws");
|
|
98
|
+
|
|
99
|
+
const [connectionStatus, setConnectionStatus] = useState<VoiceConnectionStatus>("idle");
|
|
100
|
+
const [isMuted, setIsMuted] = useState(false);
|
|
101
|
+
const [callDuration, setCallDuration] = useState(0);
|
|
102
|
+
const [error, setError] = useState<string | null>(null);
|
|
103
|
+
const [assistantHint, setAssistantHint] = useState(false);
|
|
104
|
+
const [userSpeaking, setUserSpeaking] = useState(false);
|
|
105
|
+
const [isLookingUp, setIsLookingUp] = useState(false);
|
|
106
|
+
const [lookupTool, setLookupTool] = useState<string | null>(null);
|
|
107
|
+
|
|
108
|
+
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
109
|
+
const sendAudioRef = useRef<(d: ArrayBuffer) => void>(() => {});
|
|
110
|
+
|
|
111
|
+
const playback = useAudioPlayback();
|
|
112
|
+
|
|
113
|
+
// Audio-state events arrive on the WS lane, in lockstep with the frames.
|
|
114
|
+
const handleAudioState = useCallback(
|
|
115
|
+
(event: AudioStateEvent) => {
|
|
116
|
+
const { state, metadata } = event;
|
|
117
|
+
if (state === "SPEECH_STARTED") {
|
|
118
|
+
setAssistantHint(true);
|
|
119
|
+
} else if (state === "SPEECH_ENDED") {
|
|
120
|
+
setAssistantHint(false);
|
|
121
|
+
playback.markAsLastChunk();
|
|
122
|
+
} else if (state === "USER_SPEECH_STARTED") {
|
|
123
|
+
setUserSpeaking(true);
|
|
124
|
+
playback.stopAll(); // barge-in: cut assistant playback the moment the user speaks
|
|
125
|
+
} else if (state === "USER_SPEECH_ENDED") {
|
|
126
|
+
setUserSpeaking(false);
|
|
127
|
+
} else if (state === "SESSION_READY") {
|
|
128
|
+
setConnectionStatus("connected");
|
|
129
|
+
} else if (state === "SESSION_ENDED") {
|
|
130
|
+
setConnectionStatus("ended");
|
|
131
|
+
setAssistantHint(false);
|
|
132
|
+
setUserSpeaking(false);
|
|
133
|
+
playback.markAsLastChunk();
|
|
134
|
+
} else if (state === "TOOL_USE") {
|
|
135
|
+
setIsLookingUp(true);
|
|
136
|
+
setLookupTool((metadata?.toolName as string | undefined) ?? null);
|
|
137
|
+
} else if (state === "TOOL_USE_COMPLETED") {
|
|
138
|
+
setIsLookingUp(false);
|
|
139
|
+
setLookupTool(null);
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
[playback],
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
const ws = useRealtimeWebSocket({
|
|
146
|
+
sessionId: session.conversationId,
|
|
147
|
+
userId: session.userId,
|
|
148
|
+
chatId: session.chatId,
|
|
149
|
+
wsUrl,
|
|
150
|
+
getAccessToken,
|
|
151
|
+
onAudioReceived: playback.playAudio,
|
|
152
|
+
onAudioState: handleAudioState,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// Capture → WS via a ref so the capture graph isn't rebuilt when sendAudio changes.
|
|
156
|
+
const handleAudioData = useCallback((d: ArrayBuffer) => sendAudioRef.current(d), []);
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
sendAudioRef.current = ws.sendAudio;
|
|
159
|
+
}, [ws.sendAudio]);
|
|
160
|
+
|
|
161
|
+
const capture = useAudioCapture({ onAudioData: handleAudioData });
|
|
162
|
+
|
|
163
|
+
const startCall = useCallback(async () => {
|
|
164
|
+
setConnectionStatus("connecting");
|
|
165
|
+
setError(null);
|
|
166
|
+
setCallDuration(0);
|
|
167
|
+
try {
|
|
168
|
+
// 1. Open the audio lane (sends INIT_SESSION on open) BEFORE the trigger,
|
|
169
|
+
// so the server can route audio the moment the Nova/Grok session is up.
|
|
170
|
+
await ws.connect();
|
|
171
|
+
|
|
172
|
+
// 2. START_CALL via REST — fire-and-forget; the workflow stays open for the call.
|
|
173
|
+
const token = getAccessToken ? await getAccessToken() : null;
|
|
174
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
175
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
176
|
+
const chatId = session.chatId ?? `voice_${session.conversationId}`;
|
|
177
|
+
fetch(`${apiUrl}/api/workflows/${session.workflowId}/execute`, {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers,
|
|
180
|
+
body: JSON.stringify({
|
|
181
|
+
input: {
|
|
182
|
+
message: "Start call",
|
|
183
|
+
chatId,
|
|
184
|
+
conversationId: session.conversationId,
|
|
185
|
+
userId: session.userId,
|
|
186
|
+
providerId: "gravity-voice",
|
|
187
|
+
isAudio: true,
|
|
188
|
+
metadata: {
|
|
189
|
+
targetTriggerNode: session.targetTriggerNode,
|
|
190
|
+
action: "START_CALL",
|
|
191
|
+
isAction: true,
|
|
192
|
+
workflowId: session.workflowId,
|
|
193
|
+
continuousStream: true,
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
conversationId: session.conversationId,
|
|
197
|
+
}),
|
|
198
|
+
}).catch((e) => console.error("[voice] START_CALL error:", e));
|
|
199
|
+
|
|
200
|
+
// 3. Start streaming the mic.
|
|
201
|
+
await capture.startCapture();
|
|
202
|
+
timerRef.current = setInterval(() => setCallDuration((p) => p + 1), 1000);
|
|
203
|
+
setConnectionStatus("connected");
|
|
204
|
+
} catch (err: unknown) {
|
|
205
|
+
setError(err instanceof Error ? err.message : "Failed to start call");
|
|
206
|
+
setConnectionStatus("error");
|
|
207
|
+
}
|
|
208
|
+
}, [ws, capture, apiUrl, session, getAccessToken]);
|
|
209
|
+
|
|
210
|
+
const endCall = useCallback(async () => {
|
|
211
|
+
if (timerRef.current) {
|
|
212
|
+
clearInterval(timerRef.current);
|
|
213
|
+
timerRef.current = null;
|
|
214
|
+
}
|
|
215
|
+
await capture.stopCapture();
|
|
216
|
+
playback.stopAll();
|
|
217
|
+
// END via WS control — NOT a new REST execute (that would start a new workflow).
|
|
218
|
+
ws.sendControl("AUDIO_CONTROL", { command: "stop", workflowId: session.workflowId });
|
|
219
|
+
ws.disconnect();
|
|
220
|
+
setConnectionStatus("ended");
|
|
221
|
+
setIsMuted(false);
|
|
222
|
+
}, [capture, playback, ws, session.workflowId]);
|
|
223
|
+
|
|
224
|
+
const toggleMute = useCallback(() => {
|
|
225
|
+
setIsMuted((prev) => {
|
|
226
|
+
const next = !prev;
|
|
227
|
+
capture.setMuted(next);
|
|
228
|
+
return next;
|
|
229
|
+
});
|
|
230
|
+
}, [capture]);
|
|
231
|
+
|
|
232
|
+
// Cleanup on unmount.
|
|
233
|
+
useEffect(() => {
|
|
234
|
+
return () => {
|
|
235
|
+
if (timerRef.current) clearInterval(timerRef.current);
|
|
236
|
+
ws.disconnect();
|
|
237
|
+
};
|
|
238
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
239
|
+
}, []);
|
|
240
|
+
|
|
241
|
+
const isAssistantSpeaking = assistantHint || playback.isPlaying;
|
|
242
|
+
const isUserSpeaking = userSpeaking || capture.isSpeaking;
|
|
243
|
+
const isCallActive = connectionStatus === "connecting" || connectionStatus === "connected";
|
|
244
|
+
const lookupLabel =
|
|
245
|
+
lookupTool === "findIntent" ? "Searching the knowledge base…" : `Looking up ${lookupTool ?? "information"}…`;
|
|
246
|
+
const durationText = formatDuration(callDuration);
|
|
247
|
+
const statusText =
|
|
248
|
+
connectionStatus === "idle"
|
|
249
|
+
? "Ready to call"
|
|
250
|
+
: connectionStatus === "connecting"
|
|
251
|
+
? "Connecting…"
|
|
252
|
+
: connectionStatus === "connected"
|
|
253
|
+
? durationText
|
|
254
|
+
: connectionStatus === "error"
|
|
255
|
+
? (error ?? "Connection error")
|
|
256
|
+
: "Call ended";
|
|
257
|
+
|
|
258
|
+
const state: VoiceServiceState = useMemo(
|
|
259
|
+
() => ({
|
|
260
|
+
connectionStatus,
|
|
261
|
+
// The one phase the template branches on — derived from the neutral facts. Order matters:
|
|
262
|
+
// agent speaking wins over user (barge-in), both win over plain "active".
|
|
263
|
+
callState: (!isCallActive
|
|
264
|
+
? "idle"
|
|
265
|
+
: isAssistantSpeaking
|
|
266
|
+
? "agentSpeaking"
|
|
267
|
+
: isUserSpeaking
|
|
268
|
+
? "userSpeaking"
|
|
269
|
+
: "active") as VoiceCallState,
|
|
270
|
+
isIdle: !isCallActive,
|
|
271
|
+
isCallActive,
|
|
272
|
+
isConnecting: connectionStatus === "connecting",
|
|
273
|
+
isConnected: connectionStatus === "connected",
|
|
274
|
+
isAssistantSpeaking,
|
|
275
|
+
isUserSpeaking,
|
|
276
|
+
showUserSpeaking: isUserSpeaking && !isAssistantSpeaking,
|
|
277
|
+
isMuted,
|
|
278
|
+
isLookingUp,
|
|
279
|
+
lookupLabel,
|
|
280
|
+
callDuration,
|
|
281
|
+
durationText,
|
|
282
|
+
statusText,
|
|
283
|
+
error: error ?? capture.error ?? null,
|
|
284
|
+
}),
|
|
285
|
+
[
|
|
286
|
+
connectionStatus,
|
|
287
|
+
isCallActive,
|
|
288
|
+
isAssistantSpeaking,
|
|
289
|
+
isUserSpeaking,
|
|
290
|
+
isMuted,
|
|
291
|
+
isLookingUp,
|
|
292
|
+
lookupLabel,
|
|
293
|
+
callDuration,
|
|
294
|
+
durationText,
|
|
295
|
+
statusText,
|
|
296
|
+
error,
|
|
297
|
+
capture.error,
|
|
298
|
+
],
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
// PRODUCER write (STATE_MODEL §4): the service feeds the state, it is not state.
|
|
302
|
+
// Call state lands in TEMPLATE STATE like any dev-named key, so every template —
|
|
303
|
+
// studio, canvas, native — reads voice from the store's global bag with no
|
|
304
|
+
// host-side props spread. Low-frequency (call phases + a 1Hz duration tick).
|
|
305
|
+
const store = config.store;
|
|
306
|
+
useEffect(() => {
|
|
307
|
+
store?.mergeTemplateState(state as unknown as Record<string, unknown>);
|
|
308
|
+
}, [store, state]);
|
|
309
|
+
|
|
310
|
+
return { state, startCall, endCall, toggleMute };
|
|
311
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `ui://` app entry (§6d) — what a HOST loads (the unoverse server's SAB host in an
|
|
3
|
+
* iframe, or Claude/ChatGPT). Boots the AppEngine on the LOCAL vendored SDK. The host
|
|
4
|
+
* provides config (server, bearer, session facts) via `window.__UNOVERSE_APP_CONFIG` or a
|
|
5
|
+
* postMessage `{ type: "unoverse:config", config }` (also the token-refresh channel).
|
|
6
|
+
*/
|
|
7
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
8
|
+
import { AppEngine, type AppConfig } from "./appEngine";
|
|
9
|
+
|
|
10
|
+
type HostConfig = Omit<AppConfig, "getAccessToken"> & { token?: string };
|
|
11
|
+
|
|
12
|
+
let root: Root | null = null;
|
|
13
|
+
let current: HostConfig | null = null;
|
|
14
|
+
|
|
15
|
+
function render() {
|
|
16
|
+
if (!current) return;
|
|
17
|
+
const el = document.getElementById("root")!;
|
|
18
|
+
if (!root) root = createRoot(el);
|
|
19
|
+
root.render(
|
|
20
|
+
<AppEngine
|
|
21
|
+
serverUrl={current.serverUrl}
|
|
22
|
+
apiUrl={current.apiUrl}
|
|
23
|
+
templateId={current.templateId}
|
|
24
|
+
userId={current.userId}
|
|
25
|
+
conversationId={current.conversationId}
|
|
26
|
+
getAccessToken={() => current?.token ?? null}
|
|
27
|
+
/>,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function apply(cfg: HostConfig) {
|
|
32
|
+
const first = !current;
|
|
33
|
+
current = { ...current, ...cfg };
|
|
34
|
+
if (first) render(); // mount once; token refresh just updates `current` (getAccessToken reads it)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
declare global {
|
|
38
|
+
interface Window {
|
|
39
|
+
__UNOVERSE_APP_CONFIG?: HostConfig;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (window.__UNOVERSE_APP_CONFIG) apply(window.__UNOVERSE_APP_CONFIG);
|
|
44
|
+
window.addEventListener("message", (e) => {
|
|
45
|
+
const msg = e?.data;
|
|
46
|
+
if (msg?.type === "unoverse:config" && msg.config) apply(msg.config);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// ── ChatGPT (OpenAI Apps) bootstrap ────────────────────────────────────────
|
|
50
|
+
// ChatGPT does NOT send our host's `unoverse:config` postMessage; it exposes a
|
|
51
|
+
// `window.openai` bridge. Without this the SDK never receives config → #root stays
|
|
52
|
+
// empty → blank widget (observed live). `toolOutput` = the tool's structuredContent
|
|
53
|
+
// (app id + conversationId); `toolResponseMetadata` = the tool result `_meta`
|
|
54
|
+
// (server-forwarded serverUrl + token + userId — widget-only, never seen by the model).
|
|
55
|
+
// DIAGNOSTIC-FIRST: log the real runtime shape so we wire against ground truth, not the
|
|
56
|
+
// docs' assumed names. `window.openai` may arrive after load, so also listen for its ready
|
|
57
|
+
// event. This branch is inert in our own hosts (no window.openai there).
|
|
58
|
+
function bootstrapFromOpenAI(): boolean {
|
|
59
|
+
const oai = (window as unknown as { openai?: Record<string, unknown> }).openai;
|
|
60
|
+
if (!oai) return false;
|
|
61
|
+
const out = (oai.toolOutput ?? {}) as Record<string, unknown>;
|
|
62
|
+
const meta = (oai.toolResponseMetadata ?? {}) as Record<string, unknown>;
|
|
63
|
+
// eslint-disable-next-line no-console
|
|
64
|
+
console.log("[unoverse:boot] window.openai present", {
|
|
65
|
+
keys: Object.keys(oai),
|
|
66
|
+
toolOutput: out,
|
|
67
|
+
toolResponseMetadataKeys: Object.keys(meta),
|
|
68
|
+
hasToken: Boolean(meta["unoverse/token"]),
|
|
69
|
+
});
|
|
70
|
+
// serverUrl is SERVER-PROVIDED (the tool result's `_meta.unoverse/serverUrl` = the origin
|
|
71
|
+
// that served this bundle). NEVER hardcode/guess it — a baked URL ties the bundle to one
|
|
72
|
+
// environment and hides a missing config. If it's absent, fail loudly and don't mount.
|
|
73
|
+
const serverUrl = meta["unoverse/serverUrl"] as string;
|
|
74
|
+
const templateId = (out.app as string) || (meta["unoverse/app"] as string);
|
|
75
|
+
const conversationId = (out.conversationId as string) || (meta["unoverse/conversationId"] as string);
|
|
76
|
+
const token = (meta["unoverse/token"] as string) || undefined;
|
|
77
|
+
const userId = (meta["unoverse/userId"] as string) || undefined;
|
|
78
|
+
if (!templateId || !serverUrl) {
|
|
79
|
+
// eslint-disable-next-line no-console
|
|
80
|
+
console.warn("[unoverse:boot] cannot mount — missing serverUrl or app id from window.openai", {
|
|
81
|
+
serverUrl,
|
|
82
|
+
templateId,
|
|
83
|
+
toolOutput: out,
|
|
84
|
+
toolResponseMetadataKeys: Object.keys(meta),
|
|
85
|
+
});
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
// eslint-disable-next-line no-console
|
|
89
|
+
console.log("[unoverse:boot] mounting", { serverUrl, templateId, conversationId, userId, hasToken: Boolean(token) });
|
|
90
|
+
apply({ serverUrl, templateId, conversationId, userId, token });
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (!bootstrapFromOpenAI()) {
|
|
94
|
+
// window.openai can be injected after the bundle loads — retry on its ready signal.
|
|
95
|
+
window.addEventListener("openai:set_globals", () => bootstrapFromOpenAI());
|
|
96
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local Studio — the standalone developer tool (docs/architecture/LOCAL_STUDIO.md).
|
|
3
|
+
*
|
|
4
|
+
* Runs entirely offline against the developer's own project folder. No Postgres, no
|
|
5
|
+
* Redis, no engine, no Docker: the catalog routes are served straight off disk by the
|
|
6
|
+
* local host plugin, using the SAME loaders the universe server uses.
|
|
7
|
+
*/
|
|
8
|
+
import { defineConfig } from "vite";
|
|
9
|
+
import react from "@vitejs/plugin-react";
|
|
10
|
+
import tailwindcss from "@tailwindcss/vite";
|
|
11
|
+
import { resolve } from "path";
|
|
12
|
+
import { existsSync } from "fs";
|
|
13
|
+
import { localStudioHost } from "./local/plugin.js";
|
|
14
|
+
|
|
15
|
+
export default defineConfig({
|
|
16
|
+
plugins: [react(), tailwindcss(), localStudioHost()],
|
|
17
|
+
server: {
|
|
18
|
+
// 4108. The ports below it are all taken by the dev stack:
|
|
19
|
+
// 4105 unoverse public 4106 the internal node-runtime listener (UNOVERSE_RUNTIME_PORT)
|
|
20
|
+
// 4107 apps/unoverse-runtime (`PORT || 4107`, main.ts)
|
|
21
|
+
// This was 4107, chosen to dodge 4106 and unaware that unoverse-runtime already held
|
|
22
|
+
// it. Running the full stack put two servers on one port, and Vite silently drifts to
|
|
23
|
+
// the next free one, so Studio moved and nothing said so.
|
|
24
|
+
port: 4108,
|
|
25
|
+
// Fail rather than drift. A tool that quietly answers on a different port than it
|
|
26
|
+
// printed is worse than one that refuses to start.
|
|
27
|
+
strictPort: true,
|
|
28
|
+
open: true,
|
|
29
|
+
fs: {
|
|
30
|
+
// The screens and the SDK are SOURCE outside this app's root, and the local host
|
|
31
|
+
// reads the developer's project. Allow the workspace root above us.
|
|
32
|
+
allow: [resolve(__dirname, "../..")],
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
resolve: {
|
|
36
|
+
dedupe: ["react", "react-dom"],
|
|
37
|
+
alias: {
|
|
38
|
+
// The Studio SCREENS, owned once and shared with the Canvas Studio.
|
|
39
|
+
"@unoverse/studio": resolve(__dirname, "screens/index.ts"),
|
|
40
|
+
// The host seam. Canvas aliases this to a universe; here it is the local disk.
|
|
41
|
+
"studio-host": resolve(__dirname, "src/host.ts"),
|
|
42
|
+
// The renderer, vendored source (same convention as Canvas and the old studio).
|
|
43
|
+
// THE VENDORED COPY WHEN THERE IS ONE. The monorepo path below exists only here;
|
|
44
|
+
// an installed Studio has `vendor/sdk`, written by prepack (scripts/vendor-sdk.mjs).
|
|
45
|
+
// Preferring the vendor directory is what makes the published package work at all,
|
|
46
|
+
// and falling back keeps the monorepo editing the real source with HMR.
|
|
47
|
+
"@unoverse/sdk": existsSync(resolve(__dirname, "vendor/sdk/lib/index.ts"))
|
|
48
|
+
? resolve(__dirname, "vendor/sdk/lib/index.ts")
|
|
49
|
+
: resolve(__dirname, "../unoverse/web/sdk/src/lib/index.ts"),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
ssr: {
|
|
53
|
+
// The local host loads the server's disk loaders through Vite's SSR runner, and two
|
|
54
|
+
// packages here are CommonJS, which the ESM runner cannot evaluate. Externalise them
|
|
55
|
+
// so Node requires them natively instead.
|
|
56
|
+
//
|
|
57
|
+
// plugin-base re-exported by inputs.ts
|
|
58
|
+
// marketplace the DESIGN SYSTEM. Studio is not optional about this: a developer's
|
|
59
|
+
// components are built ON TOP of marketplace atoms and components, so
|
|
60
|
+
// without it every shared reference resolves to nothing and Studio
|
|
61
|
+
// shows holes where the design system should be. It failed with
|
|
62
|
+
// "__dirname is not defined", the CommonJS giveaway.
|
|
63
|
+
external: ["@unoverse-platform/plugin-base", "@unoverse-platform/marketplace"],
|
|
64
|
+
},
|
|
65
|
+
optimizeDeps: {
|
|
66
|
+
// The SDK pulls in the MCP SDK, whose ajv-provider does `import Ajv from "ajv"`.
|
|
67
|
+
// ajv is CJS with no default ESM export, so pre-bundle for the interop shim.
|
|
68
|
+
include: ["ajv", "@modelcontextprotocol/sdk/client/index.js", "@modelcontextprotocol/sdk/client/streamableHttp.js"],
|
|
69
|
+
},
|
|
70
|
+
});
|