@unoverse-platform/studio 0.1.103 → 0.1.105
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-app/chat/manifest.yaml +2 -2
- package/local/sample-component/text-chat/components/suggestions.yaml +1 -1
- package/local/sample-component/text-chat/layouts/chat-welcome.yaml +1 -1
- package/local/sample-component/text-chat/text-chat.yaml +3 -0
- package/local/scaffold.d.mts +5 -2
- package/local/scaffold.mjs +68 -1
- 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/lint/design/index.mjs +9 -0
- 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
package/vendor/sdk/lib/voice.tsx
DELETED
|
@@ -1,422 +0,0 @@
|
|
|
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_APP_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
|
-
import { sessionUserId } from "./connection";
|
|
27
|
-
|
|
28
|
-
export type VoiceConnectionStatus = "idle" | "connecting" | "connected" | "ended" | "error";
|
|
29
|
-
|
|
30
|
-
export interface VoiceSession {
|
|
31
|
-
userId: string;
|
|
32
|
-
conversationId: string;
|
|
33
|
-
chatId?: string;
|
|
34
|
-
workflowId: string;
|
|
35
|
-
targetTriggerNode: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export interface UseVoiceServiceConfig {
|
|
39
|
-
/** Gateway REST base, e.g. http://localhost:4100 (the workflow `…/execute` host). */
|
|
40
|
-
apiUrl: string;
|
|
41
|
-
/** Audio WS base, e.g. ws://localhost:4100. Defaults to `apiUrl` with http→ws. */
|
|
42
|
-
wsUrl?: string;
|
|
43
|
-
/** Fresh access token provider (rides the WS lane + the REST Authorization header). */
|
|
44
|
-
getAccessToken?: () => Promise<string | null> | string | null;
|
|
45
|
-
session: VoiceSession;
|
|
46
|
-
/** The shared store. When given, the service acts as a PRODUCER (STATE_MODEL §4):
|
|
47
|
-
* every state change is merged into TEMPLATE STATE, so templates read voice exactly
|
|
48
|
-
* like any global key (`visibleWhen: { field: "callState", … }`) with zero host
|
|
49
|
-
* glue — the voice twin of `lifecycle`/`isThinking`/`isStreaming`. */
|
|
50
|
-
store?: ComponentStore;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** The single call-phase VALUE a voice template branches its states on — the standard voice
|
|
54
|
-
* state vocabulary (`visibleWhen: { field: "callState", eq: … }`). Derived from the neutral
|
|
55
|
-
* facts below so every voice template keys off one field, exactly like `mode` for focus. */
|
|
56
|
-
export type VoiceCallState = "idle" | "active" | "speaking" | "listening" | "thinking";
|
|
57
|
-
|
|
58
|
-
/** NEUTRAL voice projections — flat, so the channel can spread them into the template scope. */
|
|
59
|
-
export interface VoiceServiceState {
|
|
60
|
-
connectionStatus: VoiceConnectionStatus;
|
|
61
|
-
/** The single phase the template's states key off (idle | active | listening | thinking | speaking). */
|
|
62
|
-
callState: VoiceCallState;
|
|
63
|
-
isIdle: boolean;
|
|
64
|
-
isCallActive: boolean;
|
|
65
|
-
isConnecting: boolean;
|
|
66
|
-
isConnected: boolean;
|
|
67
|
-
isAssistantSpeaking: boolean;
|
|
68
|
-
isUserSpeaking: boolean;
|
|
69
|
-
/** user speaking AND assistant not — mutual exclusion (the template can't express NOT). */
|
|
70
|
-
showUserSpeaking: boolean;
|
|
71
|
-
isMuted: boolean;
|
|
72
|
-
isLookingUp: boolean;
|
|
73
|
-
lookupLabel: string;
|
|
74
|
-
callDuration: number;
|
|
75
|
-
durationText: string;
|
|
76
|
-
statusText: string;
|
|
77
|
-
error: string | null;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export interface StartCall {
|
|
81
|
-
message?: string;
|
|
82
|
-
metadata?: Record<string, unknown>;
|
|
83
|
-
workflowId?: string;
|
|
84
|
-
targetTriggerNode?: string;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export interface VoiceService {
|
|
88
|
-
/** Spread into the template's data scope (channel overrides → rootData). */
|
|
89
|
-
state: VoiceServiceState;
|
|
90
|
-
/** Start a call. `start` is what it starts with, as any request to the workflow: the text,
|
|
91
|
-
* and metadata a trigger reads (`metadata.task` names the task). Omitted → "Start call".
|
|
92
|
-
* `workflowId`/`targetTriggerNode` name the workflow to run, else the session's own: a page
|
|
93
|
-
* template's binding (UNOVERSE_HOST_PAGE.md rule 4). Started while a call is live, it MOVES
|
|
94
|
-
* the call: the same line, the new workflow, and the server closes the session before it
|
|
95
|
-
* (VOICE_STREAMING_GUIDE.md, "One Live Session Per Conversation"). */
|
|
96
|
-
startCall: (start?: StartCall) => Promise<void>;
|
|
97
|
-
/** Join a call already running: the audio lane and the microphone for a workflow something
|
|
98
|
-
* else started (a canvas Run, a button, `JOIN_CALL`), without starting it again. `workflowId`
|
|
99
|
-
* is what the hang-up names; omitted, the session's own. Joining a call already joined is
|
|
100
|
-
* nothing. */
|
|
101
|
-
joinCall: (workflowId?: string) => Promise<void>;
|
|
102
|
-
/** Wake the speaker. Call it inside the click that may start a call. */
|
|
103
|
-
unlock: () => void;
|
|
104
|
-
endCall: () => Promise<void>;
|
|
105
|
-
toggleMute: () => void;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function formatDuration(totalSeconds: number): string {
|
|
109
|
-
const m = Math.floor(totalSeconds / 60)
|
|
110
|
-
.toString()
|
|
111
|
-
.padStart(2, "0");
|
|
112
|
-
const s = (totalSeconds % 60).toString().padStart(2, "0");
|
|
113
|
-
return `${m}:${s}`;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function useVoiceService(config: UseVoiceServiceConfig): VoiceService {
|
|
117
|
-
const { apiUrl, getAccessToken } = config;
|
|
118
|
-
const wsUrl = config.wsUrl ?? apiUrl.replace(/^http/, "ws");
|
|
119
|
-
// Same identity as the data plane (connection.tsx): without a token the audio socket is a
|
|
120
|
-
// guest socket, and the server refuses INIT_SESSION unless the id is guest-prefixed.
|
|
121
|
-
const session = useMemo(
|
|
122
|
-
() => ({ ...config.session, userId: sessionUserId(config.session.userId, getAccessToken) }),
|
|
123
|
-
[config.session, getAccessToken],
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
const [connectionStatus, setConnectionStatus] = useState<VoiceConnectionStatus>("idle");
|
|
127
|
-
const [isMuted, setIsMuted] = useState(false);
|
|
128
|
-
const [callDuration, setCallDuration] = useState(0);
|
|
129
|
-
const [error, setError] = useState<string | null>(null);
|
|
130
|
-
const [assistantHint, setAssistantHint] = useState(false);
|
|
131
|
-
const [userSpeaking, setUserSpeaking] = useState(false);
|
|
132
|
-
const [isLookingUp, setIsLookingUp] = useState(false);
|
|
133
|
-
const [lookupTool, setLookupTool] = useState<string | null>(null);
|
|
134
|
-
|
|
135
|
-
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
136
|
-
/** The workflow the live call is on, which a hang-up names; null with no call. */
|
|
137
|
-
const callWorkflowRef = useRef<string | null>(null);
|
|
138
|
-
/** Lets go of the microphone and the audio lane when the server ends the call (set below,
|
|
139
|
-
* once they exist). */
|
|
140
|
-
const releaseRef = useRef<() => void>(() => {});
|
|
141
|
-
const sendAudioRef = useRef<(d: ArrayBuffer) => void>(() => {});
|
|
142
|
-
|
|
143
|
-
const playback = useAudioPlayback();
|
|
144
|
-
|
|
145
|
-
// Audio-state events arrive on the WS lane, in lockstep with the frames.
|
|
146
|
-
const handleAudioState = useCallback(
|
|
147
|
-
(event: AudioStateEvent) => {
|
|
148
|
-
const { state, metadata } = event;
|
|
149
|
-
if (state === "SPEECH_STARTED") {
|
|
150
|
-
setAssistantHint(true);
|
|
151
|
-
} else if (state === "SPEECH_ENDED") {
|
|
152
|
-
setAssistantHint(false);
|
|
153
|
-
playback.markAsLastChunk();
|
|
154
|
-
} else if (state === "USER_SPEECH_STARTED") {
|
|
155
|
-
setUserSpeaking(true);
|
|
156
|
-
playback.stopAll(); // barge-in: cut assistant playback the moment the user speaks
|
|
157
|
-
} else if (state === "USER_SPEECH_ENDED") {
|
|
158
|
-
setUserSpeaking(false);
|
|
159
|
-
} else if (state === "SESSION_READY") {
|
|
160
|
-
setConnectionStatus("connected");
|
|
161
|
-
} else if (state === "SESSION_ENDED") {
|
|
162
|
-
// THE SERVER ENDED THE CALL (the model hung up, a minute of silence, a timeout): the
|
|
163
|
-
// microphone stops and the lane closes, or the page streams to nobody.
|
|
164
|
-
releaseRef.current();
|
|
165
|
-
setConnectionStatus("ended");
|
|
166
|
-
setAssistantHint(false);
|
|
167
|
-
setUserSpeaking(false);
|
|
168
|
-
setIsLookingUp(false);
|
|
169
|
-
setLookupTool(null);
|
|
170
|
-
playback.markAsLastChunk();
|
|
171
|
-
} else if (state === "TOOL_USE") {
|
|
172
|
-
setIsLookingUp(true);
|
|
173
|
-
setLookupTool((metadata?.toolName as string | undefined) ?? null);
|
|
174
|
-
} else if (state === "TOOL_USE_COMPLETED") {
|
|
175
|
-
setIsLookingUp(false);
|
|
176
|
-
setLookupTool(null);
|
|
177
|
-
}
|
|
178
|
-
},
|
|
179
|
-
[playback],
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
const ws = useRealtimeWebSocket({
|
|
183
|
-
sessionId: session.conversationId,
|
|
184
|
-
userId: session.userId,
|
|
185
|
-
chatId: session.chatId,
|
|
186
|
-
wsUrl,
|
|
187
|
-
getAccessToken,
|
|
188
|
-
onAudioReceived: playback.playAudio,
|
|
189
|
-
onAudioState: handleAudioState,
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
// Capture → WS via a ref so the capture graph isn't rebuilt when sendAudio changes.
|
|
193
|
-
const handleAudioData = useCallback((d: ArrayBuffer) => sendAudioRef.current(d), []);
|
|
194
|
-
useEffect(() => {
|
|
195
|
-
sendAudioRef.current = ws.sendAudio;
|
|
196
|
-
}, [ws.sendAudio]);
|
|
197
|
-
|
|
198
|
-
const capture = useAudioCapture({ onAudioData: handleAudioData });
|
|
199
|
-
useEffect(() => {
|
|
200
|
-
releaseRef.current = () => {
|
|
201
|
-
if (timerRef.current) {
|
|
202
|
-
clearInterval(timerRef.current);
|
|
203
|
-
timerRef.current = null;
|
|
204
|
-
}
|
|
205
|
-
callWorkflowRef.current = null;
|
|
206
|
-
void capture.stopCapture();
|
|
207
|
-
ws.disconnect();
|
|
208
|
-
};
|
|
209
|
-
}, [capture, ws]);
|
|
210
|
-
|
|
211
|
-
const startCall = useCallback(async (start?: StartCall) => {
|
|
212
|
-
const workflowId = start?.workflowId || session.workflowId;
|
|
213
|
-
const targetTriggerNode = start?.targetTriggerNode || session.targetTriggerNode;
|
|
214
|
-
if (!workflowId) {
|
|
215
|
-
// NOTHING TO CALL: no workflow was named and the session has none. Never a default.
|
|
216
|
-
setError("No workflow to call");
|
|
217
|
-
setConnectionStatus("error");
|
|
218
|
-
return;
|
|
219
|
-
}
|
|
220
|
-
// A CALL ALREADY LIVE IS MOVED, not started again: the line and the microphone stay open,
|
|
221
|
-
// and only the workflow changes. The old voice is cut at once so two are never heard.
|
|
222
|
-
const moving = callWorkflowRef.current !== null;
|
|
223
|
-
if (moving) {
|
|
224
|
-
playback.stopAll();
|
|
225
|
-
setAssistantHint(false);
|
|
226
|
-
setIsLookingUp(false);
|
|
227
|
-
setLookupTool(null);
|
|
228
|
-
} else {
|
|
229
|
-
setConnectionStatus("connecting");
|
|
230
|
-
setError(null);
|
|
231
|
-
setCallDuration(0);
|
|
232
|
-
}
|
|
233
|
-
try {
|
|
234
|
-
// 1. Open the audio lane (sends INIT_SESSION on open) BEFORE the trigger,
|
|
235
|
-
// so the server can route audio the moment the Nova/Grok session is up.
|
|
236
|
-
if (!moving) await ws.connect();
|
|
237
|
-
callWorkflowRef.current = workflowId;
|
|
238
|
-
|
|
239
|
-
// 2. START_CALL via REST — fire-and-forget; the workflow stays open for the call.
|
|
240
|
-
const token = getAccessToken ? await getAccessToken() : null;
|
|
241
|
-
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
242
|
-
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
243
|
-
const chatId = session.chatId ?? `voice_${session.conversationId}`;
|
|
244
|
-
fetch(`${apiUrl}/api/workflows/${workflowId}/execute`, {
|
|
245
|
-
method: "POST",
|
|
246
|
-
headers,
|
|
247
|
-
body: JSON.stringify({
|
|
248
|
-
input: {
|
|
249
|
-
message: start?.message ?? "Start call",
|
|
250
|
-
chatId,
|
|
251
|
-
conversationId: session.conversationId,
|
|
252
|
-
userId: session.userId,
|
|
253
|
-
providerId: "gravity-voice",
|
|
254
|
-
isAudio: true,
|
|
255
|
-
metadata: {
|
|
256
|
-
...(start?.metadata ?? {}),
|
|
257
|
-
targetTriggerNode,
|
|
258
|
-
action: "START_CALL",
|
|
259
|
-
isAction: true,
|
|
260
|
-
workflowId,
|
|
261
|
-
continuousStream: true,
|
|
262
|
-
},
|
|
263
|
-
},
|
|
264
|
-
conversationId: session.conversationId,
|
|
265
|
-
}),
|
|
266
|
-
}).catch((e) => console.error("[voice] START_CALL error:", e));
|
|
267
|
-
if (moving) return;
|
|
268
|
-
|
|
269
|
-
// 3. Start streaming the mic.
|
|
270
|
-
await capture.startCapture();
|
|
271
|
-
timerRef.current = setInterval(() => setCallDuration((p) => p + 1), 1000);
|
|
272
|
-
setConnectionStatus("connected");
|
|
273
|
-
} catch (err: unknown) {
|
|
274
|
-
callWorkflowRef.current = null;
|
|
275
|
-
setError(err instanceof Error ? err.message : "Failed to start call");
|
|
276
|
-
setConnectionStatus("error");
|
|
277
|
-
setIsLookingUp(false);
|
|
278
|
-
setLookupTool(null);
|
|
279
|
-
}
|
|
280
|
-
}, [ws, capture, playback, apiUrl, session, getAccessToken]);
|
|
281
|
-
|
|
282
|
-
// JOIN, NOT START. The workflow is already running and its voice node has opened the vendor
|
|
283
|
-
// session on this conversation; all that is missing is a browser on the audio lane. So open
|
|
284
|
-
// the lane and the microphone, and send no execute: a second one would start a second run.
|
|
285
|
-
const joinCall = useCallback(async (workflowId?: string) => {
|
|
286
|
-
if (callWorkflowRef.current !== null) return;
|
|
287
|
-
callWorkflowRef.current = workflowId ?? session.workflowId ?? "";
|
|
288
|
-
setConnectionStatus("connecting");
|
|
289
|
-
setError(null);
|
|
290
|
-
setCallDuration(0);
|
|
291
|
-
try {
|
|
292
|
-
await ws.connect();
|
|
293
|
-
await capture.startCapture();
|
|
294
|
-
timerRef.current = setInterval(() => setCallDuration((p) => p + 1), 1000);
|
|
295
|
-
setConnectionStatus("connected");
|
|
296
|
-
} catch (err: unknown) {
|
|
297
|
-
callWorkflowRef.current = null;
|
|
298
|
-
setError(err instanceof Error ? err.message : "Failed to join call");
|
|
299
|
-
setConnectionStatus("error");
|
|
300
|
-
}
|
|
301
|
-
}, [ws, capture, session.workflowId]);
|
|
302
|
-
|
|
303
|
-
const endCall = useCallback(async () => {
|
|
304
|
-
if (timerRef.current) {
|
|
305
|
-
clearInterval(timerRef.current);
|
|
306
|
-
timerRef.current = null;
|
|
307
|
-
}
|
|
308
|
-
await capture.stopCapture();
|
|
309
|
-
playback.stopAll();
|
|
310
|
-
// END via WS control — NOT a new REST execute (that would start a new workflow).
|
|
311
|
-
ws.sendControl("AUDIO_CONTROL", { command: "stop", workflowId: callWorkflowRef.current ?? session.workflowId });
|
|
312
|
-
callWorkflowRef.current = null;
|
|
313
|
-
ws.disconnect();
|
|
314
|
-
setConnectionStatus("ended");
|
|
315
|
-
setIsMuted(false);
|
|
316
|
-
setIsLookingUp(false);
|
|
317
|
-
setLookupTool(null);
|
|
318
|
-
}, [capture, playback, ws, session.workflowId]);
|
|
319
|
-
|
|
320
|
-
const toggleMute = useCallback(() => {
|
|
321
|
-
setIsMuted((prev) => {
|
|
322
|
-
const next = !prev;
|
|
323
|
-
capture.setMuted(next);
|
|
324
|
-
return next;
|
|
325
|
-
});
|
|
326
|
-
}, [capture]);
|
|
327
|
-
|
|
328
|
-
// Cleanup on unmount.
|
|
329
|
-
useEffect(() => {
|
|
330
|
-
return () => {
|
|
331
|
-
if (timerRef.current) clearInterval(timerRef.current);
|
|
332
|
-
ws.disconnect();
|
|
333
|
-
};
|
|
334
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
335
|
-
}, []);
|
|
336
|
-
|
|
337
|
-
const isAssistantSpeaking = assistantHint || playback.isPlaying;
|
|
338
|
-
const isUserSpeaking = userSpeaking || capture.isSpeaking;
|
|
339
|
-
const isCallActive = connectionStatus === "connecting" || connectionStatus === "connected";
|
|
340
|
-
const lookupLabel =
|
|
341
|
-
lookupTool === "findIntent" ? "Searching the knowledge base…" : `Looking up ${lookupTool ?? "information"}…`;
|
|
342
|
-
const durationText = formatDuration(callDuration);
|
|
343
|
-
const statusText =
|
|
344
|
-
connectionStatus === "idle"
|
|
345
|
-
? "Ready to call"
|
|
346
|
-
: connectionStatus === "connecting"
|
|
347
|
-
? "Connecting…"
|
|
348
|
-
: connectionStatus === "connected"
|
|
349
|
-
? durationText
|
|
350
|
-
: connectionStatus === "error"
|
|
351
|
-
? (error ?? "Connection error")
|
|
352
|
-
: "Call ended";
|
|
353
|
-
|
|
354
|
-
const state: VoiceServiceState = useMemo(
|
|
355
|
-
() => ({
|
|
356
|
-
connectionStatus,
|
|
357
|
-
// The one phase the template branches on — derived from the neutral facts. Order matters:
|
|
358
|
-
// a lookup wins, then agent speaking over user (barge-in), and all three win over plain
|
|
359
|
-
// "active".
|
|
360
|
-
//
|
|
361
|
-
// `thinking` sits ABOVE speaking because a lookup has a true start and a true end (the
|
|
362
|
-
// hand-off and its answer), while speaking may have no end at all: GPT-Live sends no
|
|
363
|
-
// end-of-speech event and streams silence, so speaking can read true for the whole call
|
|
364
|
-
// and would hide the lookup. Thinking clears on the answer, a hang-up or a dropped call.
|
|
365
|
-
//
|
|
366
|
-
// It also sits ABOVE "active" because that is the bug it fixed. A lookup takes 8 to 9 seconds,
|
|
367
|
-
// and for all of it callState read "active" — which every voice template draws as a calm
|
|
368
|
-
// halo meaning "the floor is yours". The orb is the only progress indicator a call has,
|
|
369
|
-
// and it was saying the opposite of what was happening. `isLookingUp` was already right
|
|
370
|
-
// here in this hook, feeding the small lookup caption; it simply never reached the one
|
|
371
|
-
// field the templates branch on.
|
|
372
|
-
callState: (!isCallActive
|
|
373
|
-
? "idle"
|
|
374
|
-
: isLookingUp
|
|
375
|
-
? "thinking"
|
|
376
|
-
: isAssistantSpeaking
|
|
377
|
-
? "speaking"
|
|
378
|
-
: isUserSpeaking
|
|
379
|
-
? "listening"
|
|
380
|
-
: "active") as VoiceCallState,
|
|
381
|
-
isIdle: !isCallActive,
|
|
382
|
-
isCallActive,
|
|
383
|
-
isConnecting: connectionStatus === "connecting",
|
|
384
|
-
isConnected: connectionStatus === "connected",
|
|
385
|
-
isAssistantSpeaking,
|
|
386
|
-
isUserSpeaking,
|
|
387
|
-
showUserSpeaking: isUserSpeaking && !isAssistantSpeaking,
|
|
388
|
-
isMuted,
|
|
389
|
-
isLookingUp,
|
|
390
|
-
lookupLabel,
|
|
391
|
-
callDuration,
|
|
392
|
-
durationText,
|
|
393
|
-
statusText,
|
|
394
|
-
error: error ?? capture.error ?? null,
|
|
395
|
-
}),
|
|
396
|
-
[
|
|
397
|
-
connectionStatus,
|
|
398
|
-
isCallActive,
|
|
399
|
-
isAssistantSpeaking,
|
|
400
|
-
isUserSpeaking,
|
|
401
|
-
isMuted,
|
|
402
|
-
isLookingUp,
|
|
403
|
-
lookupLabel,
|
|
404
|
-
callDuration,
|
|
405
|
-
durationText,
|
|
406
|
-
statusText,
|
|
407
|
-
error,
|
|
408
|
-
capture.error,
|
|
409
|
-
],
|
|
410
|
-
);
|
|
411
|
-
|
|
412
|
-
// PRODUCER write (STATE_MODEL §4): the service feeds the state, it is not state.
|
|
413
|
-
// Call state lands in TEMPLATE STATE like any dev-named key, so every template —
|
|
414
|
-
// studio, canvas, native — reads voice from the store's global bag with no
|
|
415
|
-
// host-side props spread. Low-frequency (call phases + a 1Hz duration tick).
|
|
416
|
-
const store = config.store;
|
|
417
|
-
useEffect(() => {
|
|
418
|
-
store?.mergeAppState(state as unknown as Record<string, unknown>);
|
|
419
|
-
}, [store, state]);
|
|
420
|
-
|
|
421
|
-
return { state, startCall, joinCall, endCall, toggleMute, unlock: playback.unlock };
|
|
422
|
-
}
|
package/vendor/sdk/main.tsx
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
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
|
-
import { connectMcpAppHost } from "./mcpAppBridge";
|
|
10
|
-
import { ResponseView } from "./responseView";
|
|
11
|
-
|
|
12
|
-
type HostConfig = Omit<AppConfig, "getAccessToken"> & { token?: string };
|
|
13
|
-
|
|
14
|
-
let root: Root | null = null;
|
|
15
|
-
let current: HostConfig | null = null;
|
|
16
|
-
|
|
17
|
-
// Stable identity, module-level: the connection hook keys its lifecycle on the
|
|
18
|
-
// `getAccessToken` reference (connection.tsx effect deps), and the host re-posts
|
|
19
|
-
// config on every iframe load — an inline closure here would recycle the live
|
|
20
|
-
// /stream session on each repost.
|
|
21
|
-
const readToken = () => current?.token ?? null;
|
|
22
|
-
|
|
23
|
-
function render() {
|
|
24
|
-
if (!current) return;
|
|
25
|
-
const el = document.getElementById("root")!;
|
|
26
|
-
if (!root) root = createRoot(el);
|
|
27
|
-
root.render(
|
|
28
|
-
<AppEngine
|
|
29
|
-
serverUrl={current.serverUrl}
|
|
30
|
-
apiUrl={current.apiUrl}
|
|
31
|
-
appId={current.appId}
|
|
32
|
-
userId={current.userId}
|
|
33
|
-
conversationId={current.conversationId}
|
|
34
|
-
// ANALYTICS (UNOVERSE_ANALYTICS.md). Host-supplied: the same bundle boots inside a
|
|
35
|
-
// customer's page, our own client, and an MCP app host, and it cannot tell which
|
|
36
|
-
// from in here. A host that says nothing gets `mcp_app_host`, the one surface with
|
|
37
|
-
// no parent page to report to, so an unconfigured boot reports as the surface where
|
|
38
|
-
// events go nowhere rather than mislabelling itself as a customer embed.
|
|
39
|
-
surface={current.surface ?? "mcp_app_host"}
|
|
40
|
-
analyticsDebug={current.analyticsDebug}
|
|
41
|
-
// Only claim auth when the host actually sent a token. A fabricated accessor on a
|
|
42
|
-
// tokenless boot made the SDK skip its guest-id substitution (connection.tsx), so
|
|
43
|
-
// the session registered a non-guest id and the public-entry gate refused it.
|
|
44
|
-
// No token ⇒ no accessor ⇒ the SDK mints/persists `guest-<uuid>` per the contract
|
|
45
|
-
// (UNOVERSE_MCP_APP_PROTOCOL §5, docs/auth-security/IDENTITY.md Run Authorization).
|
|
46
|
-
getAccessToken={current.token ? readToken : undefined}
|
|
47
|
-
/>,
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function apply(cfg: HostConfig) {
|
|
52
|
-
current = { ...current, ...cfg };
|
|
53
|
-
// Re-render on every apply so a token appearing/disappearing takes effect; a same-token
|
|
54
|
-
// refresh changes nothing (readToken is stable, values equal), so no connection churn.
|
|
55
|
-
render();
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
declare global {
|
|
59
|
-
interface Window {
|
|
60
|
-
__UNOVERSE_APP_CONFIG?: HostConfig;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
if (window.__UNOVERSE_APP_CONFIG) apply(window.__UNOVERSE_APP_CONFIG);
|
|
65
|
-
window.addEventListener("message", (e) => {
|
|
66
|
-
const msg = e?.data;
|
|
67
|
-
if (msg?.type === "unoverse:config" && msg.config) apply(msg.config);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// ── MCP Apps host bootstrap (SEP-1865, the STANDARD door) ──────────────────
|
|
71
|
-
// An MCP Apps host (ChatGPT, Claude Desktop, Goose, …) speaks JSON-RPC over postMessage:
|
|
72
|
-
// we send `ui/initialize`, the host pushes `ui/notifications/tool-result`, and the boot
|
|
73
|
-
// facts ride the result's `_meta` (`unoverse/serverUrl` + app + token — widget-only,
|
|
74
|
-
// never seen by the model) with `structuredContent` as the belt-and-braces copy.
|
|
75
|
-
// The legacy `window.openai` config path is REMOVED (2026-08-13): ChatGPT delivers
|
|
76
|
-
// `toolResponseMetadata` empty now, non-OpenAI hosts never had the global, and two boot
|
|
77
|
-
// paths for one host class meant every failure had two explanations. One standard door;
|
|
78
|
-
// our own hosts keep `unoverse:config` / `__UNOVERSE_APP_CONFIG` above.
|
|
79
|
-
connectMcpAppHost((r) => {
|
|
80
|
-
const meta = (r._meta ?? {}) as Record<string, unknown>;
|
|
81
|
-
const out = (r.structuredContent ?? {}) as Record<string, unknown>;
|
|
82
|
-
// serverUrl is SERVER-PROVIDED (`_meta` first, structuredContent as fallback). NEVER
|
|
83
|
-
// hardcode/guess it — a baked URL ties the bundle to one environment and hides a
|
|
84
|
-
// missing config. If it's absent, fail loudly and don't mount.
|
|
85
|
-
const serverUrl = (meta["unoverse/serverUrl"] as string) || (out.serverUrl as string);
|
|
86
|
-
const conversationId = (out.conversationId as string) || (meta["unoverse/conversationId"] as string);
|
|
87
|
-
const token = (meta["unoverse/token"] as string) || undefined;
|
|
88
|
-
const userId = (meta["unoverse/userId"] as string) || undefined;
|
|
89
|
-
|
|
90
|
-
// THE NAMING RULE (UNOVERSE_MCP_APP_PROTOCOL § THE NAMING RULE): the RESULT names
|
|
91
|
-
// its interface. `interface.kind === "components"` → render exactly those (the
|
|
92
|
-
// response view — no app mount, no session, no home). `interface.kind === "app"`
|
|
93
|
-
// or a legacy `app`/`unoverse/app` boot fact (the app tools' results, internal
|
|
94
|
-
// hosts) → the named app mounts. Nothing mounts by default: a result naming
|
|
95
|
-
// nothing renders nothing, loudly.
|
|
96
|
-
const iface = out.interface as { kind?: string; components?: Record<string, unknown>[]; app?: string } | undefined;
|
|
97
|
-
if (iface?.kind === "components" && Array.isArray(iface.components)) {
|
|
98
|
-
if (!serverUrl) {
|
|
99
|
-
// eslint-disable-next-line no-console
|
|
100
|
-
console.warn("[unoverse:boot] response result carried no serverUrl — cannot resolve definitions");
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
// eslint-disable-next-line no-console
|
|
104
|
-
console.log("[unoverse:boot] rendering RESPONSE from MCP host", { serverUrl, components: iface.components.length });
|
|
105
|
-
const el = document.getElementById("root")!;
|
|
106
|
-
if (!root) root = createRoot(el);
|
|
107
|
-
root.render(<ResponseView serverUrl={serverUrl} events={iface.components} />);
|
|
108
|
-
return;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const appId = (iface?.kind === "app" ? iface.app : undefined) || (out.app as string) || (meta["unoverse/app"] as string);
|
|
112
|
-
if (!appId || !serverUrl) {
|
|
113
|
-
// eslint-disable-next-line no-console
|
|
114
|
-
console.warn("[unoverse:boot] cannot mount — result named no interface (no components, no app)", {
|
|
115
|
-
serverUrl,
|
|
116
|
-
appId,
|
|
117
|
-
structuredContent: out,
|
|
118
|
-
metaKeys: Object.keys(meta),
|
|
119
|
-
});
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
// eslint-disable-next-line no-console
|
|
123
|
-
console.log("[unoverse:boot] mounting NAMED app from MCP host", { serverUrl, appId, conversationId, userId, hasToken: Boolean(token) });
|
|
124
|
-
apply({ serverUrl, appId, conversationId, userId, token });
|
|
125
|
-
});
|