@ateam-ai-mobile/ui 0.1.3 → 0.1.4
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 +7 -3
- package/dist/index.d.ts +7 -3
- package/dist/index.js +28 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +29 -8
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
- package/src/voice/VoiceCallSheet.tsx +4 -2
- package/src/voice/useVoiceCall.ts +51 -6
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ateam-ai-mobile/ui",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Prebuilt ADAS chat experience for React Native
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Prebuilt ADAS chat experience for React Native — full-screen chat, chips, widget views. Built on @ateam-ai-mobile/core + @ateam-ai-mobile/plugin-runtime.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
7
|
"scripts": {
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"build": "tsup"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@ateam-ai-mobile/core": "0.1.
|
|
13
|
-
"@ateam-ai-mobile/plugin-runtime": "0.1.
|
|
12
|
+
"@ateam-ai-mobile/core": "^0.1.4",
|
|
13
|
+
"@ateam-ai-mobile/plugin-runtime": "^0.1.4",
|
|
14
14
|
"zustand": "^5.0.0"
|
|
15
15
|
},
|
|
16
16
|
"peerDependencies": {
|
|
@@ -31,4 +31,4 @@
|
|
|
31
31
|
"publishConfig": {
|
|
32
32
|
"access": "public"
|
|
33
33
|
}
|
|
34
|
-
}
|
|
34
|
+
}
|
|
@@ -23,6 +23,8 @@ import useVoiceCall from './useVoiceCall';
|
|
|
23
23
|
interface Props {
|
|
24
24
|
visible: boolean;
|
|
25
25
|
onClose: () => void;
|
|
26
|
+
/** Optional skill to hand the mic to on connect (voice /session → takeMic). */
|
|
27
|
+
skillSlug?: string;
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
const fontFam = Platform.OS === 'ios' ? 'SF Pro Rounded' : undefined;
|
|
@@ -88,12 +90,12 @@ function HighlightedText({
|
|
|
88
90
|
);
|
|
89
91
|
}
|
|
90
92
|
|
|
91
|
-
export default function VoiceCallSheet({ visible, onClose }: Props) {
|
|
93
|
+
export default function VoiceCallSheet({ visible, onClose, skillSlug }: Props) {
|
|
92
94
|
const {
|
|
93
95
|
callState, aiPartial, userPartial, transcript, error, muted,
|
|
94
96
|
startCall, stopCall, toggleMute, handleWebViewMessage, getWebViewSource, webViewRef,
|
|
95
97
|
injectedTrackHook,
|
|
96
|
-
} = useVoiceCall();
|
|
98
|
+
} = useVoiceCall({ skillSlug });
|
|
97
99
|
|
|
98
100
|
const scrollRef = useRef<ScrollView>(null);
|
|
99
101
|
|
|
@@ -7,7 +7,36 @@
|
|
|
7
7
|
* without it. Identity (token/tenant/actor) comes from the host-owned config.
|
|
8
8
|
*/
|
|
9
9
|
import { useState, useRef, useCallback } from 'react';
|
|
10
|
-
import { getVoiceApiUrl, getToken, getTenant, getUser } from '@ateam-ai-mobile/core';
|
|
10
|
+
import { getVoiceApiUrl, getToken, getTenant, getUser, refreshToken } from '@ateam-ai-mobile/core';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The voice WebView bakes the JWT into its URL once at mount and never
|
|
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).
|
|
21
|
+
*/
|
|
22
|
+
const TOKEN_REFRESH_SKEW_MS = 90_000; // refresh if <90s of life left (or expired)
|
|
23
|
+
function tokenNeedsRefresh(token: string | null): boolean {
|
|
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
|
+
}
|
|
11
40
|
|
|
12
41
|
export interface TranscriptEntry {
|
|
13
42
|
role: 'user' | 'ai' | 'tool';
|
|
@@ -113,7 +142,7 @@ const buildMuteScript = (muted: boolean) => `
|
|
|
113
142
|
})(${muted}); true;
|
|
114
143
|
`;
|
|
115
144
|
|
|
116
|
-
export default function useVoiceCall() {
|
|
145
|
+
export default function useVoiceCall({ skillSlug }: { skillSlug?: string } = {}) {
|
|
117
146
|
const [callState, setCallState] = useState<CallState>('idle');
|
|
118
147
|
const [sessionId, setSessionId] = useState<string | null>(null);
|
|
119
148
|
const [error, setError] = useState<string | null>(null);
|
|
@@ -156,12 +185,25 @@ export default function useVoiceCall() {
|
|
|
156
185
|
} catch {}
|
|
157
186
|
}, []);
|
|
158
187
|
|
|
159
|
-
const startCall = useCallback(() => {
|
|
160
|
-
setCallState('connecting');
|
|
188
|
+
const startCall = useCallback(async () => {
|
|
161
189
|
setError(null);
|
|
162
190
|
setTranscript([]);
|
|
163
191
|
setAiPartial('');
|
|
164
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');
|
|
165
207
|
}, []);
|
|
166
208
|
|
|
167
209
|
const stopCall = useCallback(() => {
|
|
@@ -187,10 +229,13 @@ export default function useVoiceCall() {
|
|
|
187
229
|
const token = getToken();
|
|
188
230
|
const tenant = getTenant();
|
|
189
231
|
const actorId = getUser()?.actorId || '';
|
|
232
|
+
// skillSlug (when opened for a specific skill) is read by the voice mini-app
|
|
233
|
+
// and posted to /session, which turns it into takeMic (E1 held-mic entry).
|
|
234
|
+
const skillParam = skillSlug ? `&skillSlug=${encodeURIComponent(skillSlug)}` : '';
|
|
190
235
|
return {
|
|
191
|
-
uri: `${getVoiceApiUrl()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || '')}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}`,
|
|
236
|
+
uri: `${getVoiceApiUrl()}/app?mode=mini&embedded=rn&authToken=${encodeURIComponent(token || '')}&tenant=${encodeURIComponent(tenant)}&actorId=${encodeURIComponent(actorId)}${skillParam}`,
|
|
192
237
|
};
|
|
193
|
-
}, []);
|
|
238
|
+
}, [skillSlug]);
|
|
194
239
|
|
|
195
240
|
return {
|
|
196
241
|
callState, sessionId, error, transcript, aiPartial, userPartial, muted,
|