@ateam-ai-mobile/ui 0.1.5 → 0.1.8
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/dist/index.d.mts +385 -21
- package/dist/index.d.ts +385 -21
- package/dist/index.js +1270 -175
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1264 -176
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/WorkspaceBanner.tsx +71 -0
- package/src/auth/HandoffScreen.tsx +94 -0
- package/src/auth/JoinScreen.tsx +145 -0
- package/src/auth/LoginScreen.tsx +181 -0
- package/src/auth/OpenJoinScreen.tsx +131 -0
- package/src/auth/handoffExchange.ts +49 -0
- package/src/auth/inviteJoin.ts +44 -0
- package/src/auth/login.ts +57 -0
- package/src/auth/openJoin.ts +56 -0
- package/src/chat/MessageBubble.tsx +3 -0
- package/src/index.ts +38 -1
- package/src/plugin/Plugin.tsx +15 -9
- package/src/providers.ts +44 -0
- package/src/voice/VoiceCallSheet.tsx +4 -32
- package/src/voice/VoiceMicIndicator.tsx +99 -0
- package/src/voice/VoiceSessionHost.tsx +77 -0
- package/src/voice/useVoiceCall.ts +40 -231
- package/src/voice/voiceSession.ts +807 -0
|
@@ -0,0 +1,807 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* voiceSession — the ONE live voice session, headless and app-root-owned.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. The session used to live inside `useVoiceCall`, whose WebView
|
|
5
|
+
* was rendered by VoiceCallSheet. Unmount the sheet and the WebView unmounted
|
|
6
|
+
* with it, so the session died — not by design, just by where the WebView lived.
|
|
7
|
+
* That made the built-in screen the ONLY way to hold a live loop, exactly the
|
|
8
|
+
* state the camera was in before sensing and preview were split.
|
|
9
|
+
*
|
|
10
|
+
* The fix is the same one VisionStreamHost applied to the camera: move the thing
|
|
11
|
+
* that must stay alive to the app root, and make every surface a READER of it.
|
|
12
|
+
* This module is that thing — a singleton that owns session state and drives the
|
|
13
|
+
* WebView; `VoiceSessionHost` mounts the (offscreen) WebView bound to it once, at
|
|
14
|
+
* the root; `useVoiceCall` / `props.voice` are views onto it.
|
|
15
|
+
*
|
|
16
|
+
* It is the provider handed to `setVoiceProvider` (plugin-runtime), so a plugin's
|
|
17
|
+
* gated `props.voice` and the built-in sheet drive the SAME session — one phone,
|
|
18
|
+
* one mic, one Realtime loop, one bill (spec invariant 2).
|
|
19
|
+
*
|
|
20
|
+
* TRUST. The device-tool channel (origin-checked postMessage) is preserved
|
|
21
|
+
* verbatim from the hook: the model's tool calls arrive on THIS phone over the
|
|
22
|
+
* WebView's own WebRTC, and only a message from the voice app's own origin may
|
|
23
|
+
* drive the device. `prompt()` is an AGENT turn, never forged user speech, and it
|
|
24
|
+
* cannot reach the WebView beyond that one injected call.
|
|
25
|
+
*/
|
|
26
|
+
import { getVoiceApiUrl, getToken, getTenant, getUser, refreshToken } from '@ateam-ai-mobile/core';
|
|
27
|
+
import {
|
|
28
|
+
callDeviceTool,
|
|
29
|
+
ensureDeviceToolsLoaded,
|
|
30
|
+
listDeviceTools,
|
|
31
|
+
hasDeviceTool,
|
|
32
|
+
PLATFORM_VOICE_CALLER,
|
|
33
|
+
type VoiceSessionProvider,
|
|
34
|
+
type VoiceCaller,
|
|
35
|
+
type VoiceSnapshot,
|
|
36
|
+
type VoiceState,
|
|
37
|
+
type VoiceTranscriptEntry,
|
|
38
|
+
type VoiceStartOptions,
|
|
39
|
+
type VoicePersona,
|
|
40
|
+
type VoiceEvent,
|
|
41
|
+
type VoiceSubscription,
|
|
42
|
+
} from '@ateam-ai-mobile/plugin-runtime';
|
|
43
|
+
|
|
44
|
+
// Re-export the transcript/state shapes under their historical names so existing
|
|
45
|
+
// importers (useVoiceCall, VoiceCallSheet) keep compiling. The canonical
|
|
46
|
+
// definitions live in plugin-runtime (the layer both ui and plugins depend on).
|
|
47
|
+
export type CallState = VoiceState;
|
|
48
|
+
export type TranscriptEntry = VoiceTranscriptEntry;
|
|
49
|
+
|
|
50
|
+
/** How long to wait for the WebView to reach 'connected' before start() rejects.
|
|
51
|
+
* A hung promise (e.g. no VoiceSessionHost mounted) is worse than a clear error. */
|
|
52
|
+
const CONNECT_TIMEOUT_MS = 30_000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* The voice WebView bakes the JWT into its URL once at mount and never refreshes
|
|
56
|
+
* it (unlike the SDK REST client, which self-heals on 401). Refresh a stale/near-
|
|
57
|
+
* expiry token BEFORE the WebView mounts so it gets a fresh one. Cheap unless
|
|
58
|
+
* actually near expiry (decoded locally, no network unless needed).
|
|
59
|
+
*/
|
|
60
|
+
const TOKEN_REFRESH_SKEW_MS = 90_000;
|
|
61
|
+
function tokenNeedsRefresh(token: string | null): boolean {
|
|
62
|
+
if (!token) return true;
|
|
63
|
+
try {
|
|
64
|
+
const payloadB64 = token.split('.')[1];
|
|
65
|
+
const padded = payloadB64.replace(/-/g, '+').replace(/_/g, '/');
|
|
66
|
+
const json = typeof atob === 'function'
|
|
67
|
+
? atob(padded)
|
|
68
|
+
: Buffer.from(padded, 'base64').toString('utf8');
|
|
69
|
+
const exp = JSON.parse(json)?.exp;
|
|
70
|
+
if (typeof exp !== 'number') return false;
|
|
71
|
+
return Date.now() >= exp * 1000 - TOKEN_REFRESH_SKEW_MS;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Installed BEFORE the voice app loads — captures every audio track that could
|
|
78
|
+
// reach the peer connection so setMuted can flip enabled=false on all of them,
|
|
79
|
+
// however the voice app obtained the track. (Verbatim from useVoiceCall.)
|
|
80
|
+
export const INJECT_TRACK_HOOK = `
|
|
81
|
+
(function() {
|
|
82
|
+
if (window._adaTrackHookInstalled) return;
|
|
83
|
+
window._adaTrackHookInstalled = true;
|
|
84
|
+
window._allAudioTracks = [];
|
|
85
|
+
window._adaPeerConnections = [];
|
|
86
|
+
|
|
87
|
+
function track(t) {
|
|
88
|
+
if (t && t.kind === 'audio' && window._allAudioTracks.indexOf(t) === -1) {
|
|
89
|
+
window._allAudioTracks.push(t);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
|
95
|
+
const origGUM = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
|
96
|
+
navigator.mediaDevices.getUserMedia = async function(constraints) {
|
|
97
|
+
const stream = await origGUM(constraints);
|
|
98
|
+
if (constraints && constraints.audio) {
|
|
99
|
+
try { stream.getAudioTracks().forEach(track); } catch (e) {}
|
|
100
|
+
}
|
|
101
|
+
return stream;
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
} catch (e) {}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
if (window.RTCPeerConnection) {
|
|
108
|
+
const origPC = window.RTCPeerConnection;
|
|
109
|
+
const origAddTrack = origPC.prototype.addTrack;
|
|
110
|
+
const origAddStream = origPC.prototype.addStream;
|
|
111
|
+
const origAddTransceiver = origPC.prototype.addTransceiver;
|
|
112
|
+
|
|
113
|
+
origPC.prototype.addTrack = function(t) {
|
|
114
|
+
if (t && t.kind === 'audio') track(t);
|
|
115
|
+
return origAddTrack.apply(this, arguments);
|
|
116
|
+
};
|
|
117
|
+
if (origAddStream) {
|
|
118
|
+
origPC.prototype.addStream = function(stream) {
|
|
119
|
+
try { stream.getAudioTracks().forEach(track); } catch (e) {}
|
|
120
|
+
return origAddStream.apply(this, arguments);
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
if (origAddTransceiver) {
|
|
124
|
+
origPC.prototype.addTransceiver = function(arg) {
|
|
125
|
+
if (arg && arg.kind === undefined && arg.track && arg.track.kind === 'audio') {
|
|
126
|
+
track(arg.track);
|
|
127
|
+
}
|
|
128
|
+
const tx = origAddTransceiver.apply(this, arguments);
|
|
129
|
+
try {
|
|
130
|
+
if (tx && tx.sender && tx.sender.track && tx.sender.track.kind === 'audio') track(tx.sender.track);
|
|
131
|
+
} catch (e) {}
|
|
132
|
+
return tx;
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} catch (e) {}
|
|
137
|
+
|
|
138
|
+
window._adaSetMuted = function(muted) {
|
|
139
|
+
let n = 0;
|
|
140
|
+
if (Array.isArray(window._allAudioTracks)) {
|
|
141
|
+
window._allAudioTracks.forEach(function(t) {
|
|
142
|
+
try { t.enabled = !muted; n++; } catch (e) {}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return n;
|
|
146
|
+
};
|
|
147
|
+
})(); true;
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
const buildMuteScript = (muted: boolean) => `
|
|
151
|
+
(function(muted) {
|
|
152
|
+
if (typeof window.setMuted === 'function') { window.setMuted(muted); }
|
|
153
|
+
let n = 0;
|
|
154
|
+
if (typeof window._adaSetMuted === 'function') {
|
|
155
|
+
n = window._adaSetMuted(muted);
|
|
156
|
+
}
|
|
157
|
+
if (n === 0 && Array.isArray(window._allAudioTracks)) {
|
|
158
|
+
window._allAudioTracks.forEach(function(t) {
|
|
159
|
+
try { t.enabled = !muted; n++; } catch (e) {}
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
console.log('[ada-mute]', muted ? 'MUTED' : 'UNMUTED', 'tracks=', n);
|
|
163
|
+
})(${muted}); true;
|
|
164
|
+
`;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The agent-initiated turn — the guide speaking first. Calls the voice app's
|
|
168
|
+
* `__adasAgentTurn(input)`, which must issue a Realtime `response.create` into
|
|
169
|
+
* the ALREADY-OPEN session (Core owns that function; see the spec §4.2).
|
|
170
|
+
*
|
|
171
|
+
* REFUSE, DON'T NO-OP: an undefined function called through injectJavaScript
|
|
172
|
+
* fails SILENTLY in the WebView, and the author sees a guide that simply never
|
|
173
|
+
* speaks first — indistinguishable from a model that chose silence. So if the
|
|
174
|
+
* hook is absent (an older voice page), we post a notice back that surfaces as
|
|
175
|
+
* `lastError`, without ending the session.
|
|
176
|
+
*/
|
|
177
|
+
const buildAgentTurnScript = (input: string) => `
|
|
178
|
+
(function(input) {
|
|
179
|
+
if (typeof window.__adasAgentTurn === 'function') {
|
|
180
|
+
try { window.__adasAgentTurn(input); }
|
|
181
|
+
catch (e) {
|
|
182
|
+
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify({
|
|
183
|
+
type: 'agentTurnFailed', message: String(e && e.message || e),
|
|
184
|
+
}));
|
|
185
|
+
}
|
|
186
|
+
} else if (window.ReactNativeWebView) {
|
|
187
|
+
window.ReactNativeWebView.postMessage(JSON.stringify({
|
|
188
|
+
type: 'agentTurnUnsupported',
|
|
189
|
+
message: 'voice app does not support agent turns (__adasAgentTurn missing) — update the voice page',
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
})(${JSON.stringify(input)}); true;
|
|
193
|
+
`;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* A silent OBSERVATION — tell the model something without asking it to speak.
|
|
197
|
+
* Calls the voice app's `__adasObserve(text)`, which adds a `system` conversation
|
|
198
|
+
* item and does NOT request a response (Core owns that function). This is the
|
|
199
|
+
* cheap channel for a continuous stream — e.g. what the camera sees while the
|
|
200
|
+
* user walks — as opposed to `__adasAgentTurn`, which asks the model to speak.
|
|
201
|
+
*
|
|
202
|
+
* Same refuse-don't-no-op contract as the agent turn: a missing hook posts a
|
|
203
|
+
* notice back rather than failing silently.
|
|
204
|
+
*/
|
|
205
|
+
const buildObserveScript = (text: string) => `
|
|
206
|
+
(function(text) {
|
|
207
|
+
if (typeof window.__adasObserve === 'function') {
|
|
208
|
+
try { window.__adasObserve(text); }
|
|
209
|
+
catch (e) {
|
|
210
|
+
window.ReactNativeWebView && window.ReactNativeWebView.postMessage(JSON.stringify({
|
|
211
|
+
type: 'observeFailed', message: String(e && e.message || e),
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
} else if (window.ReactNativeWebView) {
|
|
215
|
+
window.ReactNativeWebView.postMessage(JSON.stringify({
|
|
216
|
+
type: 'observeUnsupported',
|
|
217
|
+
message: 'voice app does not support observe (__adasObserve missing) — update the voice page',
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
})(${JSON.stringify(text)}); true;
|
|
221
|
+
`;
|
|
222
|
+
|
|
223
|
+
/** Origin of a URL, or null if it can't be parsed. */
|
|
224
|
+
function originOf(url?: string | null): string | null {
|
|
225
|
+
if (!url) return null;
|
|
226
|
+
const m = /^([a-z][a-z0-9+.-]*:\/\/[^/?#]+)/i.exec(url);
|
|
227
|
+
return m ? m[1].toLowerCase() : null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
interface InternalState {
|
|
231
|
+
callState: CallState;
|
|
232
|
+
sessionId: string | null;
|
|
233
|
+
error: string | null;
|
|
234
|
+
transcript: TranscriptEntry[];
|
|
235
|
+
aiPartial: string;
|
|
236
|
+
userPartial: string;
|
|
237
|
+
muted: boolean;
|
|
238
|
+
/** User VAD, from the mini-app's speechActivity events. */
|
|
239
|
+
userSpeaking: boolean;
|
|
240
|
+
/** Epoch ms of the last speaking:false, or null before the first utterance. */
|
|
241
|
+
lastUserSpeechEndedAt: number | null;
|
|
242
|
+
/** The last device-tool call the model made (name + when), or null. */
|
|
243
|
+
lastDeviceTool: { name: string; at: number } | null;
|
|
244
|
+
/** The WebView should be mounted (a session is wanted / live). */
|
|
245
|
+
mounted: boolean;
|
|
246
|
+
/** The WebView source, computed ONCE at start() so state changes (transcript,
|
|
247
|
+
* partials) never remount the WebView and drop the session. */
|
|
248
|
+
webViewSource: { uri: string } | null;
|
|
249
|
+
skillSlug?: string;
|
|
250
|
+
/** Per-call persona override ({ name, style }) — sent to the voice page on the
|
|
251
|
+
* session URL and declared on POST /session; the server layers it. */
|
|
252
|
+
persona?: VoicePersona;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const IDLE: InternalState = {
|
|
256
|
+
callState: 'idle',
|
|
257
|
+
sessionId: null,
|
|
258
|
+
error: null,
|
|
259
|
+
transcript: [],
|
|
260
|
+
aiPartial: '',
|
|
261
|
+
userPartial: '',
|
|
262
|
+
muted: false,
|
|
263
|
+
userSpeaking: false,
|
|
264
|
+
lastUserSpeechEndedAt: null,
|
|
265
|
+
lastDeviceTool: null,
|
|
266
|
+
mounted: false,
|
|
267
|
+
webViewSource: null,
|
|
268
|
+
skillSlug: undefined,
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
type Pending = { resolve: (s: VoiceSnapshot) => void; reject: (e: Error) => void; timer: any };
|
|
272
|
+
|
|
273
|
+
class VoiceSessionManager implements VoiceSessionProvider {
|
|
274
|
+
private state: InternalState = { ...IDLE };
|
|
275
|
+
private listeners = new Set<(e: VoiceEvent) => void>();
|
|
276
|
+
private webView: { injectJavaScript?: (js: string) => void } | null = null;
|
|
277
|
+
private pending: Pending[] = [];
|
|
278
|
+
/**
|
|
279
|
+
* WHO owns the one physical session. Set to the caller that started it from
|
|
280
|
+
* idle (a plugin's pluginId, or the PLATFORM symbol for the built-in sheet),
|
|
281
|
+
* and kept after stop until the next start reassigns it — so a plugin can read
|
|
282
|
+
* its own just-ended transcript but another plugin never can.
|
|
283
|
+
*
|
|
284
|
+
* ISOLATION (GPT blocker on 8881225): every access is scoped through this. A
|
|
285
|
+
* different plugin must not read, control, or attach to another plugin's live
|
|
286
|
+
* conversation just because it declared voice.
|
|
287
|
+
*/
|
|
288
|
+
private owner: VoiceCaller | null = null;
|
|
289
|
+
|
|
290
|
+
/** EVERY start() still in its pre-mount awaits (tool preload, token refresh).
|
|
291
|
+
* The session is not 'connecting' yet, so a stop() arriving now would otherwise
|
|
292
|
+
* be an idle no-op — and a start would then mount a call for a screen that has
|
|
293
|
+
* already gone. A set, not one slot: two concurrent starts must BOTH be
|
|
294
|
+
* cancellable. stop() cancels every pending start of its caller (the platform:
|
|
295
|
+
* all of them); another plugin's stop cancels none. */
|
|
296
|
+
private pendingStarts = new Set<{ caller: VoiceCaller; cancelled: boolean }>();
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* The device-tool names Core ALLOWED for this voice session — Core's sanitized
|
|
300
|
+
* allowset, delivered to the phone in a `deviceToolAllowset` message.
|
|
301
|
+
* Authorization gates against THIS (necessary, alongside the registry's can-do).
|
|
302
|
+
*
|
|
303
|
+
* WHY (GPT gate 1, corrected): the phone only PROPOSES its available tools
|
|
304
|
+
* (sendDeviceToolList); CORE is the session authority and decides which are
|
|
305
|
+
* accepted (session.device_tools). Freezing the phone's own proposal would let
|
|
306
|
+
* a tool Core REJECTED still run locally. So the authz set is populated from
|
|
307
|
+
* Core's accepted set, never from listDeviceTools(). Until Core's set arrives
|
|
308
|
+
* this is null and every call FAILS CLOSED. Pinned once per session; the live
|
|
309
|
+
* registry (which drifts via lazy loads / host registration / tenant changes)
|
|
310
|
+
* never feeds it.
|
|
311
|
+
*/
|
|
312
|
+
private frozenTools: Set<string> | null = null;
|
|
313
|
+
|
|
314
|
+
/** The voice `sessionId` the current allowset is BOUND to. Authorization
|
|
315
|
+
* requires it to equal the active session's id (state.sessionId), so a stale
|
|
316
|
+
* allowset from a previous/other session is refused. */
|
|
317
|
+
private frozenSessionId: string | null = null;
|
|
318
|
+
|
|
319
|
+
/** A CLEAN call (skills:'none') started by a plugin offers and runs ONLY that
|
|
320
|
+
* plugin's own device tools — not other solutions' tools that happen to be
|
|
321
|
+
* registered on the same phone. null = every registered tool (a normal call,
|
|
322
|
+
* or the platform's). Set by every start(); like `owner`, kept after stop.
|
|
323
|
+
* ONE identity: the caller is the TRUSTED manifest pluginId gateVoice stamps,
|
|
324
|
+
* and the bundleManager registers a plugin's tools under that same id (never
|
|
325
|
+
* the bundle's own short `id`), so exact equality is the whole check. */
|
|
326
|
+
private toolScope: string | null = null;
|
|
327
|
+
|
|
328
|
+
/** The platform (built-in sheet, mic indicator, session host) sees and
|
|
329
|
+
* controls any session — it is the user's own surface. A plugin caller may
|
|
330
|
+
* only touch a session it owns. */
|
|
331
|
+
private canAccess(caller: VoiceCaller): boolean {
|
|
332
|
+
return caller === PLATFORM_VOICE_CALLER || this.owner === caller;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
private foreignError(): Error {
|
|
336
|
+
const err: any = new Error(
|
|
337
|
+
'voice: another solution is using the microphone — one live session per device.',
|
|
338
|
+
);
|
|
339
|
+
err.code = 'RESTRICTED';
|
|
340
|
+
return err;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ── Host wiring (used only by VoiceSessionHost) ──────────────
|
|
344
|
+
|
|
345
|
+
/** The offscreen WebView sets its ref here so the session can inject into it. */
|
|
346
|
+
setWebView(ref: { injectJavaScript?: (js: string) => void } | null): void {
|
|
347
|
+
this.webView = ref;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Whole state — the host reads `mounted` + `webViewSource` to decide what to
|
|
351
|
+
* render, and every reader derives its snapshot from this. */
|
|
352
|
+
getInternal(): Readonly<InternalState> {
|
|
353
|
+
return this.state;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
readonly injectedTrackHook = INJECT_TRACK_HOOK;
|
|
357
|
+
|
|
358
|
+
// ── VoiceAPI (what props.voice and useVoiceCall call) ────────
|
|
359
|
+
|
|
360
|
+
async start(opts?: VoiceStartOptions, caller: VoiceCaller = PLATFORM_VOICE_CALLER): Promise<VoiceSnapshot> {
|
|
361
|
+
// Single session: if one is live or connecting, ATTACH — never a second
|
|
362
|
+
// Realtime loop (invariant 2). But ONLY the owner (or the platform) may
|
|
363
|
+
// attach; a different plugin that declared voice is REFUSED, not silently
|
|
364
|
+
// joined to a conversation it does not own (GPT isolation blocker). A
|
|
365
|
+
// different skillSlug does NOT restart it.
|
|
366
|
+
if (this.state.callState === 'connected') {
|
|
367
|
+
if (!this.canAccess(caller)) throw this.foreignError();
|
|
368
|
+
return this.realSnapshot();
|
|
369
|
+
}
|
|
370
|
+
if (this.state.callState === 'connecting') {
|
|
371
|
+
if (!this.canAccess(caller)) throw this.foreignError();
|
|
372
|
+
return this.waitForConnect();
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (opts?.skills === 'none' && opts?.skillSlug) {
|
|
376
|
+
throw new Error("voice: skills:'none' (a clean call) cannot also hand the mic to a skill (skillSlug).");
|
|
377
|
+
}
|
|
378
|
+
const ticket = { caller, cancelled: false };
|
|
379
|
+
this.pendingStarts.add(ticket);
|
|
380
|
+
|
|
381
|
+
// Device tools only exist once their bundle is evaluated; a user who goes
|
|
382
|
+
// straight to voice can otherwise ask for a tool the phone never loaded.
|
|
383
|
+
// Best-effort — a failure just means no device tools this time.
|
|
384
|
+
try {
|
|
385
|
+
await ensureDeviceToolsLoaded();
|
|
386
|
+
} catch (e: any) {
|
|
387
|
+
console.warn('[voiceSession] device tool preload failed:', e?.message || String(e));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Hand the WebView a FRESH token before it mounts (it never refreshes once
|
|
391
|
+
// mounted). Only pays a round-trip when near expiry.
|
|
392
|
+
try {
|
|
393
|
+
if (tokenNeedsRefresh(getToken())) await refreshToken();
|
|
394
|
+
} catch {
|
|
395
|
+
// Proceed with whatever token we have; voice-backend surfaces a clean auth
|
|
396
|
+
// error rather than us blocking the call.
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
this.pendingStarts.delete(ticket);
|
|
400
|
+
if (ticket.cancelled) throw new Error('voice: stopped before the session started');
|
|
401
|
+
// Another start() may have mounted a session while we awaited — attach to it
|
|
402
|
+
// (or be refused) exactly as the entry checks would have, never mount twice.
|
|
403
|
+
// (Re-read: the awaits above can change it; the entry checks' narrowing can't.)
|
|
404
|
+
const after = this.getInternal().callState;
|
|
405
|
+
if (after === 'connected') {
|
|
406
|
+
if (!this.canAccess(caller)) throw this.foreignError();
|
|
407
|
+
return this.realSnapshot();
|
|
408
|
+
}
|
|
409
|
+
if (after === 'connecting') {
|
|
410
|
+
if (!this.canAccess(caller)) throw this.foreignError();
|
|
411
|
+
return this.waitForConnect();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Idle → this caller becomes the owner of the new session. A NEW session
|
|
415
|
+
// re-freezes its tool set on the next tool-list request.
|
|
416
|
+
this.owner = caller;
|
|
417
|
+
this.toolScope = opts?.skills === 'none' && typeof caller === 'string' ? caller : null;
|
|
418
|
+
this.frozenTools = null;
|
|
419
|
+
this.frozenSessionId = null;
|
|
420
|
+
const skillSlug = opts?.skillSlug;
|
|
421
|
+
this.setState({
|
|
422
|
+
callState: 'connecting',
|
|
423
|
+
error: null,
|
|
424
|
+
transcript: [],
|
|
425
|
+
aiPartial: '',
|
|
426
|
+
userPartial: '',
|
|
427
|
+
muted: false,
|
|
428
|
+
mounted: true,
|
|
429
|
+
skillSlug,
|
|
430
|
+
persona: opts?.persona,
|
|
431
|
+
webViewSource: { uri: this.buildUri(skillSlug, opts?.persona, opts?.skills) },
|
|
432
|
+
});
|
|
433
|
+
return this.waitForConnect();
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
stop(caller: VoiceCaller = PLATFORM_VOICE_CALLER): void {
|
|
437
|
+
// A plugin may not hang up another solution's live call. (Idle stop is a
|
|
438
|
+
// harmless no-op for anyone.)
|
|
439
|
+
if (this.state.callState !== 'idle' && !this.canAccess(caller)) throw this.foreignError();
|
|
440
|
+
// Starts still before their mount: cancel ALL of this caller's (the platform:
|
|
441
|
+
// everyone's) — another plugin must not be able to abort someone's call.
|
|
442
|
+
for (const t of [...this.pendingStarts]) {
|
|
443
|
+
if (caller === PLATFORM_VOICE_CALLER || caller === t.caller) {
|
|
444
|
+
t.cancelled = true;
|
|
445
|
+
this.pendingStarts.delete(t);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
this.webView?.injectJavaScript?.('window.hangUp && window.hangUp(); true;');
|
|
449
|
+
this.rejectPending(new Error('voice: session stopped before it connected'));
|
|
450
|
+
this.frozenTools = null; // the session's authorized set ends with the session
|
|
451
|
+
this.frozenSessionId = null;
|
|
452
|
+
this.setState({
|
|
453
|
+
...IDLE,
|
|
454
|
+
// keep the transcript of the call that just ended for any reader still up
|
|
455
|
+
transcript: this.state.transcript,
|
|
456
|
+
});
|
|
457
|
+
// owner is intentionally KEPT (not reset to null): the next start() from
|
|
458
|
+
// idle reassigns it, and until then the owner can still read its own
|
|
459
|
+
// just-ended transcript while others cannot.
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
setMuted(muted: boolean, caller: VoiceCaller = PLATFORM_VOICE_CALLER): void {
|
|
463
|
+
if (this.state.callState !== 'idle' && !this.canAccess(caller)) throw this.foreignError();
|
|
464
|
+
this.webView?.injectJavaScript?.(buildMuteScript(muted));
|
|
465
|
+
this.setState({ muted });
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
prompt(input: string, caller: VoiceCaller = PLATFORM_VOICE_CALLER): void {
|
|
469
|
+
if (this.state.callState !== 'connected') {
|
|
470
|
+
throw new Error('voice: session is not connected — call start() and await it before prompt().');
|
|
471
|
+
}
|
|
472
|
+
if (!this.canAccess(caller)) throw this.foreignError();
|
|
473
|
+
this.webView?.injectJavaScript?.(buildAgentTurnScript(input));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Stream a silent observation into the live session — tell the model without
|
|
478
|
+
* asking it to speak. The cheap channel behind the gated props.voice.observe();
|
|
479
|
+
* every item stays in the model's context for the call, so a caller sends only
|
|
480
|
+
* material changes. Owner-scoped like every other control.
|
|
481
|
+
*/
|
|
482
|
+
observe(text: string, caller: VoiceCaller = PLATFORM_VOICE_CALLER): void {
|
|
483
|
+
if (this.state.callState !== 'connected') {
|
|
484
|
+
throw new Error('voice: session is not connected — observe() needs a live session.');
|
|
485
|
+
}
|
|
486
|
+
if (!this.canAccess(caller)) throw this.foreignError();
|
|
487
|
+
this.webView?.injectJavaScript?.(buildObserveScript(text));
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
getSnapshot(caller: VoiceCaller = PLATFORM_VOICE_CALLER): VoiceSnapshot {
|
|
491
|
+
// A non-owner sees "you have no session" — which is TRUE for it — and learns
|
|
492
|
+
// nothing about the conversation another solution is holding. This is not a
|
|
493
|
+
// fake idle (invariant 6 is about an UNDECLARED plugin): a declared plugin
|
|
494
|
+
// that does not own the live session genuinely has none of its own.
|
|
495
|
+
return this.canAccess(caller) ? this.realSnapshot() : this.foreignSnapshot();
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
private realSnapshot(): VoiceSnapshot {
|
|
499
|
+
const s = this.state;
|
|
500
|
+
return {
|
|
501
|
+
state: s.callState,
|
|
502
|
+
sessionId: s.sessionId,
|
|
503
|
+
muted: s.muted,
|
|
504
|
+
speaking: !!s.aiPartial,
|
|
505
|
+
listening: !!s.userPartial,
|
|
506
|
+
userSpeaking: s.userSpeaking,
|
|
507
|
+
lastUserSpeechEndedAt: s.lastUserSpeechEndedAt,
|
|
508
|
+
lastDeviceTool: s.lastDeviceTool,
|
|
509
|
+
authorizedSessionId: this.frozenSessionId,
|
|
510
|
+
lastError: s.error,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private foreignSnapshot(): VoiceSnapshot {
|
|
515
|
+
return {
|
|
516
|
+
state: 'idle', sessionId: null, muted: false, speaking: false, listening: false,
|
|
517
|
+
userSpeaking: false, lastUserSpeechEndedAt: null, lastDeviceTool: null,
|
|
518
|
+
authorizedSessionId: null, lastError: null,
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
subscribe(cb: (event: VoiceEvent) => void, caller: VoiceCaller = PLATFORM_VOICE_CALLER): VoiceSubscription {
|
|
523
|
+
// Scope the stream: a non-owner's callback never fires (it has no session to
|
|
524
|
+
// observe), so a plugin cannot watch another solution's conversation by
|
|
525
|
+
// pre-registering a subscription before/while it is live. When this caller
|
|
526
|
+
// later owns a session, canAccess flips true and its callback starts firing.
|
|
527
|
+
const wrapped = (event: VoiceEvent) => { if (this.canAccess(caller)) cb(event); };
|
|
528
|
+
this.listeners.add(wrapped);
|
|
529
|
+
return { unsubscribe: () => { this.listeners.delete(wrapped); } };
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
transcript(caller: VoiceCaller = PLATFORM_VOICE_CALLER): TranscriptEntry[] {
|
|
533
|
+
return this.canAccess(caller) ? this.state.transcript.slice() : [];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ── WebView plumbing ─────────────────────────────────────────
|
|
537
|
+
|
|
538
|
+
private buildUri(skillSlug?: string, persona?: VoicePersona, skills?: VoiceStartOptions['skills']): string {
|
|
539
|
+
const token = getToken();
|
|
540
|
+
const tenant = getTenant();
|
|
541
|
+
const actorId = getUser()?.actorId || '';
|
|
542
|
+
const skillParam = skillSlug ? `&skillSlug=${encodeURIComponent(skillSlug)}` : '';
|
|
543
|
+
// Declared start option, carried on the same URL as tenant/authToken; the
|
|
544
|
+
// voice page forwards it as POST /session `persona`, where it's sanitized.
|
|
545
|
+
const p = persona && (persona.name || persona.style)
|
|
546
|
+
? { ...(persona.name ? { name: persona.name } : {}), ...(persona.style ? { style: persona.style } : {}) }
|
|
547
|
+
: null;
|
|
548
|
+
const personaParam = p ? `&persona=${encodeURIComponent(JSON.stringify(p))}` : '';
|
|
549
|
+
// Clean call: only the one reduce-only value is ever sent; anything else is
|
|
550
|
+
// not a request at all (the server accepts only 'none' too).
|
|
551
|
+
const skillsParam = skills === 'none' ? '&skills=none' : '';
|
|
552
|
+
return `${getVoiceApiUrl()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || '')}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}${personaParam}${skillsParam}`;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** Handle a postMessage from the WebView. `event` is the RN WebView event. */
|
|
556
|
+
handleMessage(event: any): void {
|
|
557
|
+
let data: any;
|
|
558
|
+
try {
|
|
559
|
+
data = JSON.parse(event.nativeEvent.data);
|
|
560
|
+
} catch {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
switch (data.type) {
|
|
564
|
+
case 'callState':
|
|
565
|
+
case 'call-state':
|
|
566
|
+
if (data.state) this.setState({ callState: data.state });
|
|
567
|
+
if (data.sessionId) this.setState({ sessionId: data.sessionId });
|
|
568
|
+
if (data.error) this.setState({ error: data.error });
|
|
569
|
+
if (data.state === 'connected') this.resolvePending();
|
|
570
|
+
if (data.state === 'error') this.rejectPending(new Error(data.error || 'voice: connection error'));
|
|
571
|
+
break;
|
|
572
|
+
case 'transcript':
|
|
573
|
+
this.appendTranscript({ role: data.role, text: data.text, ts: Date.now() });
|
|
574
|
+
break;
|
|
575
|
+
case 'aiPartial':
|
|
576
|
+
this.setState({ aiPartial: data.text });
|
|
577
|
+
break;
|
|
578
|
+
case 'userPartial':
|
|
579
|
+
this.setState({ userPartial: data.text });
|
|
580
|
+
break;
|
|
581
|
+
case 'clearPartials':
|
|
582
|
+
this.setState({ aiPartial: '', userPartial: '' });
|
|
583
|
+
break;
|
|
584
|
+
// User VAD from the mini-app. `at` is when it happened; on speaking:false
|
|
585
|
+
// we stamp lastUserSpeechEndedAt so a solution can gate "speak after N s of
|
|
586
|
+
// silence" on the first real stop, never before the user has spoken.
|
|
587
|
+
case 'speechActivity': {
|
|
588
|
+
const speaking = !!data.speaking;
|
|
589
|
+
const at = typeof data.at === 'number' ? data.at : Date.now();
|
|
590
|
+
this.setState(
|
|
591
|
+
speaking
|
|
592
|
+
? { userSpeaking: true }
|
|
593
|
+
: { userSpeaking: false, lastUserSpeechEndedAt: at },
|
|
594
|
+
);
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
case 'error':
|
|
598
|
+
this.setState({ error: data.message, callState: 'error' });
|
|
599
|
+
this.rejectPending(new Error(data.message || 'voice: error'));
|
|
600
|
+
break;
|
|
601
|
+
// A proactive turn couldn't run (older voice page / hook threw). NON-FATAL:
|
|
602
|
+
// the session stays live; we only surface why the guide didn't speak.
|
|
603
|
+
case 'agentTurnUnsupported':
|
|
604
|
+
case 'agentTurnFailed':
|
|
605
|
+
case 'observeUnsupported':
|
|
606
|
+
case 'observeFailed':
|
|
607
|
+
this.setState({ error: data.message || 'voice: channel notice' });
|
|
608
|
+
break;
|
|
609
|
+
// Device tools — executed locally, origin-checked. The model's function
|
|
610
|
+
// call already arrived on this phone; only the voice app's own origin may
|
|
611
|
+
// drive the camera/GPS.
|
|
612
|
+
case 'device-tool-list': {
|
|
613
|
+
const listSender = originOf(event?.nativeEvent?.url);
|
|
614
|
+
const listExpected = originOf(getVoiceApiUrl());
|
|
615
|
+
if (!listSender || !listExpected || listSender !== listExpected) break;
|
|
616
|
+
this.sendDeviceToolList(data.correlationId);
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
// CORE's ALLOWSET for this session — what the model may ASK for. Core sends
|
|
620
|
+
// it (even empty, on purpose) at session start. It is a NECESSARY gate, not
|
|
621
|
+
// the whole story: a call must be in the allowset AND the device must
|
|
622
|
+
// actually have the tool (the registry check inside callDeviceTool) — Core
|
|
623
|
+
// says what may be asked, the device decides what it will do.
|
|
624
|
+
//
|
|
625
|
+
// Origin-checked (only the voice app may set it) and pinned ONCE per session
|
|
626
|
+
// (a later message cannot widen it). An EMPTY allowset (arrived) refuses
|
|
627
|
+
// everything but is distinct from NEVER-arrived (frozenTools stays null);
|
|
628
|
+
// both fail closed here — we never fall back to the phone's own registry,
|
|
629
|
+
// which is the seam this closes.
|
|
630
|
+
case 'deviceToolAllowset': {
|
|
631
|
+
const sender = originOf(event?.nativeEvent?.url);
|
|
632
|
+
const expected = originOf(getVoiceApiUrl());
|
|
633
|
+
if (!sender || !expected || sender !== expected) break;
|
|
634
|
+
// Bind the allowset to its sessionId. Pin (or REPLACE for a different
|
|
635
|
+
// session) but never WIDEN within the same session — a second allowset
|
|
636
|
+
// for the same sessionId is ignored. Requires a string sessionId, so an
|
|
637
|
+
// allowset without one never authorizes anything (fail closed).
|
|
638
|
+
if (Array.isArray(data.tools) && typeof data.sessionId === 'string' && data.sessionId !== this.frozenSessionId) {
|
|
639
|
+
this.frozenTools = new Set(data.tools.filter((n: unknown) => typeof n === 'string'));
|
|
640
|
+
this.frozenSessionId = data.sessionId;
|
|
641
|
+
}
|
|
642
|
+
break;
|
|
643
|
+
}
|
|
644
|
+
case 'device-tool-call': {
|
|
645
|
+
const sender = originOf(event?.nativeEvent?.url);
|
|
646
|
+
const expected = originOf(getVoiceApiUrl());
|
|
647
|
+
if (!sender || !expected || sender !== expected) {
|
|
648
|
+
console.warn(`[voiceSession] REFUSED device tool call from "${sender || 'unknown origin'}" (expected "${expected}")`);
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
if (data.name) this.setState({ lastDeviceTool: { name: data.name, at: Date.now() } });
|
|
652
|
+
// AUTHORIZATION against the FROZEN session set, not the live registry
|
|
653
|
+
// (GPT gate 1). A tool that appeared after connect — the drift case — is
|
|
654
|
+
// refused here, before execution, even if it is registered right now.
|
|
655
|
+
if (!this.isToolAuthorized(data.name)) {
|
|
656
|
+
// Both refuse (fail closed), but they are DIFFERENT answers to the model:
|
|
657
|
+
// NOT-YET-TOLD (Core's allowset hasn't arrived) is transient — retry;
|
|
658
|
+
// TOLD-AND-DENIED (allowset present, tool not in it / wrong session /
|
|
659
|
+
// unregistered) is final. Same refusal, different sentence.
|
|
660
|
+
const notReady = this.frozenTools === null;
|
|
661
|
+
console.warn(`[voiceSession] REFUSED "${data.name}" — ${notReady ? 'allowset not received yet' : 'not authorized for this session'}`);
|
|
662
|
+
this.postDeviceToolRefusal(data.correlationId, data.name, notReady);
|
|
663
|
+
break;
|
|
664
|
+
}
|
|
665
|
+
void this.runDeviceTool(data);
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Final authorization = Core's frozen allowset ∩ the current valid local
|
|
673
|
+
* registry, bound to the ACTIVE session. All must hold:
|
|
674
|
+
* - a Core allowset arrived and is bound to THIS session (frozenSessionId ===
|
|
675
|
+
* the active state.sessionId) — a missing or stale/wrong-session allowset
|
|
676
|
+
* fails closed;
|
|
677
|
+
* - the tool is IN that allowset (Core approved it); AND
|
|
678
|
+
* - the tool is STILL registered locally right now (removed-after-approval is
|
|
679
|
+
* refused; a lazily-added tool Core never approved is refused) — and, on a
|
|
680
|
+
* clean call, registered by the call's owner.
|
|
681
|
+
* No fallback to the live registry alone, ever.
|
|
682
|
+
*/
|
|
683
|
+
private isToolAuthorized(name: unknown): boolean {
|
|
684
|
+
if (typeof name !== 'string') return false;
|
|
685
|
+
if (this.frozenTools === null || this.frozenSessionId === null) return false;
|
|
686
|
+
if (this.frozenSessionId !== this.state.sessionId) return false;
|
|
687
|
+
if (!this.frozenTools.has(name)) return false;
|
|
688
|
+
return hasDeviceTool(name, this.toolScope ?? undefined); // still registered locally right now
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/** Tell the mini-app the call was refused — a stated reason, never a silent drop.
|
|
692
|
+
* `notReady` distinguishes a transient "allowset not here yet" (retry) from a
|
|
693
|
+
* final "not allowed in this session". */
|
|
694
|
+
private postDeviceToolRefusal(correlationId: string, name: string, notReady: boolean): void {
|
|
695
|
+
const payload = notReady
|
|
696
|
+
? {
|
|
697
|
+
correlationId, ok: false, reason: 'not_ready',
|
|
698
|
+
error: 'The voice session is still starting up — its tool allowset has not arrived yet. Try again in a moment.',
|
|
699
|
+
}
|
|
700
|
+
: {
|
|
701
|
+
correlationId, ok: false, reason: 'not_authorized',
|
|
702
|
+
error: `"${name}" is not available in this voice session.`,
|
|
703
|
+
};
|
|
704
|
+
this.webView?.injectJavaScript?.(
|
|
705
|
+
`window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
|
|
706
|
+
JSON.stringify(payload),
|
|
707
|
+
)}); true;`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** PROPOSE the phone's available tools — this is an offer to Core, not the
|
|
712
|
+
* authorization set. Core sanitizes it and returns the accepted set in a
|
|
713
|
+
* `deviceToolAllowset` message, which is what actually gates execution. */
|
|
714
|
+
private sendDeviceToolList(correlationId: string): void {
|
|
715
|
+
const scope = this.toolScope;
|
|
716
|
+
const tools = listDeviceTools().filter(t => scope === null || t.pluginId === scope).map(t => ({
|
|
717
|
+
name: t.name,
|
|
718
|
+
description: t.description,
|
|
719
|
+
input_schema: t.input_schema,
|
|
720
|
+
}));
|
|
721
|
+
this.webView?.injectJavaScript?.(
|
|
722
|
+
`window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
|
|
723
|
+
JSON.stringify({ correlationId, ok: true, tools }),
|
|
724
|
+
)}); true;`,
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/** Run a device tool and post the outcome back into the WebView. */
|
|
729
|
+
private async runDeviceTool(data: any): Promise<void> {
|
|
730
|
+
const { correlationId, name, args, timeoutMs } = data || {};
|
|
731
|
+
if (!correlationId || typeof name !== 'string') return;
|
|
732
|
+
let payload: any;
|
|
733
|
+
try {
|
|
734
|
+
const outcome = await callDeviceTool(name, args || {}, { timeoutMs });
|
|
735
|
+
payload = outcome.ok
|
|
736
|
+
? { correlationId, ok: true, result: outcome.result }
|
|
737
|
+
: { correlationId, ok: false, error: outcome.error, reason: outcome.reason };
|
|
738
|
+
} catch (err: any) {
|
|
739
|
+
payload = { correlationId, ok: false, reason: 'failed', error: err?.message || String(err) };
|
|
740
|
+
}
|
|
741
|
+
this.webView?.injectJavaScript?.(
|
|
742
|
+
`window.__adasDeviceToolResult && window.__adasDeviceToolResult(${JSON.stringify(
|
|
743
|
+
JSON.stringify(payload),
|
|
744
|
+
)}); true;`,
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// ── internals ────────────────────────────────────────────────
|
|
749
|
+
|
|
750
|
+
private setState(partial: Partial<InternalState>): void {
|
|
751
|
+
this.state = { ...this.state, ...partial };
|
|
752
|
+
this.emit({ type: 'state' });
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
private appendTranscript(entry: TranscriptEntry): void {
|
|
756
|
+
this.state = { ...this.state, transcript: [...this.state.transcript, entry] };
|
|
757
|
+
this.emit({ type: 'transcript' });
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
private emit(event: VoiceEvent): void {
|
|
761
|
+
for (const cb of this.listeners) {
|
|
762
|
+
try { cb(event); } catch (e) { console.warn('[voiceSession] listener threw', e); }
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
private waitForConnect(): Promise<VoiceSnapshot> {
|
|
767
|
+
return new Promise<VoiceSnapshot>((resolve, reject) => {
|
|
768
|
+
const timer = setTimeout(() => {
|
|
769
|
+
this.pending = this.pending.filter(p => p.timer !== timer);
|
|
770
|
+
reject(new Error('voice: timed out waiting to connect (is VoiceSessionHost mounted?)'));
|
|
771
|
+
}, CONNECT_TIMEOUT_MS);
|
|
772
|
+
this.pending.push({ resolve, reject, timer });
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
private resolvePending(): void {
|
|
777
|
+
// Everyone still waiting on connect already passed the access check in
|
|
778
|
+
// start(), so they get the real snapshot.
|
|
779
|
+
const snap = this.realSnapshot();
|
|
780
|
+
const waiting = this.pending;
|
|
781
|
+
this.pending = [];
|
|
782
|
+
for (const p of waiting) { clearTimeout(p.timer); p.resolve(snap); }
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private rejectPending(err: Error): void {
|
|
786
|
+
const waiting = this.pending;
|
|
787
|
+
this.pending = [];
|
|
788
|
+
for (const p of waiting) { clearTimeout(p.timer); p.reject(err); }
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** TEST ONLY: reset the singleton to a clean idle state (mirrors the
|
|
792
|
+
* __resetSessionSyncForTest precedent). Not part of any surface. */
|
|
793
|
+
__resetForTest(): void {
|
|
794
|
+
this.rejectPending(new Error('voice: reset'));
|
|
795
|
+
this.state = { ...IDLE };
|
|
796
|
+
this.owner = null;
|
|
797
|
+
this.toolScope = null;
|
|
798
|
+
this.pendingStarts.clear();
|
|
799
|
+
this.frozenTools = null;
|
|
800
|
+
this.frozenSessionId = null;
|
|
801
|
+
this.listeners.clear();
|
|
802
|
+
this.webView = null;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** The one live session. Handed to setVoiceProvider(voiceSession) at startup. */
|
|
807
|
+
export const voiceSession = new VoiceSessionManager();
|