@qafka/react-native 2.6.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/dist/QafkaSDK.js +54 -7
- package/dist/components/Qafka.js +31 -2
- package/dist/components/VoicePage.d.ts +4 -1
- package/dist/components/VoicePage.js +20 -10
- package/dist/decide-voice-close.d.ts +12 -0
- package/dist/decide-voice-close.js +26 -0
- package/dist/decide-voice-resume.d.ts +21 -0
- package/dist/decide-voice-resume.js +27 -0
- package/dist/hooks/useChatMessages.js +12 -5
- package/dist/hooks/useVoiceChat.d.ts +24 -1
- package/dist/hooks/useVoiceChat.js +59 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +3 -1
- package/dist/services/AttestationManager.js +10 -0
- package/dist/services/BackendService.js +19 -3
- package/dist/services/RealtimeService.d.ts +1 -0
- package/dist/services/RealtimeService.js +1 -0
- package/dist/services/featureNotAvailable.d.ts +66 -0
- package/dist/services/featureNotAvailable.js +109 -0
- package/docs/qafka-tool-authoring-guide.md +4 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,28 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.8.0] — 2026-10-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Unavailable state now also covers a project for which the mobile SDK is not enabled: the chat screen shows the same neutral, localized message as for a suspended project, `onUnavailable` is called and the status becomes `'unavailable'` (via `onStatusChange` and `QafkaSDK.getStatus()`). No error bubble is shown and `onError` is not called. A voice session rejected for the same reason ends the same way.
|
|
15
|
+
- New exported `FeatureNotAvailableError` (`code: 'FEATURE_NOT_AVAILABLE'`, with the `feature` name). SDK calls that fail because a feature is not enabled for the project reject with it, so you can tell this case apart from other failures. A feature other than the mobile SDK itself does not switch the SDK to the unavailable state — only that call fails.
|
|
16
|
+
- In development builds, the SDK logs a one-time console warning naming the feature that is not enabled. Production builds log nothing.
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- Voice could not be continued after returning to the voice page: when a voice session ended while the voice page stayed on screen (the assistant opened another screen and you came back, the app went to the background, or the connection dropped), the page stayed idle and only swiping to the text chat and back re-opened it. The voice page now shows "Tap to continue" (Turkish when `locale` starts with `tr`), and tapping the microphone button or the indicator re-opens the session. No public API changes.
|
|
21
|
+
|
|
22
|
+
## [2.7.0] — 2026-09-16
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Voice session ended by the server: when the backend ends a voice session because voice time is no longer available for the project, the SDK now closes voice cleanly, returns the user to the text chat and shows a neutral, localized message ("Voice chat is currently unavailable. You can keep chatting by text." / Turkish when `locale` starts with `tr`). Text chat keeps working. This applies both when voice is opened and during an ongoing call.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Voice sessions closed by the server mid-call are now distinguished from network drops and user-initiated disconnects, so an unexpected close no longer leaves the voice screen in a connecting or listening state. No public API changes.
|
|
31
|
+
|
|
10
32
|
## [2.6.0] — 2026-09-15
|
|
11
33
|
|
|
12
34
|
### Added
|
package/dist/QafkaSDK.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.getSDK = exports.QafkaSDK = void 0;
|
|
|
4
4
|
const storage_1 = require("./services/storage");
|
|
5
5
|
const BackendService_1 = require("./services/BackendService");
|
|
6
6
|
const projectUnavailable_1 = require("./services/projectUnavailable");
|
|
7
|
+
const featureNotAvailable_1 = require("./services/featureNotAvailable");
|
|
7
8
|
const AttestationManager_1 = require("./services/AttestationManager");
|
|
8
9
|
const ConversationManager_1 = require("./services/ConversationManager");
|
|
9
10
|
const NavigationHandler_1 = require("./services/NavigationHandler");
|
|
@@ -119,6 +120,21 @@ class QafkaSDK {
|
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
122
|
catch (error) {
|
|
123
|
+
// Any plan-gated feature warns the integrator once in __DEV__, but only
|
|
124
|
+
// mobile_sdk — the feature that gates the whole SDK — flips status to
|
|
125
|
+
// the neutral 'unavailable' UX. A different gated feature (e.g.
|
|
126
|
+
// card_templates) falls through to the normal error/dev-bypass handling
|
|
127
|
+
// below instead of taking down the rest of the SDK.
|
|
128
|
+
if (error instanceof featureNotAvailable_1.FeatureNotAvailableError) {
|
|
129
|
+
(0, featureNotAvailable_1.warnFeatureNotAvailable)(error.feature);
|
|
130
|
+
if ((0, featureNotAvailable_1.isMobileSdkDenied)(error)) {
|
|
131
|
+
this.status = 'unavailable';
|
|
132
|
+
if (config.onStatusChange) {
|
|
133
|
+
config.onStatusChange('unavailable');
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
122
138
|
// The unsupported-device fallback only applies on simulators/emulators.
|
|
123
139
|
// On real devices, attestation errors must propagate — even in __DEV__ —
|
|
124
140
|
// so that initialization cannot silently continue unverified.
|
|
@@ -132,16 +148,22 @@ class QafkaSDK {
|
|
|
132
148
|
}
|
|
133
149
|
}
|
|
134
150
|
// Decide the final status after the theme prefetch settles — a
|
|
135
|
-
// ProjectUnavailableError there means the
|
|
136
|
-
//
|
|
137
|
-
// rejects: the prefetch
|
|
138
|
-
// (see themePrefetchError
|
|
151
|
+
// ProjectUnavailableError / FeatureNotAvailableError there means the
|
|
152
|
+
// project is suspended or plan-gated, which takes priority over an
|
|
153
|
+
// otherwise-successful attestation. This never rejects: the prefetch
|
|
154
|
+
// promise already swallows its own rejection (see themePrefetchError
|
|
155
|
+
// above).
|
|
139
156
|
if (this.themePrefetchPromise) {
|
|
140
157
|
await this.themePrefetchPromise;
|
|
141
158
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
159
|
+
if (this.themePrefetchError instanceof featureNotAvailable_1.FeatureNotAvailableError) {
|
|
160
|
+
(0, featureNotAvailable_1.warnFeatureNotAvailable)(this.themePrefetchError.feature);
|
|
161
|
+
}
|
|
162
|
+
this.status =
|
|
163
|
+
this.themePrefetchError instanceof projectUnavailable_1.ProjectUnavailableError ||
|
|
164
|
+
(0, featureNotAvailable_1.isMobileSdkDenied)(this.themePrefetchError)
|
|
165
|
+
? 'unavailable'
|
|
166
|
+
: 'ready';
|
|
145
167
|
if (config.onStatusChange) {
|
|
146
168
|
config.onStatusChange(this.status);
|
|
147
169
|
}
|
|
@@ -249,6 +271,17 @@ class QafkaSDK {
|
|
|
249
271
|
return response;
|
|
250
272
|
}
|
|
251
273
|
catch (error) {
|
|
274
|
+
// Any plan-gated feature warns the integrator once in __DEV__, but
|
|
275
|
+
// only mobile_sdk flips the SDK to the neutral "unavailable" state a
|
|
276
|
+
// suspended project uses — a different gated feature still rejects
|
|
277
|
+
// this call with a typed FeatureNotAvailableError, but must not take
|
|
278
|
+
// down the rest of the SDK.
|
|
279
|
+
if (error instanceof featureNotAvailable_1.FeatureNotAvailableError) {
|
|
280
|
+
(0, featureNotAvailable_1.warnFeatureNotAvailable)(error.feature);
|
|
281
|
+
if ((0, featureNotAvailable_1.isMobileSdkDenied)(error)) {
|
|
282
|
+
this.markUnavailable();
|
|
283
|
+
}
|
|
284
|
+
}
|
|
252
285
|
throw error;
|
|
253
286
|
}
|
|
254
287
|
}
|
|
@@ -301,6 +334,14 @@ class QafkaSDK {
|
|
|
301
334
|
}
|
|
302
335
|
onComplete?.(response);
|
|
303
336
|
}, (_error) => {
|
|
337
|
+
// Matches the sendMessage branch above; the streaming transport
|
|
338
|
+
// reports this via callback, not a rejection.
|
|
339
|
+
if (_error instanceof featureNotAvailable_1.FeatureNotAvailableError) {
|
|
340
|
+
(0, featureNotAvailable_1.warnFeatureNotAvailable)(_error.feature);
|
|
341
|
+
if ((0, featureNotAvailable_1.isMobileSdkDenied)(_error)) {
|
|
342
|
+
this.markUnavailable();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
304
345
|
onError?.(_error);
|
|
305
346
|
}, context, contextDescription, onToolSuggestions, // Pass tool suggestions callback
|
|
306
347
|
isInitialMessage, onActionResult, // Pass action result callback
|
|
@@ -312,6 +353,12 @@ class QafkaSDK {
|
|
|
312
353
|
onFinalChunk);
|
|
313
354
|
}
|
|
314
355
|
catch (error) {
|
|
356
|
+
if (error instanceof featureNotAvailable_1.FeatureNotAvailableError) {
|
|
357
|
+
(0, featureNotAvailable_1.warnFeatureNotAvailable)(error.feature);
|
|
358
|
+
if ((0, featureNotAvailable_1.isMobileSdkDenied)(error)) {
|
|
359
|
+
this.markUnavailable();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
315
362
|
if (onError) {
|
|
316
363
|
onError(error instanceof Error ? error : new Error('Unknown error'));
|
|
317
364
|
}
|
package/dist/components/Qafka.js
CHANGED
|
@@ -135,6 +135,7 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
|
|
|
135
135
|
apiKey: resolvedApiKey || '',
|
|
136
136
|
userContext: currentContext,
|
|
137
137
|
contextDescription,
|
|
138
|
+
locale,
|
|
138
139
|
onToolSuggested,
|
|
139
140
|
toolRenderMode,
|
|
140
141
|
// Forward the device-attestation session token so the realtime WS
|
|
@@ -148,7 +149,7 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
|
|
|
148
149
|
endUserData: validatedEndUserData,
|
|
149
150
|
onNavigationSuggest,
|
|
150
151
|
});
|
|
151
|
-
const { toolStatus: voiceToolStatus, renderedTools: voiceRenderedTools, transcriptOverrideForTurn, setToolStatusManually, clearRenderedTools, connect: voiceConnect, disconnect: voiceDisconnect, pauseMic: voicePauseMic, resumeMic: voiceResumeMic, isMuted: voiceIsMuted, mute: voiceMute, unmute: voiceUnmute, toggleMute: voiceToggleMute, navigationSuggestion: voiceNavigationSuggestion, acceptNavigationSuggestion: voiceAcceptNavigation, dismissNavigationSuggestion: voiceDismissNavigation, } = voiceChat;
|
|
152
|
+
const { toolStatus: voiceToolStatus, renderedTools: voiceRenderedTools, transcriptOverrideForTurn, setToolStatusManually, clearRenderedTools, connect: voiceConnect, disconnect: voiceDisconnect, pauseMic: voicePauseMic, resumeMic: voiceResumeMic, isMuted: voiceIsMuted, mute: voiceMute, unmute: voiceUnmute, toggleMute: voiceToggleMute, navigationSuggestion: voiceNavigationSuggestion, acceptNavigationSuggestion: voiceAcceptNavigation, dismissNavigationSuggestion: voiceDismissNavigation, voiceCloseReason, voiceCloseMessage, canResume: voiceCanResume, resume: voiceResume, resumeLabel: voiceResumeLabel, } = voiceChat;
|
|
152
153
|
const { messages, isTyping, isSending, streamingMessage, toolStatus, flatListRef, handleSend, setMessages, setIsTyping, setStreamingMessage, } = (0, useChatMessages_1.useChatMessages)({
|
|
153
154
|
enableStreaming,
|
|
154
155
|
context: currentContext,
|
|
@@ -398,6 +399,34 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
|
|
|
398
399
|
voiceChat.pauseMic();
|
|
399
400
|
}
|
|
400
401
|
}, [voiceChat]);
|
|
402
|
+
// The server can end a voice session for a reason the user should be told
|
|
403
|
+
// about (currently only when it can no longer continue). useVoiceChat
|
|
404
|
+
// already tears the session down; here we mirror the user's own "swipe
|
|
405
|
+
// back to chat" gesture and drop its message into the transcript the same
|
|
406
|
+
// way a failed send does (see useChatMessages' fallback error bubble) —
|
|
407
|
+
// reusing the existing chat-bubble UI instead of adding a new surface.
|
|
408
|
+
const handledVoiceCloseRef = (0, react_1.useRef)(false);
|
|
409
|
+
(0, react_1.useEffect)(() => {
|
|
410
|
+
if (voiceCloseReason === 'minutes_exhausted') {
|
|
411
|
+
if (handledVoiceCloseRef.current)
|
|
412
|
+
return;
|
|
413
|
+
handledVoiceCloseRef.current = true;
|
|
414
|
+
scrollViewRef.current?.scrollTo({ x: 0, animated: true });
|
|
415
|
+
setMessages((prev) => [
|
|
416
|
+
...prev,
|
|
417
|
+
{
|
|
418
|
+
id: `voice_closed_${Date.now()}`,
|
|
419
|
+
role: 'assistant',
|
|
420
|
+
text: voiceCloseMessage ?? '',
|
|
421
|
+
content: voiceCloseMessage ?? '',
|
|
422
|
+
timestamp: new Date(),
|
|
423
|
+
},
|
|
424
|
+
]);
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
handledVoiceCloseRef.current = false;
|
|
428
|
+
}
|
|
429
|
+
}, [voiceCloseReason, voiceCloseMessage, setMessages]);
|
|
401
430
|
// Card CTA host callbacks bundle. Stable reference via useMemo
|
|
402
431
|
// so MessageBubble's CardRuntimeProvider doesn't re-mount on every render.
|
|
403
432
|
// MUST be declared before the conditional returns below — Rules of Hooks.
|
|
@@ -485,7 +514,7 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
|
|
|
485
514
|
|
|
486
515
|
{/* Page 1: Voice — VoicePage paints Background edge-to-edge and
|
|
487
516
|
handles its own internal safe-area padding for orb/transcript. */}
|
|
488
|
-
<VoicePage_1.VoicePage state={voiceChat.state} transcript={voiceChat.transcript} userTranscript={voiceChat.userTranscript} transcriptHistory={voiceChat.transcriptHistory} transcriptMode={voiceTranscript} amplitude={voiceChat.amplitude} theme={theme} voiceComponents={voiceComponents} toolStatus={voiceToolStatus} renderedTools={voiceRenderedTools} registeredComponents={customComponents} transcriptOverrideForTurn={transcriptOverrideForTurn} DataChipListComponent={voiceComponents?.dataChipList} isMuted={voiceIsMuted} onToggleMute={voiceToggleMute} navigationSuggestion={voiceNavigationSuggestion} onNavigationAccept={voiceAcceptNavigation} onNavigationDismiss={voiceDismissNavigation} NavigationButtonComponent={NavigationButtonComponent}/>
|
|
517
|
+
<VoicePage_1.VoicePage state={voiceChat.state} transcript={voiceChat.transcript} userTranscript={voiceChat.userTranscript} transcriptHistory={voiceChat.transcriptHistory} transcriptMode={voiceTranscript} amplitude={voiceChat.amplitude} theme={theme} voiceComponents={voiceComponents} toolStatus={voiceToolStatus} renderedTools={voiceRenderedTools} registeredComponents={customComponents} transcriptOverrideForTurn={transcriptOverrideForTurn} DataChipListComponent={voiceComponents?.dataChipList} isMuted={voiceIsMuted} onToggleMute={voiceToggleMute} canResume={voiceCanResume} onResume={voiceResume} resumeLabel={voiceResumeLabel} navigationSuggestion={voiceNavigationSuggestion} onNavigationAccept={voiceAcceptNavigation} onNavigationDismiss={voiceDismissNavigation} NavigationButtonComponent={NavigationButtonComponent}/>
|
|
489
518
|
</react_native_1.ScrollView>
|
|
490
519
|
</react_native_1.View>) : (<ChatPage_1.ChatPage {...chatPageProps}/>);
|
|
491
520
|
return (<react_native_1.View style={[
|
|
@@ -44,10 +44,13 @@ interface VoicePageProps {
|
|
|
44
44
|
}>;
|
|
45
45
|
isMuted?: boolean;
|
|
46
46
|
onToggleMute?: () => void;
|
|
47
|
+
canResume?: boolean;
|
|
48
|
+
onResume?: () => void;
|
|
49
|
+
resumeLabel?: string;
|
|
47
50
|
navigationSuggestion?: NavigationSuggestion | null;
|
|
48
51
|
onNavigationAccept?: () => void;
|
|
49
52
|
onNavigationDismiss?: () => void;
|
|
50
53
|
NavigationButtonComponent?: ComponentType<NavigationButtonProps>;
|
|
51
54
|
}
|
|
52
|
-
export declare function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode, amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn, DataChipListComponent, isMuted, onToggleMute, navigationSuggestion, onNavigationAccept, onNavigationDismiss, NavigationButtonComponent, }: VoicePageProps): React.JSX.Element;
|
|
55
|
+
export declare function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode, amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn, DataChipListComponent, isMuted, onToggleMute, canResume, onResume, resumeLabel, navigationSuggestion, onNavigationAccept, onNavigationDismiss, NavigationButtonComponent, }: VoicePageProps): React.JSX.Element;
|
|
53
56
|
export {};
|
|
@@ -408,7 +408,7 @@ function ChatTranscript({ history, liveAiText, state, primaryTextColor, secondar
|
|
|
408
408
|
</react_native_1.View>);
|
|
409
409
|
}
|
|
410
410
|
// --- Main component ---
|
|
411
|
-
function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode = 'centered', amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn = false, DataChipListComponent, isMuted = false, onToggleMute, navigationSuggestion, onNavigationAccept, onNavigationDismiss, NavigationButtonComponent, }) {
|
|
411
|
+
function VoicePage({ state, transcript, userTranscript, transcriptHistory, transcriptMode = 'centered', amplitude, theme, voiceComponents, toolStatus, renderedTools, registeredComponents, transcriptOverrideForTurn = false, DataChipListComponent, isMuted = false, onToggleMute, canResume = false, onResume, resumeLabel, navigationSuggestion, onNavigationAccept, onNavigationDismiss, NavigationButtonComponent, }) {
|
|
412
412
|
const Indicator = voiceComponents?.VoiceIndicator || DefaultVoiceIndicator;
|
|
413
413
|
const Background = voiceComponents?.VoiceBackground || DefaultVoiceBackground;
|
|
414
414
|
const Transcript = voiceComponents?.VoiceTranscript || DefaultVoiceTranscript;
|
|
@@ -416,6 +416,14 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
|
|
|
416
416
|
// Disable rendering the button entirely when there's nothing to drive it,
|
|
417
417
|
// e.g. consumer drives the SDK without using useVoiceChat's mute layer.
|
|
418
418
|
const showMute = typeof onToggleMute === 'function';
|
|
419
|
+
// The session ended while this page stayed on screen (see
|
|
420
|
+
// decide-voice-resume.ts): the mic button and the indicator re-open it
|
|
421
|
+
// instead of toggling mute.
|
|
422
|
+
const resumable = canResume && typeof onResume === 'function';
|
|
423
|
+
const onMicPress = resumable ? onResume : onToggleMute;
|
|
424
|
+
const resumeHint = resumable && resumeLabel ? (<react_native_1.Text style={{ marginTop: 12, fontSize: 14, color: STATE_COLORS.idle }}>
|
|
425
|
+
{resumeLabel}
|
|
426
|
+
</react_native_1.Text>) : null;
|
|
419
427
|
const hasToolUI = !transcriptOverrideForTurn && (renderedTools?.length ?? 0) > 0;
|
|
420
428
|
// When tool UI is rendered, the compact status bar takes over regardless of
|
|
421
429
|
// chosen mode — preserves the single-screen / hands-free experience.
|
|
@@ -476,19 +484,19 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
|
|
|
476
484
|
</react_native_1.View>
|
|
477
485
|
</react_native_1.View>
|
|
478
486
|
|
|
479
|
-
<react_native_1.
|
|
487
|
+
<react_native_1.Pressable style={{ flex: 1 }} disabled={!resumable} onPress={onResume}>
|
|
480
488
|
<react_native_1.Text style={{
|
|
481
489
|
fontSize: 13,
|
|
482
490
|
fontWeight: react_native_1.Platform.select({ ios: '600', default: 'bold' }),
|
|
483
491
|
color: STATE_COLORS[state],
|
|
484
492
|
}} numberOfLines={1}>
|
|
485
|
-
{STATE_LABELS[state]}
|
|
493
|
+
{resumable && resumeLabel ? resumeLabel : STATE_LABELS[state]}
|
|
486
494
|
</react_native_1.Text>
|
|
487
495
|
{toolStatus?.loadingMessage ? (<react_native_1.Text style={{ fontSize: 11, color: '#888', marginTop: 1 }} numberOfLines={1}>
|
|
488
496
|
{toolStatus.loadingMessage}
|
|
489
497
|
</react_native_1.Text>) : null}
|
|
490
|
-
</react_native_1.
|
|
491
|
-
{showMute ? (<MuteButton isMuted={isMuted} onToggle={
|
|
498
|
+
</react_native_1.Pressable>
|
|
499
|
+
{showMute ? (<MuteButton isMuted={isMuted} onToggle={onMicPress} theme={theme} state={state}/>) : null}
|
|
492
500
|
</react_native_1.View>) : isChatLayout ? (
|
|
493
501
|
// Chat layout: orb anchored at the top, chat history fills the rest.
|
|
494
502
|
// The orb moves up (vs. centered) to give the transcript room to breathe.
|
|
@@ -498,9 +506,10 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
|
|
|
498
506
|
width: '100%',
|
|
499
507
|
paddingTop: 32,
|
|
500
508
|
}}>
|
|
501
|
-
<react_native_1.
|
|
509
|
+
<react_native_1.Pressable disabled={!resumable} onPress={onResume} accessibilityRole={resumable ? 'button' : undefined} accessibilityLabel={resumable ? resumeLabel : undefined} style={{ alignItems: 'center', justifyContent: 'center' }}>
|
|
502
510
|
<Indicator state={state} amplitude={amplitude} theme={theme} isMuted={isMuted}/>
|
|
503
|
-
</react_native_1.
|
|
511
|
+
</react_native_1.Pressable>
|
|
512
|
+
{resumeHint}
|
|
504
513
|
|
|
505
514
|
{toolStatus ? (<react_native_1.View style={{ marginTop: 4, alignItems: 'center' }}>
|
|
506
515
|
<ToolStatusPill_1.ToolStatusPill message={toolStatus.loadingMessage} theme={theme}/>
|
|
@@ -517,9 +526,10 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
|
|
|
517
526
|
alignItems: 'center',
|
|
518
527
|
justifyContent: 'center',
|
|
519
528
|
}}>
|
|
520
|
-
<react_native_1.
|
|
529
|
+
<react_native_1.Pressable disabled={!resumable} onPress={onResume} accessibilityRole={resumable ? 'button' : undefined} accessibilityLabel={resumable ? resumeLabel : undefined} style={{ alignItems: 'center', justifyContent: 'center' }}>
|
|
521
530
|
<Indicator state={state} amplitude={amplitude} theme={theme} isMuted={isMuted}/>
|
|
522
|
-
</react_native_1.
|
|
531
|
+
</react_native_1.Pressable>
|
|
532
|
+
{resumeHint}
|
|
523
533
|
|
|
524
534
|
{toolStatus ? (<react_native_1.View style={{ marginTop: 12, alignItems: 'center' }}>
|
|
525
535
|
<ToolStatusPill_1.ToolStatusPill message={toolStatus.loadingMessage} theme={theme}/>
|
|
@@ -602,7 +612,7 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
|
|
|
602
612
|
top: insets.top + 12,
|
|
603
613
|
right: 16,
|
|
604
614
|
}}>
|
|
605
|
-
<MuteButton isMuted={isMuted} onToggle={
|
|
615
|
+
<MuteButton isMuted={isMuted} onToggle={onMicPress} theme={theme} state={state}/>
|
|
606
616
|
</react_native_1.View>) : null}
|
|
607
617
|
</react_native_1.View>
|
|
608
618
|
</Background>);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifies why a voice session's WebSocket closed, from the close code
|
|
3
|
+
* and/or reason string the server sends, into an outcome the caller can act
|
|
4
|
+
* on without needing to know the raw close-code values itself. Kept as a
|
|
5
|
+
* pure function so the mapping is unit-testable without loading the
|
|
6
|
+
* native-bound RealtimeService.
|
|
7
|
+
*/
|
|
8
|
+
export type VoiceCloseReason = 'minutes_exhausted' | 'not_allowed' | 'project_unavailable' | 'other';
|
|
9
|
+
export declare function classifyVoiceClose(event: {
|
|
10
|
+
code?: number;
|
|
11
|
+
reason?: string;
|
|
12
|
+
} | null | undefined): VoiceCloseReason;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.classifyVoiceClose = classifyVoiceClose;
|
|
4
|
+
const featureNotAvailable_1 = require("./services/featureNotAvailable");
|
|
5
|
+
function classifyVoiceClose(event) {
|
|
6
|
+
const code = event?.code;
|
|
7
|
+
const reason = event?.reason;
|
|
8
|
+
if (code === 4011 || reason === 'VOICE_MINUTES_EXHAUSTED') {
|
|
9
|
+
return 'minutes_exhausted';
|
|
10
|
+
}
|
|
11
|
+
if (code === 4003 || reason === 'VOICE_NOT_ALLOWED') {
|
|
12
|
+
return 'not_allowed';
|
|
13
|
+
}
|
|
14
|
+
// 4010 = project suspended; close code 4012 is shared by every
|
|
15
|
+
// feature-gated close (see featureNotAvailable.ts), so only the
|
|
16
|
+
// mobile_sdk-specific reason string maps here — mobile_sdk gates the
|
|
17
|
+
// whole SDK, so it gets the same neutral "assistant unavailable" UX as a
|
|
18
|
+
// suspended project. A close for a different gated feature (were voice
|
|
19
|
+
// ever gated on one) falls through to 'other' instead.
|
|
20
|
+
if (code === 4010 ||
|
|
21
|
+
reason === 'project_unavailable' ||
|
|
22
|
+
reason === featureNotAvailable_1.MOBILE_SDK_FEATURE_NOT_AVAILABLE_REASON) {
|
|
23
|
+
return 'project_unavailable';
|
|
24
|
+
}
|
|
25
|
+
return 'other';
|
|
26
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decides whether the voice page should offer to re-open the session.
|
|
3
|
+
*
|
|
4
|
+
* A session can end while the voice page stays on screen — the assistant
|
|
5
|
+
* navigated to a host screen and the user came back, the app went to the
|
|
6
|
+
* background, or the connection dropped. A session is otherwise only opened
|
|
7
|
+
* when the pager lands on the voice page, so without this offer the page
|
|
8
|
+
* would sit idle with no way back in short of swiping away and back.
|
|
9
|
+
*
|
|
10
|
+
* Not offered before the first session (the swipe opens it on its own), nor
|
|
11
|
+
* when the server ended the session because voice is unavailable — that case
|
|
12
|
+
* falls back to text chat instead. Kept as a pure function so it is
|
|
13
|
+
* unit-testable without loading the native-bound voice hook.
|
|
14
|
+
*/
|
|
15
|
+
export declare function canResumeVoice(input: {
|
|
16
|
+
state: string;
|
|
17
|
+
sessionEnded: boolean;
|
|
18
|
+
closeReason: string | null | undefined;
|
|
19
|
+
}): boolean;
|
|
20
|
+
/** Locale-aware label for the resume affordance. */
|
|
21
|
+
export declare function voiceResumeLabel(locale?: string): string;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.canResumeVoice = canResumeVoice;
|
|
4
|
+
exports.voiceResumeLabel = voiceResumeLabel;
|
|
5
|
+
/**
|
|
6
|
+
* Decides whether the voice page should offer to re-open the session.
|
|
7
|
+
*
|
|
8
|
+
* A session can end while the voice page stays on screen — the assistant
|
|
9
|
+
* navigated to a host screen and the user came back, the app went to the
|
|
10
|
+
* background, or the connection dropped. A session is otherwise only opened
|
|
11
|
+
* when the pager lands on the voice page, so without this offer the page
|
|
12
|
+
* would sit idle with no way back in short of swiping away and back.
|
|
13
|
+
*
|
|
14
|
+
* Not offered before the first session (the swipe opens it on its own), nor
|
|
15
|
+
* when the server ended the session because voice is unavailable — that case
|
|
16
|
+
* falls back to text chat instead. Kept as a pure function so it is
|
|
17
|
+
* unit-testable without loading the native-bound voice hook.
|
|
18
|
+
*/
|
|
19
|
+
function canResumeVoice(input) {
|
|
20
|
+
return input.state === 'idle' && input.sessionEnded && !input.closeReason;
|
|
21
|
+
}
|
|
22
|
+
/** Locale-aware label for the resume affordance. */
|
|
23
|
+
function voiceResumeLabel(locale) {
|
|
24
|
+
return locale?.toLowerCase().startsWith('tr')
|
|
25
|
+
? 'Devam etmek için dokunun'
|
|
26
|
+
: 'Tap to continue';
|
|
27
|
+
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.useChatMessages = void 0;
|
|
4
4
|
const react_1 = require("react");
|
|
5
5
|
const QafkaSDK_1 = require("../QafkaSDK");
|
|
6
|
+
const featureNotAvailable_1 = require("../services/featureNotAvailable");
|
|
6
7
|
const Qafka_utils_1 = require("../components/Qafka.utils");
|
|
7
8
|
const joinNarration_1 = require("../utils/joinNarration");
|
|
8
9
|
const extractionContinuation_1 = require("../utils/extractionContinuation");
|
|
@@ -534,11 +535,17 @@ const useChatMessages = ({ enableStreaming = true, context = {}, contextDescript
|
|
|
534
535
|
setIsSending(false);
|
|
535
536
|
setToolStatus(null);
|
|
536
537
|
finalChunkBufferRef.current = '';
|
|
537
|
-
// Project suspended
|
|
538
|
-
// treating this as a normal
|
|
539
|
-
//
|
|
540
|
-
//
|
|
541
|
-
|
|
538
|
+
// Project suspended, or the plan does not include mobile_sdk, mid-session:
|
|
539
|
+
// flip the SDK to 'unavailable' instead of treating this as a normal
|
|
540
|
+
// error. QafkaSDK.markUnavailable() notifies the same onStatusChange
|
|
541
|
+
// listener useSDK subscribes to, so `onUnavailable` fires there — no
|
|
542
|
+
// onError call and no error bubble here. The __DEV__ upgrade warning is
|
|
543
|
+
// already emitted by QafkaSDK before this hook sees the error, so it
|
|
544
|
+
// isn't repeated here. A FEATURE_NOT_AVAILABLE for a feature other than
|
|
545
|
+
// mobile_sdk (e.g. card_templates) is NOT this case — it falls through
|
|
546
|
+
// to the normal onError/error-bubble handling below, since it doesn't
|
|
547
|
+
// gate the whole SDK.
|
|
548
|
+
if (err?.code === 'PROJECT_UNAVAILABLE' || (0, featureNotAvailable_1.isMobileSdkDenied)(err)) {
|
|
542
549
|
QafkaSDK_1.QafkaSDK.getInstance().markUnavailable();
|
|
543
550
|
return;
|
|
544
551
|
}
|
|
@@ -64,6 +64,11 @@ interface UseVoiceChatOptions {
|
|
|
64
64
|
apiKey: string;
|
|
65
65
|
userContext?: Record<string, any>;
|
|
66
66
|
contextDescription?: string;
|
|
67
|
+
/**
|
|
68
|
+
* BCP 47 locale, forwarded to the neutral message shown when the server
|
|
69
|
+
* ends the voice session (e.g. `voiceCloseMessage` after minutes run out).
|
|
70
|
+
*/
|
|
71
|
+
locale?: string;
|
|
67
72
|
onToolSuggested?: VoiceOnToolSuggested;
|
|
68
73
|
toolRenderMode?: VoiceToolRenderMode;
|
|
69
74
|
/**
|
|
@@ -119,5 +124,23 @@ interface UseVoiceChatResult {
|
|
|
119
124
|
acceptNavigationSuggestion: () => void;
|
|
120
125
|
/** Dismiss the pending suggestion without navigating. */
|
|
121
126
|
dismissNavigationSuggestion: () => void;
|
|
127
|
+
/**
|
|
128
|
+
* Set when the server closed the voice session for a reason the user
|
|
129
|
+
* should be told about (currently only `'minutes_exhausted'`). Cleared on
|
|
130
|
+
* the next `connect()`. `null` in every other case, including a normal
|
|
131
|
+
* user-initiated `disconnect()`.
|
|
132
|
+
*/
|
|
133
|
+
voiceCloseReason: 'minutes_exhausted' | null;
|
|
134
|
+
/** Locale-aware copy to show alongside `voiceCloseReason`, or `null`. */
|
|
135
|
+
voiceCloseMessage: string | null;
|
|
136
|
+
/**
|
|
137
|
+
* True when the session has ended while the voice page may still be on
|
|
138
|
+
* screen, so the page should offer `resume` instead of sitting idle.
|
|
139
|
+
*/
|
|
140
|
+
canResume: boolean;
|
|
141
|
+
/** Re-opens the voice session after it ended. */
|
|
142
|
+
resume: () => Promise<void>;
|
|
143
|
+
/** Locale-aware label for the resume affordance. */
|
|
144
|
+
resumeLabel: string;
|
|
122
145
|
}
|
|
123
|
-
export declare function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolSuggested, toolRenderMode, getSessionToken, endUserId, endUserData, onNavigationSuggest, }: UseVoiceChatOptions): UseVoiceChatResult;
|
|
146
|
+
export declare function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale, onToolSuggested, toolRenderMode, getSessionToken, endUserId, endUserData, onNavigationSuggest, }: UseVoiceChatOptions): UseVoiceChatResult;
|
|
@@ -6,8 +6,30 @@ const react_native_1 = require("react-native");
|
|
|
6
6
|
const RealtimeService_1 = require("../services/RealtimeService");
|
|
7
7
|
const QafkaAudio_1 = require("../native/QafkaAudio");
|
|
8
8
|
const decide_voice_navigation_1 = require("../decide-voice-navigation");
|
|
9
|
-
|
|
9
|
+
const decide_voice_close_1 = require("../decide-voice-close");
|
|
10
|
+
const decide_voice_resume_1 = require("../decide-voice-resume");
|
|
11
|
+
/**
|
|
12
|
+
* Neutral, locale-aware copy shown when the server ends a voice session
|
|
13
|
+
* because it can no longer be continued. Mirrors `unavailableMessage()` in
|
|
14
|
+
* `services/projectUnavailable.ts` — deliberately generic, no mention of
|
|
15
|
+
* limits or plans.
|
|
16
|
+
*/
|
|
17
|
+
function voiceUnavailableMessage(locale) {
|
|
18
|
+
return locale?.toLowerCase().startsWith('tr')
|
|
19
|
+
? 'Sesli görüşme şu anda kullanılamıyor. Yazarak sohbete devam edebilirsiniz.'
|
|
20
|
+
: 'Voice chat is currently unavailable. You can keep chatting by text.';
|
|
21
|
+
}
|
|
22
|
+
function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale, onToolSuggested, toolRenderMode = 'upsert', getSessionToken, endUserId, endUserData, onNavigationSuggest, }) {
|
|
10
23
|
const [state, setState] = (0, react_1.useState)('idle');
|
|
24
|
+
const [voiceCloseReason, setVoiceCloseReason] = (0, react_1.useState)(null);
|
|
25
|
+
// True once a session has ended while this hook stays mounted. Drives the
|
|
26
|
+
// voice page's resume affordance (see decide-voice-resume.ts).
|
|
27
|
+
const [sessionEnded, setSessionEnded] = (0, react_1.useState)(false);
|
|
28
|
+
const [voiceCloseMessage, setVoiceCloseMessage] = (0, react_1.useState)(null);
|
|
29
|
+
const localeRef = (0, react_1.useRef)(locale);
|
|
30
|
+
(0, react_1.useEffect)(() => {
|
|
31
|
+
localeRef.current = locale;
|
|
32
|
+
}, [locale]);
|
|
11
33
|
const [transcript, setTranscript] = (0, react_1.useState)('');
|
|
12
34
|
const [userTranscript, setUserTranscript] = (0, react_1.useState)('');
|
|
13
35
|
const [transcriptHistory, setTranscriptHistory] = (0, react_1.useState)([]);
|
|
@@ -147,13 +169,29 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
|
|
|
147
169
|
setState('listening');
|
|
148
170
|
return;
|
|
149
171
|
}
|
|
150
|
-
case 'session.closed':
|
|
172
|
+
case 'session.closed': {
|
|
173
|
+
const closeReason = (0, decide_voice_close_1.classifyVoiceClose)({ code: event.code, reason: event.reason });
|
|
174
|
+
if (closeReason === 'minutes_exhausted') {
|
|
175
|
+
// Surface the neutral message and fall back to text chat. The server
|
|
176
|
+
// has already closed the socket, so this only tears down local state
|
|
177
|
+
// (native audio engine, refs, mute flags) via the same path a
|
|
178
|
+
// user-initiated disconnect takes.
|
|
179
|
+
setVoiceCloseReason('minutes_exhausted');
|
|
180
|
+
setVoiceCloseMessage(voiceUnavailableMessage(localeRef.current));
|
|
181
|
+
disconnectRef.current?.();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
setVoiceCloseReason(null);
|
|
185
|
+
setVoiceCloseMessage(null);
|
|
151
186
|
setState('idle');
|
|
187
|
+
setSessionEnded(true);
|
|
152
188
|
setNavigationSuggestion(null);
|
|
153
189
|
pendingNavDisconnectRef.current = false;
|
|
154
190
|
return;
|
|
191
|
+
}
|
|
155
192
|
case 'error':
|
|
156
193
|
setState('idle');
|
|
194
|
+
setSessionEnded(true);
|
|
157
195
|
return;
|
|
158
196
|
case 'tool_status':
|
|
159
197
|
setToolStatus({
|
|
@@ -392,6 +430,9 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
|
|
|
392
430
|
return;
|
|
393
431
|
}
|
|
394
432
|
setState('connecting');
|
|
433
|
+
setSessionEnded(false);
|
|
434
|
+
setVoiceCloseReason(null);
|
|
435
|
+
setVoiceCloseMessage(null);
|
|
395
436
|
const service = new RealtimeService_1.RealtimeService(apiUrl, apiKey);
|
|
396
437
|
if (getSessionToken)
|
|
397
438
|
service.setSessionTokenGetter(getSessionToken);
|
|
@@ -425,6 +466,7 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
|
|
|
425
466
|
}
|
|
426
467
|
catch {
|
|
427
468
|
setState('idle');
|
|
469
|
+
setSessionEnded(true);
|
|
428
470
|
serviceRef.current = null;
|
|
429
471
|
}
|
|
430
472
|
}, [apiUrl, apiKey, handleEvent, userContext, contextDescription, getSessionToken, endUserId, endUserData]);
|
|
@@ -434,6 +476,7 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
|
|
|
434
476
|
await serviceRef.current?.disconnect();
|
|
435
477
|
serviceRef.current = null;
|
|
436
478
|
setState('idle');
|
|
479
|
+
setSessionEnded(true);
|
|
437
480
|
setTranscript('');
|
|
438
481
|
setUserTranscript('');
|
|
439
482
|
setTranscriptHistory([]);
|
|
@@ -449,6 +492,15 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
|
|
|
449
492
|
(0, react_1.useEffect)(() => {
|
|
450
493
|
disconnectRef.current = disconnect;
|
|
451
494
|
}, [disconnect]);
|
|
495
|
+
// Re-opens the session from the voice page. A session that ended without
|
|
496
|
+
// going through disconnect() (connection error, unexpected close) leaves its
|
|
497
|
+
// service behind, which connect() would treat as still live — clear it first.
|
|
498
|
+
const resume = (0, react_1.useCallback)(async () => {
|
|
499
|
+
if (serviceRef.current && !serviceRef.current.isConnected) {
|
|
500
|
+
await disconnect();
|
|
501
|
+
}
|
|
502
|
+
await connect();
|
|
503
|
+
}, [connect, disconnect]);
|
|
452
504
|
const pauseMic = (0, react_1.useCallback)(async () => {
|
|
453
505
|
await serviceRef.current?.pauseMic();
|
|
454
506
|
}, []);
|
|
@@ -523,5 +575,10 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, onToolS
|
|
|
523
575
|
navigationSuggestion,
|
|
524
576
|
acceptNavigationSuggestion,
|
|
525
577
|
dismissNavigationSuggestion,
|
|
578
|
+
voiceCloseReason,
|
|
579
|
+
voiceCloseMessage,
|
|
580
|
+
canResume: (0, decide_voice_resume_1.canResumeVoice)({ state, sessionEnded, closeReason: voiceCloseReason }),
|
|
581
|
+
resume,
|
|
582
|
+
resumeLabel: (0, decide_voice_resume_1.voiceResumeLabel)(locale),
|
|
526
583
|
};
|
|
527
584
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* AI-powered conversational interface with navigation intelligence
|
|
4
4
|
*/
|
|
5
5
|
export { QafkaSDK, getSDK } from './QafkaSDK';
|
|
6
|
+
export { FeatureNotAvailableError } from './services/featureNotAvailable';
|
|
6
7
|
export declare const getVersion: () => string;
|
|
7
8
|
export declare const isVersionAtLeast: (minVersion: string) => boolean;
|
|
8
9
|
export * from './components';
|
package/dist/index.js
CHANGED
|
@@ -18,11 +18,13 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
18
18
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
19
19
|
};
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.isVersionAtLeast = exports.getVersion = exports.getSDK = exports.QafkaSDK = void 0;
|
|
21
|
+
exports.isVersionAtLeast = exports.getVersion = exports.FeatureNotAvailableError = exports.getSDK = exports.QafkaSDK = void 0;
|
|
22
22
|
// Main SDK export
|
|
23
23
|
var QafkaSDK_1 = require("./QafkaSDK");
|
|
24
24
|
Object.defineProperty(exports, "QafkaSDK", { enumerable: true, get: function () { return QafkaSDK_1.QafkaSDK; } });
|
|
25
25
|
Object.defineProperty(exports, "getSDK", { enumerable: true, get: function () { return QafkaSDK_1.getSDK; } });
|
|
26
|
+
var featureNotAvailable_1 = require("./services/featureNotAvailable");
|
|
27
|
+
Object.defineProperty(exports, "FeatureNotAvailableError", { enumerable: true, get: function () { return featureNotAvailable_1.FeatureNotAvailableError; } });
|
|
26
28
|
const package_json_1 = require("../package.json");
|
|
27
29
|
const getVersion = () => package_json_1.version;
|
|
28
30
|
exports.getVersion = getVersion;
|
|
@@ -38,6 +38,7 @@ const react_native_1 = require("react-native");
|
|
|
38
38
|
const storage_1 = require("./storage");
|
|
39
39
|
const NativeAttestation = __importStar(require("../native/QafkaAttestation"));
|
|
40
40
|
const QafkaAttestation_1 = require("../native/QafkaAttestation");
|
|
41
|
+
const featureNotAvailable_1 = require("./featureNotAvailable");
|
|
41
42
|
class AttestationManager {
|
|
42
43
|
config;
|
|
43
44
|
sessionToken = null;
|
|
@@ -273,6 +274,11 @@ class AttestationManager {
|
|
|
273
274
|
}
|
|
274
275
|
this.log(`[Qafka:init] challenge status=${response.status}`);
|
|
275
276
|
if (!response.ok) {
|
|
277
|
+
const errorData = await response.json().catch(() => ({}));
|
|
278
|
+
const unavailableFeature = (0, featureNotAvailable_1.getUnavailableFeature)(response.status, errorData);
|
|
279
|
+
if (unavailableFeature) {
|
|
280
|
+
throw new featureNotAvailable_1.FeatureNotAvailableError(unavailableFeature, errorData.message);
|
|
281
|
+
}
|
|
276
282
|
throw new Error(`Challenge request failed: ${response.status}`);
|
|
277
283
|
}
|
|
278
284
|
const data = await response.json();
|
|
@@ -300,6 +306,10 @@ class AttestationManager {
|
|
|
300
306
|
this.log(`[Qafka:init] attest status=${response.status}`);
|
|
301
307
|
if (!response.ok) {
|
|
302
308
|
const errorData = await response.json().catch(() => ({}));
|
|
309
|
+
const unavailableFeature = (0, featureNotAvailable_1.getUnavailableFeature)(response.status, errorData);
|
|
310
|
+
if (unavailableFeature) {
|
|
311
|
+
throw new featureNotAvailable_1.FeatureNotAvailableError(unavailableFeature, errorData.message);
|
|
312
|
+
}
|
|
303
313
|
throw new Error(`Attestation failed: ${response.status} — ${errorData.message || errorData.error || 'Unknown error'}`);
|
|
304
314
|
}
|
|
305
315
|
return response.json();
|
|
@@ -5,6 +5,7 @@ const react_native_1 = require("react-native");
|
|
|
5
5
|
const package_json_1 = require("../../package.json");
|
|
6
6
|
const QafkaAttestation_1 = require("../native/QafkaAttestation");
|
|
7
7
|
const projectUnavailable_1 = require("./projectUnavailable");
|
|
8
|
+
const featureNotAvailable_1 = require("./featureNotAvailable");
|
|
8
9
|
/**
|
|
9
10
|
* Backend API Service using native fetch
|
|
10
11
|
*/
|
|
@@ -282,6 +283,13 @@ class BackendService {
|
|
|
282
283
|
if ((0, projectUnavailable_1.isProjectUnavailable)(response.status, errorData)) {
|
|
283
284
|
throw new projectUnavailable_1.ProjectUnavailableError(errorMessage);
|
|
284
285
|
}
|
|
286
|
+
// Plan does not include this SDK channel (e.g. `mobile_sdk`) — a
|
|
287
|
+
// distinct signal from the other 403 branches below, callers branch on
|
|
288
|
+
// `FeatureNotAvailableError` / `code === 'FEATURE_NOT_AVAILABLE'`.
|
|
289
|
+
const unavailableFeature = (0, featureNotAvailable_1.getUnavailableFeature)(response.status, errorData);
|
|
290
|
+
if (unavailableFeature) {
|
|
291
|
+
throw new featureNotAvailable_1.FeatureNotAvailableError(unavailableFeature, errorMessage);
|
|
292
|
+
}
|
|
285
293
|
// 401: Unauthorized (Invalid/Missing API Key)
|
|
286
294
|
if (response.status === 401) {
|
|
287
295
|
if (errorCode === 'MISSING_API_KEY') {
|
|
@@ -432,9 +440,10 @@ class BackendService {
|
|
|
432
440
|
return this.handleResponse(response);
|
|
433
441
|
}
|
|
434
442
|
catch (error) {
|
|
435
|
-
// Preserve ProjectUnavailableError as-is —
|
|
436
|
-
// branch on `instanceof`, so re-wrapping
|
|
437
|
-
|
|
443
|
+
// Preserve ProjectUnavailableError / FeatureNotAvailableError as-is —
|
|
444
|
+
// callers (QafkaSDK.initialize) branch on `instanceof`, so re-wrapping
|
|
445
|
+
// here would lose that signal.
|
|
446
|
+
if (error instanceof projectUnavailable_1.ProjectUnavailableError || error instanceof featureNotAvailable_1.FeatureNotAvailableError) {
|
|
438
447
|
throw error;
|
|
439
448
|
}
|
|
440
449
|
throw new Error(error.message || 'Failed to fetch theme');
|
|
@@ -686,6 +695,13 @@ class BackendService {
|
|
|
686
695
|
onError(unavailableErr);
|
|
687
696
|
return;
|
|
688
697
|
}
|
|
698
|
+
const unavailableFeature = (0, featureNotAvailable_1.getUnavailableFeature)(xhr.status, parsedBody);
|
|
699
|
+
if (unavailableFeature) {
|
|
700
|
+
const featureErr = new featureNotAvailable_1.FeatureNotAvailableError(unavailableFeature, serverMessage);
|
|
701
|
+
featureErr.status = xhr.status;
|
|
702
|
+
onError(featureErr);
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
689
705
|
// In dev, a 401 with a test key almost always means the rotated dev
|
|
690
706
|
// key is stale. Surface an actionable hint instead of a bare HTTP error.
|
|
691
707
|
if (__DEV__ && xhr.status === 401 && this.apiKey?.startsWith('qafka_test_')) {
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the "feature not available" state: when the project's
|
|
3
|
+
* plan does not include a given SDK feature (e.g. `mobile_sdk`), the backend
|
|
4
|
+
* answers HTTP 403 `{ error: 'feature_not_available', feature: '<f>',
|
|
5
|
+
* message }` on every request for that channel, and closes a voice
|
|
6
|
+
* WebSocket with code 4012. The SDK maps this to the same neutral
|
|
7
|
+
* "assistant unavailable" UX as a suspended project (see
|
|
8
|
+
* `./projectUnavailable.ts`) — no error bubble — and, in `__DEV__` only
|
|
9
|
+
* (repo rule: no bare `console.*`), warns the developer once per feature
|
|
10
|
+
* with an actionable upgrade message.
|
|
11
|
+
*/
|
|
12
|
+
/** WebSocket close code the realtime/voice backend uses for this signal. */
|
|
13
|
+
export declare const FEATURE_NOT_AVAILABLE_CLOSE_CODE = 4012;
|
|
14
|
+
/**
|
|
15
|
+
* True when a response is the specific "feature not available" signal, as
|
|
16
|
+
* opposed to any other 403 (bundle id, platform, permission, project
|
|
17
|
+
* suspension, ...). Returns the gated feature name, or `'unknown'` when the
|
|
18
|
+
* body confirms the signal but omits the feature, or `null` when the
|
|
19
|
+
* response is not this signal at all.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getUnavailableFeature(status: number, body: unknown): string | null;
|
|
22
|
+
/**
|
|
23
|
+
* Thrown by the transport layer when a request fails because the project's
|
|
24
|
+
* plan does not include the requested feature. Callers branch on `code`,
|
|
25
|
+
* not `instanceof`, where they only have a plain error (e.g. across the
|
|
26
|
+
* streaming/XHR boundary) — mirroring `ProjectUnavailableError`.
|
|
27
|
+
*/
|
|
28
|
+
export declare class FeatureNotAvailableError extends Error {
|
|
29
|
+
code: string;
|
|
30
|
+
feature: string;
|
|
31
|
+
/** HTTP status of the response that produced this error, when known. */
|
|
32
|
+
status?: number;
|
|
33
|
+
constructor(feature: string, message?: string);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Developer-facing (not end-user-facing) message: names the gated feature
|
|
37
|
+
* and points at the upgrade path. Used both as the default
|
|
38
|
+
* `FeatureNotAvailableError` message and as the `__DEV__` console warning.
|
|
39
|
+
*/
|
|
40
|
+
export declare function featureNotAvailableMessage(feature: string): string;
|
|
41
|
+
/**
|
|
42
|
+
* Warns the developer, once per feature per app run, that a plan-gated SDK
|
|
43
|
+
* feature was rejected. Only runs in `__DEV__` — production builds never
|
|
44
|
+
* print this (repo rule: no bare `console.*`). The end user is unaffected;
|
|
45
|
+
* they see the neutral "unavailable" UX, not this message.
|
|
46
|
+
*/
|
|
47
|
+
export declare function warnFeatureNotAvailable(feature: string): void;
|
|
48
|
+
/** Test-only: clears the once-per-feature warning dedupe state. */
|
|
49
|
+
export declare function resetFeatureWarningsForTests(): void;
|
|
50
|
+
/**
|
|
51
|
+
* True when `err` is a `FeatureNotAvailableError` for the `mobile_sdk`
|
|
52
|
+
* feature specifically. Only `mobile_sdk` justifies flipping the whole SDK
|
|
53
|
+
* to the neutral 'unavailable' state (the same UX as a suspended project):
|
|
54
|
+
* it gates every request the SDK makes. Any other plan-gated feature (e.g.
|
|
55
|
+
* a card-template or tool-specific flag) still surfaces as a typed
|
|
56
|
+
* `FeatureNotAvailableError` to the caller, but must not take down the rest
|
|
57
|
+
* of the SDK — a project can be denied one feature and fine on everything
|
|
58
|
+
* else. Callers still call `warnFeatureNotAvailable(err.feature)` for every
|
|
59
|
+
* feature; only the unavailable flip is mobile_sdk-specific.
|
|
60
|
+
*/
|
|
61
|
+
export declare function isMobileSdkDenied(err: unknown): boolean;
|
|
62
|
+
/** WS close reason string the backend sends when a voice session is closed
|
|
63
|
+
* because the project's plan does not include `mobile_sdk`. Voice-only:
|
|
64
|
+
* used by `decide-voice-close.ts` to keep the 'unavailable' outcome scoped
|
|
65
|
+
* to this specific feature rather than any `feature_not_available` close. */
|
|
66
|
+
export declare const MOBILE_SDK_FEATURE_NOT_AVAILABLE_REASON = "FEATURE_NOT_AVAILABLE:mobile_sdk";
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared helpers for the "feature not available" state: when the project's
|
|
4
|
+
* plan does not include a given SDK feature (e.g. `mobile_sdk`), the backend
|
|
5
|
+
* answers HTTP 403 `{ error: 'feature_not_available', feature: '<f>',
|
|
6
|
+
* message }` on every request for that channel, and closes a voice
|
|
7
|
+
* WebSocket with code 4012. The SDK maps this to the same neutral
|
|
8
|
+
* "assistant unavailable" UX as a suspended project (see
|
|
9
|
+
* `./projectUnavailable.ts`) — no error bubble — and, in `__DEV__` only
|
|
10
|
+
* (repo rule: no bare `console.*`), warns the developer once per feature
|
|
11
|
+
* with an actionable upgrade message.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.MOBILE_SDK_FEATURE_NOT_AVAILABLE_REASON = exports.FeatureNotAvailableError = exports.FEATURE_NOT_AVAILABLE_CLOSE_CODE = void 0;
|
|
15
|
+
exports.getUnavailableFeature = getUnavailableFeature;
|
|
16
|
+
exports.featureNotAvailableMessage = featureNotAvailableMessage;
|
|
17
|
+
exports.warnFeatureNotAvailable = warnFeatureNotAvailable;
|
|
18
|
+
exports.resetFeatureWarningsForTests = resetFeatureWarningsForTests;
|
|
19
|
+
exports.isMobileSdkDenied = isMobileSdkDenied;
|
|
20
|
+
/** WebSocket close code the realtime/voice backend uses for this signal. */
|
|
21
|
+
exports.FEATURE_NOT_AVAILABLE_CLOSE_CODE = 4012;
|
|
22
|
+
/** Human-readable names for features callers may see in the 403 body. */
|
|
23
|
+
const FEATURE_DISPLAY_NAMES = {
|
|
24
|
+
mobile_sdk: 'Mobile SDK',
|
|
25
|
+
web_sdk: 'Web SDK',
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* True when a response is the specific "feature not available" signal, as
|
|
29
|
+
* opposed to any other 403 (bundle id, platform, permission, project
|
|
30
|
+
* suspension, ...). Returns the gated feature name, or `'unknown'` when the
|
|
31
|
+
* body confirms the signal but omits the feature, or `null` when the
|
|
32
|
+
* response is not this signal at all.
|
|
33
|
+
*/
|
|
34
|
+
function getUnavailableFeature(status, body) {
|
|
35
|
+
if (status !== 403)
|
|
36
|
+
return null;
|
|
37
|
+
if (!body || typeof body !== 'object')
|
|
38
|
+
return null;
|
|
39
|
+
if (body.error !== 'feature_not_available')
|
|
40
|
+
return null;
|
|
41
|
+
const feature = body.feature;
|
|
42
|
+
return typeof feature === 'string' && feature.length > 0 ? feature : 'unknown';
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Thrown by the transport layer when a request fails because the project's
|
|
46
|
+
* plan does not include the requested feature. Callers branch on `code`,
|
|
47
|
+
* not `instanceof`, where they only have a plain error (e.g. across the
|
|
48
|
+
* streaming/XHR boundary) — mirroring `ProjectUnavailableError`.
|
|
49
|
+
*/
|
|
50
|
+
class FeatureNotAvailableError extends Error {
|
|
51
|
+
code = 'FEATURE_NOT_AVAILABLE';
|
|
52
|
+
feature;
|
|
53
|
+
/** HTTP status of the response that produced this error, when known. */
|
|
54
|
+
status;
|
|
55
|
+
constructor(feature, message) {
|
|
56
|
+
super(message ?? featureNotAvailableMessage(feature));
|
|
57
|
+
this.name = 'FeatureNotAvailableError';
|
|
58
|
+
this.feature = feature;
|
|
59
|
+
Object.setPrototypeOf(this, FeatureNotAvailableError.prototype);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
exports.FeatureNotAvailableError = FeatureNotAvailableError;
|
|
63
|
+
/**
|
|
64
|
+
* Developer-facing (not end-user-facing) message: names the gated feature
|
|
65
|
+
* and points at the upgrade path. Used both as the default
|
|
66
|
+
* `FeatureNotAvailableError` message and as the `__DEV__` console warning.
|
|
67
|
+
*/
|
|
68
|
+
function featureNotAvailableMessage(feature) {
|
|
69
|
+
const displayName = FEATURE_DISPLAY_NAMES[feature] ?? feature;
|
|
70
|
+
return `[Qafka] ${displayName} is not available on the current plan. Upgrade the project's plan in the Dashboard to enable it.`;
|
|
71
|
+
}
|
|
72
|
+
const warnedFeatures = new Set();
|
|
73
|
+
/**
|
|
74
|
+
* Warns the developer, once per feature per app run, that a plan-gated SDK
|
|
75
|
+
* feature was rejected. Only runs in `__DEV__` — production builds never
|
|
76
|
+
* print this (repo rule: no bare `console.*`). The end user is unaffected;
|
|
77
|
+
* they see the neutral "unavailable" UX, not this message.
|
|
78
|
+
*/
|
|
79
|
+
function warnFeatureNotAvailable(feature) {
|
|
80
|
+
if (!__DEV__)
|
|
81
|
+
return;
|
|
82
|
+
if (warnedFeatures.has(feature))
|
|
83
|
+
return;
|
|
84
|
+
warnedFeatures.add(feature);
|
|
85
|
+
console.warn(featureNotAvailableMessage(feature));
|
|
86
|
+
}
|
|
87
|
+
/** Test-only: clears the once-per-feature warning dedupe state. */
|
|
88
|
+
function resetFeatureWarningsForTests() {
|
|
89
|
+
warnedFeatures.clear();
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* True when `err` is a `FeatureNotAvailableError` for the `mobile_sdk`
|
|
93
|
+
* feature specifically. Only `mobile_sdk` justifies flipping the whole SDK
|
|
94
|
+
* to the neutral 'unavailable' state (the same UX as a suspended project):
|
|
95
|
+
* it gates every request the SDK makes. Any other plan-gated feature (e.g.
|
|
96
|
+
* a card-template or tool-specific flag) still surfaces as a typed
|
|
97
|
+
* `FeatureNotAvailableError` to the caller, but must not take down the rest
|
|
98
|
+
* of the SDK — a project can be denied one feature and fine on everything
|
|
99
|
+
* else. Callers still call `warnFeatureNotAvailable(err.feature)` for every
|
|
100
|
+
* feature; only the unavailable flip is mobile_sdk-specific.
|
|
101
|
+
*/
|
|
102
|
+
function isMobileSdkDenied(err) {
|
|
103
|
+
return err instanceof FeatureNotAvailableError && err.feature === 'mobile_sdk';
|
|
104
|
+
}
|
|
105
|
+
/** WS close reason string the backend sends when a voice session is closed
|
|
106
|
+
* because the project's plan does not include `mobile_sdk`. Voice-only:
|
|
107
|
+
* used by `decide-voice-close.ts` to keep the 'unavailable' outcome scoped
|
|
108
|
+
* to this specific feature rather than any `feature_not_available` close. */
|
|
109
|
+
exports.MOBILE_SDK_FEATURE_NOT_AVAILABLE_REASON = 'FEATURE_NOT_AVAILABLE:mobile_sdk';
|
|
@@ -288,9 +288,10 @@ You may optionally restyle the chips via `voiceComponents.dataChipList` / `voice
|
|
|
288
288
|
the inverse of `context`.
|
|
289
289
|
- **`subProjectId`** — multi-tenant routing.
|
|
290
290
|
- **`locale`** — BCP-47 locale.
|
|
291
|
-
- **`onUnavailable`** — fires when the project becomes unavailable (suspended
|
|
292
|
-
screen stays mounted and shows a neutral message in
|
|
293
|
-
treated as an error (`onError` is not also called).
|
|
291
|
+
- **`onUnavailable`** — fires when the project becomes unavailable (suspended, or the mobile
|
|
292
|
+
SDK is not enabled for it). The chat screen stays mounted and shows a neutral message in
|
|
293
|
+
place of the normal chat surface; not treated as an error (`onError` is not also called).
|
|
294
|
+
Use it to hide your own entry point —
|
|
294
295
|
e.g. a launcher button or nav item — so users can't open a chat screen with nothing to say:
|
|
295
296
|
```tsx
|
|
296
297
|
const [chatAvailable, setChatAvailable] = useState(true);
|
package/package.json
CHANGED