@ateam-ai-mobile/ui 0.1.5 → 0.1.7
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 +325 -21
- package/dist/index.d.ts +325 -21
- package/dist/index.js +1127 -176
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1120 -177
- 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 +14 -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 +628 -0
|
@@ -1,245 +1,54 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* useVoiceCall —
|
|
2
|
+
* useVoiceCall — a React VIEW onto the one `voiceSession`.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
*
|
|
14
|
-
* refreshes it (unlike the SDK REST client, which self-heals on 401). So a
|
|
15
|
-
* token that has gone stale by the time the user taps the mic is handed to
|
|
16
|
-
* voice-backend and rejected by Core /api/auth/me ("Invalid or expired
|
|
17
|
-
* token"). Guard: if the current token is expired or within `SKEW_MS` of
|
|
18
|
-
* expiry, refresh it BEFORE the WebView mounts so it gets a fresh token.
|
|
19
|
-
* Cheap in the common case — we only pay the round-trip when actually near
|
|
20
|
-
* expiry (decoded locally, no network unless needed).
|
|
4
|
+
* The session itself (WebRTC WebView, transcript, device tools, mute, token
|
|
5
|
+
* refresh) no longer lives here — it moved to `voiceSession`, a singleton the
|
|
6
|
+
* app roots via VoiceSessionHost so the loop survives any screen unmounting. This
|
|
7
|
+
* hook is now just a subscription: it re-renders its caller on session changes
|
|
8
|
+
* and forwards the controls. VoiceCallSheet is one such view; a plugin's own UI
|
|
9
|
+
* is another, via `props.voice`.
|
|
10
|
+
*
|
|
11
|
+
* The return shape is kept backward-compatible for VoiceCallSheet, minus the
|
|
12
|
+
* WebView plumbing (source/ref/message handler/track hook) — that is the host's
|
|
13
|
+
* job now, not any screen's.
|
|
21
14
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (!token) return true;
|
|
25
|
-
try {
|
|
26
|
-
const payloadB64 = token.split('.')[1];
|
|
27
|
-
const padded = payloadB64.replace(/-/g, '+').replace(/_/g, '/');
|
|
28
|
-
// Hermes has atob; Buffer fallback for older runtimes (mirrors
|
|
29
|
-
// apps/mobile authStore.decodeJwtPayload).
|
|
30
|
-
const json = typeof atob === 'function'
|
|
31
|
-
? atob(padded)
|
|
32
|
-
: Buffer.from(padded, 'base64').toString('utf8');
|
|
33
|
-
const exp = JSON.parse(json)?.exp;
|
|
34
|
-
if (typeof exp !== 'number') return false;
|
|
35
|
-
return Date.now() >= exp * 1000 - TOKEN_REFRESH_SKEW_MS;
|
|
36
|
-
} catch {
|
|
37
|
-
return false; // can't decode → let the server decide, don't block the call
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export interface TranscriptEntry {
|
|
42
|
-
role: 'user' | 'ai' | 'tool';
|
|
43
|
-
text: string;
|
|
44
|
-
ts: number;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export type CallState = 'idle' | 'connecting' | 'connected' | 'error';
|
|
48
|
-
|
|
49
|
-
// Installed BEFORE the voice app loads. Captures every audio track that
|
|
50
|
-
// could possibly be sent to the peer connection so the mute button can flip
|
|
51
|
-
// enabled=false on all of them, regardless of how the voice app obtained or
|
|
52
|
-
// added the track.
|
|
53
|
-
const INJECT_TRACK_HOOK = `
|
|
54
|
-
(function() {
|
|
55
|
-
if (window._adaTrackHookInstalled) return;
|
|
56
|
-
window._adaTrackHookInstalled = true;
|
|
57
|
-
window._allAudioTracks = [];
|
|
58
|
-
window._adaPeerConnections = [];
|
|
59
|
-
|
|
60
|
-
function track(t) {
|
|
61
|
-
if (t && t.kind === 'audio' && window._allAudioTracks.indexOf(t) === -1) {
|
|
62
|
-
window._allAudioTracks.push(t);
|
|
63
|
-
}
|
|
64
|
-
}
|
|
15
|
+
import { useCallback, useEffect, useReducer } from 'react';
|
|
16
|
+
import { voiceSession, type CallState, type TranscriptEntry } from './voiceSession';
|
|
65
17
|
|
|
66
|
-
|
|
67
|
-
try {
|
|
68
|
-
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
|
|
69
|
-
const origGUM = navigator.mediaDevices.getUserMedia.bind(navigator.mediaDevices);
|
|
70
|
-
navigator.mediaDevices.getUserMedia = async function(constraints) {
|
|
71
|
-
const stream = await origGUM(constraints);
|
|
72
|
-
if (constraints && constraints.audio) {
|
|
73
|
-
try { stream.getAudioTracks().forEach(track); } catch (e) {}
|
|
74
|
-
}
|
|
75
|
-
return stream;
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
} catch (e) {}
|
|
79
|
-
|
|
80
|
-
// 2) Hook RTCPeerConnection.addTrack — catches whatever the app sends.
|
|
81
|
-
try {
|
|
82
|
-
if (window.RTCPeerConnection) {
|
|
83
|
-
const origPC = window.RTCPeerConnection;
|
|
84
|
-
const origAddTrack = origPC.prototype.addTrack;
|
|
85
|
-
const origAddStream = origPC.prototype.addStream;
|
|
86
|
-
const origAddTransceiver = origPC.prototype.addTransceiver;
|
|
87
|
-
|
|
88
|
-
origPC.prototype.addTrack = function(t) {
|
|
89
|
-
if (t && t.kind === 'audio') track(t);
|
|
90
|
-
return origAddTrack.apply(this, arguments);
|
|
91
|
-
};
|
|
92
|
-
if (origAddStream) {
|
|
93
|
-
origPC.prototype.addStream = function(stream) {
|
|
94
|
-
try { stream.getAudioTracks().forEach(track); } catch (e) {}
|
|
95
|
-
return origAddStream.apply(this, arguments);
|
|
96
|
-
};
|
|
97
|
-
}
|
|
98
|
-
if (origAddTransceiver) {
|
|
99
|
-
origPC.prototype.addTransceiver = function(arg) {
|
|
100
|
-
if (arg && arg.kind === 'audio') {
|
|
101
|
-
// Transceiver created with kind 'audio' — sender track set later.
|
|
102
|
-
} else if (arg && arg.kind === undefined && arg.track && arg.track.kind === 'audio') {
|
|
103
|
-
track(arg.track);
|
|
104
|
-
}
|
|
105
|
-
const tx = origAddTransceiver.apply(this, arguments);
|
|
106
|
-
try {
|
|
107
|
-
if (tx && tx.sender && tx.sender.track && tx.sender.track.kind === 'audio') track(tx.sender.track);
|
|
108
|
-
} catch (e) {}
|
|
109
|
-
return tx;
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
} catch (e) {}
|
|
114
|
-
|
|
115
|
-
// 3) Helper exposed for direct calls.
|
|
116
|
-
window._adaSetMuted = function(muted) {
|
|
117
|
-
let n = 0;
|
|
118
|
-
if (Array.isArray(window._allAudioTracks)) {
|
|
119
|
-
window._allAudioTracks.forEach(function(t) {
|
|
120
|
-
try { t.enabled = !muted; n++; } catch (e) {}
|
|
121
|
-
});
|
|
122
|
-
}
|
|
123
|
-
return n;
|
|
124
|
-
};
|
|
125
|
-
})(); true;
|
|
126
|
-
`;
|
|
127
|
-
|
|
128
|
-
const buildMuteScript = (muted: boolean) => `
|
|
129
|
-
(function(muted) {
|
|
130
|
-
if (typeof window.setMuted === 'function') { window.setMuted(muted); }
|
|
131
|
-
let n = 0;
|
|
132
|
-
if (typeof window._adaSetMuted === 'function') {
|
|
133
|
-
n = window._adaSetMuted(muted);
|
|
134
|
-
}
|
|
135
|
-
// Last-resort scan if hook somehow missed.
|
|
136
|
-
if (n === 0 && Array.isArray(window._allAudioTracks)) {
|
|
137
|
-
window._allAudioTracks.forEach(function(t) {
|
|
138
|
-
try { t.enabled = !muted; n++; } catch (e) {}
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
console.log('[ada-mute]', muted ? 'MUTED' : 'UNMUTED', 'tracks=', n);
|
|
142
|
-
})(${muted}); true;
|
|
143
|
-
`;
|
|
18
|
+
export type { CallState, TranscriptEntry };
|
|
144
19
|
|
|
145
20
|
export default function useVoiceCall({ skillSlug }: { skillSlug?: string } = {}) {
|
|
146
|
-
const [
|
|
147
|
-
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
148
|
-
const [error, setError] = useState<string | null>(null);
|
|
149
|
-
const [transcript, setTranscript] = useState<TranscriptEntry[]>([]);
|
|
150
|
-
const [aiPartial, setAiPartial] = useState('');
|
|
151
|
-
const [userPartial, setUserPartial] = useState('');
|
|
152
|
-
const [muted, setMuted] = useState(false);
|
|
153
|
-
const webViewRef = useRef<any>(null);
|
|
21
|
+
const [, force] = useReducer((x: number) => x + 1, 0);
|
|
154
22
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const data = JSON.parse(event.nativeEvent.data);
|
|
159
|
-
console.log('[VoiceWebView] message:', data.type, data.text?.slice?.(0, 50) || data.state || '');
|
|
160
|
-
switch (data.type) {
|
|
161
|
-
case 'callState':
|
|
162
|
-
case 'call-state':
|
|
163
|
-
setCallState(data.state);
|
|
164
|
-
if (data.sessionId) setSessionId(data.sessionId);
|
|
165
|
-
if (data.error) setError(data.error);
|
|
166
|
-
break;
|
|
167
|
-
case 'transcript':
|
|
168
|
-
setTranscript(prev => [...prev, { role: data.role, text: data.text, ts: Date.now() }]);
|
|
169
|
-
break;
|
|
170
|
-
case 'aiPartial':
|
|
171
|
-
setAiPartial(data.text);
|
|
172
|
-
break;
|
|
173
|
-
case 'userPartial':
|
|
174
|
-
setUserPartial(data.text);
|
|
175
|
-
break;
|
|
176
|
-
case 'clearPartials':
|
|
177
|
-
setAiPartial('');
|
|
178
|
-
setUserPartial('');
|
|
179
|
-
break;
|
|
180
|
-
case 'error':
|
|
181
|
-
setError(data.message);
|
|
182
|
-
setCallState('error');
|
|
183
|
-
break;
|
|
184
|
-
}
|
|
185
|
-
} catch {}
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
const sub = voiceSession.subscribe(() => force());
|
|
25
|
+
return () => sub.unsubscribe();
|
|
186
26
|
}, []);
|
|
187
27
|
|
|
188
|
-
const
|
|
189
|
-
setError(null);
|
|
190
|
-
setTranscript([]);
|
|
191
|
-
setAiPartial('');
|
|
192
|
-
setUserPartial('');
|
|
193
|
-
// Ensure the WebView is handed a FRESH token BEFORE it mounts (it never
|
|
194
|
-
// refreshes once mounted, and the WebView mounts as soon as callState
|
|
195
|
-
// leaves 'idle'). Only pays a round-trip when the token is expired or
|
|
196
|
-
// near-expiry; otherwise instant. Flip to 'connecting' only after, so
|
|
197
|
-
// getWebViewSource() reads the refreshed token.
|
|
198
|
-
try {
|
|
199
|
-
if (tokenNeedsRefresh(getToken())) {
|
|
200
|
-
await refreshToken();
|
|
201
|
-
}
|
|
202
|
-
} catch {
|
|
203
|
-
// Refresh failed — proceed with whatever token we have; voice-backend
|
|
204
|
-
// will surface a clean auth error rather than us blocking the call.
|
|
205
|
-
}
|
|
206
|
-
setCallState('connecting');
|
|
207
|
-
}, []);
|
|
208
|
-
|
|
209
|
-
const stopCall = useCallback(() => {
|
|
210
|
-
// Tell WebView to hang up
|
|
211
|
-
webViewRef.current?.injectJavaScript?.('window.hangUp && window.hangUp(); true;');
|
|
212
|
-
setCallState('idle');
|
|
213
|
-
setAiPartial('');
|
|
214
|
-
setUserPartial('');
|
|
215
|
-
setMuted(false);
|
|
216
|
-
}, []);
|
|
217
|
-
|
|
218
|
-
const toggleMute = useCallback(() => {
|
|
219
|
-
setMuted(prev => {
|
|
220
|
-
const next = !prev;
|
|
221
|
-
webViewRef.current?.injectJavaScript?.(buildMuteScript(next));
|
|
222
|
-
return next;
|
|
223
|
-
});
|
|
224
|
-
}, []);
|
|
28
|
+
const s = voiceSession.getInternal();
|
|
225
29
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
};
|
|
238
|
-
}, [skillSlug]);
|
|
30
|
+
// Opening a screen while a session is already live ATTACHES to it (start() is
|
|
31
|
+
// a no-op that resolves) — never a second Realtime loop (invariant 2).
|
|
32
|
+
const startCall = useCallback(
|
|
33
|
+
(opts?: { skillSlug?: string }) => voiceSession.start(opts ?? { skillSlug }),
|
|
34
|
+
[skillSlug],
|
|
35
|
+
);
|
|
36
|
+
const stopCall = useCallback(() => voiceSession.stop(), []);
|
|
37
|
+
const toggleMute = useCallback(
|
|
38
|
+
() => voiceSession.setMuted(!voiceSession.getSnapshot().muted),
|
|
39
|
+
[],
|
|
40
|
+
);
|
|
239
41
|
|
|
240
42
|
return {
|
|
241
|
-
callState
|
|
242
|
-
|
|
243
|
-
|
|
43
|
+
callState: s.callState,
|
|
44
|
+
sessionId: s.sessionId,
|
|
45
|
+
error: s.error,
|
|
46
|
+
transcript: s.transcript,
|
|
47
|
+
aiPartial: s.aiPartial,
|
|
48
|
+
userPartial: s.userPartial,
|
|
49
|
+
muted: s.muted,
|
|
50
|
+
startCall,
|
|
51
|
+
stopCall,
|
|
52
|
+
toggleMute,
|
|
244
53
|
};
|
|
245
54
|
}
|