@qafka/react-native 2.7.0 → 2.8.1
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 +20 -0
- package/dist/QafkaSDK.js +54 -7
- package/dist/components/Qafka.js +10 -3
- package/dist/components/Qafka.types.d.ts +19 -0
- package/dist/components/QafkaProvider.d.ts +1 -1
- package/dist/components/VoicePage.d.ts +5 -1
- package/dist/components/VoicePage.js +47 -10
- package/dist/components/index.d.ts +1 -1
- package/dist/decide-voice-close.js +10 -1
- 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 +9 -0
- package/dist/hooks/useVoiceChat.js +21 -0
- 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/featureNotAvailable.d.ts +66 -0
- package/dist/services/featureNotAvailable.js +109 -0
- package/docs/qafka-tool-authoring-guide.md +8 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.8.1] — 2026-10-06
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Close button on the voice page, in its top left corner. It only leaves voice: the voice session is ended and the widget returns to the chat page, so users no longer have to swipe back. The chat stays open and `onClose` is not called — closing the assistant is still done from the chat page.
|
|
15
|
+
- New optional `onVoiceClose` prop on `<Qafka />`, called when that button is pressed.
|
|
16
|
+
- New optional `voiceComponents.VoiceCloseButton` slot (`VoiceCloseButtonProps`) to replace the default button.
|
|
17
|
+
|
|
18
|
+
## [2.8.0] — 2026-10-06
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- 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.
|
|
23
|
+
- 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.
|
|
24
|
+
- In development builds, the SDK logs a one-time console warning naming the feature that is not enabled. Production builds log nothing.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- 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.
|
|
29
|
+
|
|
10
30
|
## [2.7.0] — 2026-09-16
|
|
11
31
|
|
|
12
32
|
### 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
|
@@ -87,7 +87,7 @@ const { width: SCREEN_WIDTH } = react_native_1.Dimensions.get('window');
|
|
|
87
87
|
* />
|
|
88
88
|
* ```
|
|
89
89
|
*/
|
|
90
|
-
exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlProp, devConfig, subProjectId, projectId, locale, mode = 'fullscreen', theme: themeName = 'light', customTheme, themeOverride, isAuthenticated, endUserId, endUserData, enableStreaming = true, voiceEnabled: voiceEnabledProp = true, context: userContext, contextDescription, components: customComponents, showTimestamps = true, placeholder = 'Type a message...', maxMessageLength = 500, greetingMessage, onReady, onMessageSent, onResponseReceived, onError, onUnavailable, onNavigationSuggest, onNavigationAction, onExternalSuggestion, onCardDeepLink, onCardSuggestMessage, onCardExternalNavigation, onCardShare, onCardCopy, onCardToolTrigger, onCardCTAClick, onToolSuggested, onToolDataRequested, onActionResult, onStepCompleted, onFileUploadRequest, onExtractionResult, onClose, onBack, CloseComponent, BackComponent, navigationLabelFormat, NavigationButtonComponent, voiceComponents, voiceTranscript = 'centered', toolRenderMode, }, ref) {
|
|
90
|
+
exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlProp, devConfig, subProjectId, projectId, locale, mode = 'fullscreen', theme: themeName = 'light', customTheme, themeOverride, isAuthenticated, endUserId, endUserData, enableStreaming = true, voiceEnabled: voiceEnabledProp = true, context: userContext, contextDescription, components: customComponents, showTimestamps = true, placeholder = 'Type a message...', maxMessageLength = 500, greetingMessage, onReady, onMessageSent, onResponseReceived, onError, onUnavailable, onNavigationSuggest, onNavigationAction, onExternalSuggestion, onCardDeepLink, onCardSuggestMessage, onCardExternalNavigation, onCardShare, onCardCopy, onCardToolTrigger, onCardCTAClick, onToolSuggested, onToolDataRequested, onActionResult, onStepCompleted, onFileUploadRequest, onExtractionResult, onClose, onVoiceClose, onBack, CloseComponent, BackComponent, navigationLabelFormat, NavigationButtonComponent, voiceComponents, voiceTranscript = 'centered', toolRenderMode, }, ref) {
|
|
91
91
|
// === ALL HOOKS FIRST (Rules of Hooks compliance) ===
|
|
92
92
|
const insets = (0, react_native_safe_area_context_1.useSafeAreaInsets)();
|
|
93
93
|
const { sdkReady, unavailable, error: sdkError, resolvedApiKey, resolvedApiUrl } = (0, useSDK_1.useSDK)({
|
|
@@ -149,7 +149,7 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
|
|
|
149
149
|
endUserData: validatedEndUserData,
|
|
150
150
|
onNavigationSuggest,
|
|
151
151
|
});
|
|
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, } = 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;
|
|
153
153
|
const { messages, isTyping, isSending, streamingMessage, toolStatus, flatListRef, handleSend, setMessages, setIsTyping, setStreamingMessage, } = (0, useChatMessages_1.useChatMessages)({
|
|
154
154
|
enableStreaming,
|
|
155
155
|
context: currentContext,
|
|
@@ -514,7 +514,14 @@ exports.Qafka = (0, react_1.forwardRef)(function Qafka({ style, apiUrl: apiUrlPr
|
|
|
514
514
|
|
|
515
515
|
{/* Page 1: Voice — VoicePage paints Background edge-to-edge and
|
|
516
516
|
handles its own internal safe-area padding for orb/transcript. */}
|
|
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} 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}
|
|
518
|
+
// The voice page's close button only leaves voice: end the session
|
|
519
|
+
// and slide back to the chat page, which stays open.
|
|
520
|
+
onClose={() => {
|
|
521
|
+
void voiceDisconnect();
|
|
522
|
+
scrollViewRef.current?.scrollTo({ x: 0, animated: true });
|
|
523
|
+
onVoiceClose?.();
|
|
524
|
+
}}/>
|
|
518
525
|
</react_native_1.ScrollView>
|
|
519
526
|
</react_native_1.View>) : (<ChatPage_1.ChatPage {...chatPageProps}/>);
|
|
520
527
|
return (<react_native_1.View style={[
|
|
@@ -64,6 +64,14 @@ export interface VoiceMuteButtonProps {
|
|
|
64
64
|
theme: Theme;
|
|
65
65
|
state: VoiceChatState;
|
|
66
66
|
}
|
|
67
|
+
/**
|
|
68
|
+
* Props passed to custom voice close button component
|
|
69
|
+
*/
|
|
70
|
+
export interface VoiceCloseButtonProps {
|
|
71
|
+
onClose: () => void;
|
|
72
|
+
theme: Theme;
|
|
73
|
+
state: VoiceChatState;
|
|
74
|
+
}
|
|
67
75
|
/**
|
|
68
76
|
* Props passed to custom voice background component
|
|
69
77
|
*/
|
|
@@ -125,6 +133,11 @@ export interface VoiceComponents {
|
|
|
125
133
|
* row in every voice layout. Defaults to a circular icon button.
|
|
126
134
|
*/
|
|
127
135
|
VoiceMuteButton?: React.ComponentType<VoiceMuteButtonProps>;
|
|
136
|
+
/**
|
|
137
|
+
* Slot for the button that leaves the voice page and returns to the chat
|
|
138
|
+
* page, rendered in the top left corner. Defaults to a circular icon button.
|
|
139
|
+
*/
|
|
140
|
+
VoiceCloseButton?: React.ComponentType<VoiceCloseButtonProps>;
|
|
128
141
|
/**
|
|
129
142
|
* Slot for the multi-chip list rendered when the AI calls the built-in
|
|
130
143
|
* `qafka_display` tool with mode="chip".
|
|
@@ -689,6 +702,12 @@ export interface QafkaProps {
|
|
|
689
702
|
*/
|
|
690
703
|
onClose?: () => void;
|
|
691
704
|
/**
|
|
705
|
+
* Optional. Called when the close button on the voice page is pressed. That
|
|
706
|
+
* button only leaves voice: the voice session is ended and the widget
|
|
707
|
+
* returns to the chat page, which stays open — `onClose` is not called.
|
|
708
|
+
*/
|
|
709
|
+
onVoiceClose?: () => void;
|
|
710
|
+
/**
|
|
692
711
|
* Callback when back button is pressed
|
|
693
712
|
* If provided, a back button will be shown in the top left corner
|
|
694
713
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import React from 'react';
|
|
2
2
|
import { QafkaProps } from './Qafka.types';
|
|
3
|
-
type QafkaProviderProps = Pick<QafkaProps, 'style' | 'projectId' | 'apiUrl' | 'theme' | 'themeOverride' | 'customTheme' | 'enableStreaming' | 'isAuthenticated' | 'endUserId' | 'endUserData' | 'context' | 'contextDescription' | 'components' | 'showTimestamps' | 'placeholder' | 'maxMessageLength' | 'greetingMessage' | 'title' | 'showHeader' | 'onReady' | 'onMessageSent' | 'onResponseReceived' | 'onError' | 'onNavigationSuggest' | 'onNavigationAction' | 'onToolSuggested' | 'onActionResult' | 'onStepCompleted' | 'onClose' | 'onBack' | 'CloseComponent' | 'BackComponent' | 'navigationLabelFormat' | 'NavigationButtonComponent'> & {
|
|
3
|
+
type QafkaProviderProps = Pick<QafkaProps, 'style' | 'projectId' | 'apiUrl' | 'theme' | 'themeOverride' | 'customTheme' | 'enableStreaming' | 'isAuthenticated' | 'endUserId' | 'endUserData' | 'context' | 'contextDescription' | 'components' | 'showTimestamps' | 'placeholder' | 'maxMessageLength' | 'greetingMessage' | 'title' | 'showHeader' | 'onReady' | 'onMessageSent' | 'onResponseReceived' | 'onError' | 'onNavigationSuggest' | 'onNavigationAction' | 'onToolSuggested' | 'onActionResult' | 'onStepCompleted' | 'onClose' | 'onVoiceClose' | 'onBack' | 'CloseComponent' | 'BackComponent' | 'navigationLabelFormat' | 'NavigationButtonComponent'> & {
|
|
4
4
|
mode?: 'floating' | 'fullscreen' | 'inline';
|
|
5
5
|
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
6
6
|
};
|
|
@@ -44,10 +44,14 @@ 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>;
|
|
54
|
+
onClose?: () => void;
|
|
51
55
|
}
|
|
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;
|
|
56
|
+
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, onClose, }: VoicePageProps): React.JSX.Element;
|
|
53
57
|
export {};
|
|
@@ -306,6 +306,19 @@ function DefaultVoiceMuteButton({ isMuted, onToggle, theme, }) {
|
|
|
306
306
|
{isMuted ? <react_native_1.View style={[muteStyles.slash, { backgroundColor: fg }]}/> : null}
|
|
307
307
|
</react_native_1.Pressable>);
|
|
308
308
|
}
|
|
309
|
+
function DefaultVoiceCloseButton({ onClose, theme }) {
|
|
310
|
+
return (<react_native_1.Pressable onPress={onClose} hitSlop={8} accessibilityRole="button" accessibilityLabel="End voice chat" style={({ pressed }) => [
|
|
311
|
+
muteStyles.button,
|
|
312
|
+
{
|
|
313
|
+
backgroundColor: theme?.colors?.surface ?? 'rgba(0,0,0,0.06)',
|
|
314
|
+
opacity: pressed ? 0.75 : 1,
|
|
315
|
+
},
|
|
316
|
+
]}>
|
|
317
|
+
<react_native_1.Text style={{ fontSize: 24, lineHeight: 26, color: theme?.colors?.text ?? '#111' }}>
|
|
318
|
+
{'×'}
|
|
319
|
+
</react_native_1.Text>
|
|
320
|
+
</react_native_1.Pressable>);
|
|
321
|
+
}
|
|
309
322
|
/**
|
|
310
323
|
* Minimal microphone glyph built with stock Views — capsule body, stand
|
|
311
324
|
* post, and base bar. No external icon font, no SVG, no Skia.
|
|
@@ -408,7 +421,7 @@ function ChatTranscript({ history, liveAiText, state, primaryTextColor, secondar
|
|
|
408
421
|
</react_native_1.View>);
|
|
409
422
|
}
|
|
410
423
|
// --- 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, }) {
|
|
424
|
+
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, onClose, }) {
|
|
412
425
|
const Indicator = voiceComponents?.VoiceIndicator || DefaultVoiceIndicator;
|
|
413
426
|
const Background = voiceComponents?.VoiceBackground || DefaultVoiceBackground;
|
|
414
427
|
const Transcript = voiceComponents?.VoiceTranscript || DefaultVoiceTranscript;
|
|
@@ -416,6 +429,19 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
|
|
|
416
429
|
// Disable rendering the button entirely when there's nothing to drive it,
|
|
417
430
|
// e.g. consumer drives the SDK without using useVoiceChat's mute layer.
|
|
418
431
|
const showMute = typeof onToggleMute === 'function';
|
|
432
|
+
// The session ended while this page stayed on screen (see
|
|
433
|
+
// decide-voice-resume.ts): the mic button and the indicator re-open it
|
|
434
|
+
// instead of toggling mute.
|
|
435
|
+
const resumable = canResume && typeof onResume === 'function';
|
|
436
|
+
const onMicPress = resumable ? onResume : onToggleMute;
|
|
437
|
+
const resumeHint = resumable && resumeLabel ? (<react_native_1.Text style={{ marginTop: 12, fontSize: 14, color: STATE_COLORS.idle }}>
|
|
438
|
+
{resumeLabel}
|
|
439
|
+
</react_native_1.Text>) : null;
|
|
440
|
+
// Leaves voice and returns to the chat page, so that does not require
|
|
441
|
+
// swiping back. Top left: the mic button owns the top right corner.
|
|
442
|
+
const CloseButton = voiceComponents?.VoiceCloseButton || DefaultVoiceCloseButton;
|
|
443
|
+
const showClose = typeof onClose === 'function';
|
|
444
|
+
const closeButton = showClose ? (<CloseButton onClose={onClose} theme={theme} state={state}/>) : null;
|
|
419
445
|
const hasToolUI = !transcriptOverrideForTurn && (renderedTools?.length ?? 0) > 0;
|
|
420
446
|
// When tool UI is rendered, the compact status bar takes over regardless of
|
|
421
447
|
// chosen mode — preserves the single-screen / hands-free experience.
|
|
@@ -455,6 +481,7 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
|
|
|
455
481
|
borderBottomColor: 'rgba(0,0,0,0.08)',
|
|
456
482
|
backgroundColor: theme?.colors?.background ?? '#fff',
|
|
457
483
|
}}>
|
|
484
|
+
{closeButton}
|
|
458
485
|
<react_native_1.View style={{
|
|
459
486
|
width: 44,
|
|
460
487
|
height: 44,
|
|
@@ -476,19 +503,19 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
|
|
|
476
503
|
</react_native_1.View>
|
|
477
504
|
</react_native_1.View>
|
|
478
505
|
|
|
479
|
-
<react_native_1.
|
|
506
|
+
<react_native_1.Pressable style={{ flex: 1 }} disabled={!resumable} onPress={onResume}>
|
|
480
507
|
<react_native_1.Text style={{
|
|
481
508
|
fontSize: 13,
|
|
482
509
|
fontWeight: react_native_1.Platform.select({ ios: '600', default: 'bold' }),
|
|
483
510
|
color: STATE_COLORS[state],
|
|
484
511
|
}} numberOfLines={1}>
|
|
485
|
-
{STATE_LABELS[state]}
|
|
512
|
+
{resumable && resumeLabel ? resumeLabel : STATE_LABELS[state]}
|
|
486
513
|
</react_native_1.Text>
|
|
487
514
|
{toolStatus?.loadingMessage ? (<react_native_1.Text style={{ fontSize: 11, color: '#888', marginTop: 1 }} numberOfLines={1}>
|
|
488
515
|
{toolStatus.loadingMessage}
|
|
489
516
|
</react_native_1.Text>) : null}
|
|
490
|
-
</react_native_1.
|
|
491
|
-
{showMute ? (<MuteButton isMuted={isMuted} onToggle={
|
|
517
|
+
</react_native_1.Pressable>
|
|
518
|
+
{showMute ? (<MuteButton isMuted={isMuted} onToggle={onMicPress} theme={theme} state={state}/>) : null}
|
|
492
519
|
</react_native_1.View>) : isChatLayout ? (
|
|
493
520
|
// Chat layout: orb anchored at the top, chat history fills the rest.
|
|
494
521
|
// The orb moves up (vs. centered) to give the transcript room to breathe.
|
|
@@ -498,9 +525,10 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
|
|
|
498
525
|
width: '100%',
|
|
499
526
|
paddingTop: 32,
|
|
500
527
|
}}>
|
|
501
|
-
<react_native_1.
|
|
528
|
+
<react_native_1.Pressable disabled={!resumable} onPress={onResume} accessibilityRole={resumable ? 'button' : undefined} accessibilityLabel={resumable ? resumeLabel : undefined} style={{ alignItems: 'center', justifyContent: 'center' }}>
|
|
502
529
|
<Indicator state={state} amplitude={amplitude} theme={theme} isMuted={isMuted}/>
|
|
503
|
-
</react_native_1.
|
|
530
|
+
</react_native_1.Pressable>
|
|
531
|
+
{resumeHint}
|
|
504
532
|
|
|
505
533
|
{toolStatus ? (<react_native_1.View style={{ marginTop: 4, alignItems: 'center' }}>
|
|
506
534
|
<ToolStatusPill_1.ToolStatusPill message={toolStatus.loadingMessage} theme={theme}/>
|
|
@@ -517,9 +545,10 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
|
|
|
517
545
|
alignItems: 'center',
|
|
518
546
|
justifyContent: 'center',
|
|
519
547
|
}}>
|
|
520
|
-
<react_native_1.
|
|
548
|
+
<react_native_1.Pressable disabled={!resumable} onPress={onResume} accessibilityRole={resumable ? 'button' : undefined} accessibilityLabel={resumable ? resumeLabel : undefined} style={{ alignItems: 'center', justifyContent: 'center' }}>
|
|
521
549
|
<Indicator state={state} amplitude={amplitude} theme={theme} isMuted={isMuted}/>
|
|
522
|
-
</react_native_1.
|
|
550
|
+
</react_native_1.Pressable>
|
|
551
|
+
{resumeHint}
|
|
523
552
|
|
|
524
553
|
{toolStatus ? (<react_native_1.View style={{ marginTop: 12, alignItems: 'center' }}>
|
|
525
554
|
<ToolStatusPill_1.ToolStatusPill message={toolStatus.loadingMessage} theme={theme}/>
|
|
@@ -595,6 +624,14 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
|
|
|
595
624
|
</react_native_1.ScrollView>) : null}
|
|
596
625
|
</react_native_1.View>
|
|
597
626
|
|
|
627
|
+
{showClose && !hasToolUI ? (<react_native_1.View style={{
|
|
628
|
+
position: 'absolute',
|
|
629
|
+
top: insets.top + 12,
|
|
630
|
+
left: 16,
|
|
631
|
+
}}>
|
|
632
|
+
{closeButton}
|
|
633
|
+
</react_native_1.View>) : null}
|
|
634
|
+
|
|
598
635
|
{showMute && !hasToolUI ? (<react_native_1.View style={{
|
|
599
636
|
position: 'absolute',
|
|
600
637
|
// Absolute children aren't pushed by the parent's safe-area paddingTop,
|
|
@@ -602,7 +639,7 @@ function VoicePage({ state, transcript, userTranscript, transcriptHistory, trans
|
|
|
602
639
|
top: insets.top + 12,
|
|
603
640
|
right: 16,
|
|
604
641
|
}}>
|
|
605
|
-
<MuteButton isMuted={isMuted} onToggle={
|
|
642
|
+
<MuteButton isMuted={isMuted} onToggle={onMicPress} theme={theme} state={state}/>
|
|
606
643
|
</react_native_1.View>) : null}
|
|
607
644
|
</react_native_1.View>
|
|
608
645
|
</Background>);
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export { QafkaProvider } from './QafkaProvider';
|
|
5
5
|
export { Qafka } from './Qafka';
|
|
6
|
-
export type { VoiceComponents, VoiceIndicatorProps, VoiceBackgroundProps, VoiceTranscriptProps, VoiceMuteButtonProps, QafkaHandle } from './Qafka.types';
|
|
6
|
+
export type { VoiceComponents, VoiceIndicatorProps, VoiceBackgroundProps, VoiceTranscriptProps, VoiceMuteButtonProps, VoiceCloseButtonProps, QafkaHandle } from './Qafka.types';
|
|
7
7
|
export type { ToolResponseMode } from './Qafka.types';
|
|
8
8
|
export type { QafkaProps, QafkaProjectIds, ProjectIdOf } from './Qafka.types';
|
|
9
9
|
export { MessageBubble } from './MessageBubble';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.classifyVoiceClose = classifyVoiceClose;
|
|
4
|
+
const featureNotAvailable_1 = require("./services/featureNotAvailable");
|
|
4
5
|
function classifyVoiceClose(event) {
|
|
5
6
|
const code = event?.code;
|
|
6
7
|
const reason = event?.reason;
|
|
@@ -10,7 +11,15 @@ function classifyVoiceClose(event) {
|
|
|
10
11
|
if (code === 4003 || reason === 'VOICE_NOT_ALLOWED') {
|
|
11
12
|
return 'not_allowed';
|
|
12
13
|
}
|
|
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) {
|
|
14
23
|
return 'project_unavailable';
|
|
15
24
|
}
|
|
16
25
|
return 'other';
|
|
@@ -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
|
}
|
|
@@ -133,5 +133,14 @@ interface UseVoiceChatResult {
|
|
|
133
133
|
voiceCloseReason: 'minutes_exhausted' | null;
|
|
134
134
|
/** Locale-aware copy to show alongside `voiceCloseReason`, or `null`. */
|
|
135
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;
|
|
136
145
|
}
|
|
137
146
|
export declare function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale, onToolSuggested, toolRenderMode, getSessionToken, endUserId, endUserData, onNavigationSuggest, }: UseVoiceChatOptions): UseVoiceChatResult;
|
|
@@ -7,6 +7,7 @@ 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");
|
|
10
11
|
/**
|
|
11
12
|
* Neutral, locale-aware copy shown when the server ends a voice session
|
|
12
13
|
* because it can no longer be continued. Mirrors `unavailableMessage()` in
|
|
@@ -21,6 +22,9 @@ function voiceUnavailableMessage(locale) {
|
|
|
21
22
|
function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale, onToolSuggested, toolRenderMode = 'upsert', getSessionToken, endUserId, endUserData, onNavigationSuggest, }) {
|
|
22
23
|
const [state, setState] = (0, react_1.useState)('idle');
|
|
23
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);
|
|
24
28
|
const [voiceCloseMessage, setVoiceCloseMessage] = (0, react_1.useState)(null);
|
|
25
29
|
const localeRef = (0, react_1.useRef)(locale);
|
|
26
30
|
(0, react_1.useEffect)(() => {
|
|
@@ -180,12 +184,14 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale,
|
|
|
180
184
|
setVoiceCloseReason(null);
|
|
181
185
|
setVoiceCloseMessage(null);
|
|
182
186
|
setState('idle');
|
|
187
|
+
setSessionEnded(true);
|
|
183
188
|
setNavigationSuggestion(null);
|
|
184
189
|
pendingNavDisconnectRef.current = false;
|
|
185
190
|
return;
|
|
186
191
|
}
|
|
187
192
|
case 'error':
|
|
188
193
|
setState('idle');
|
|
194
|
+
setSessionEnded(true);
|
|
189
195
|
return;
|
|
190
196
|
case 'tool_status':
|
|
191
197
|
setToolStatus({
|
|
@@ -424,6 +430,7 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale,
|
|
|
424
430
|
return;
|
|
425
431
|
}
|
|
426
432
|
setState('connecting');
|
|
433
|
+
setSessionEnded(false);
|
|
427
434
|
setVoiceCloseReason(null);
|
|
428
435
|
setVoiceCloseMessage(null);
|
|
429
436
|
const service = new RealtimeService_1.RealtimeService(apiUrl, apiKey);
|
|
@@ -459,6 +466,7 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale,
|
|
|
459
466
|
}
|
|
460
467
|
catch {
|
|
461
468
|
setState('idle');
|
|
469
|
+
setSessionEnded(true);
|
|
462
470
|
serviceRef.current = null;
|
|
463
471
|
}
|
|
464
472
|
}, [apiUrl, apiKey, handleEvent, userContext, contextDescription, getSessionToken, endUserId, endUserData]);
|
|
@@ -468,6 +476,7 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale,
|
|
|
468
476
|
await serviceRef.current?.disconnect();
|
|
469
477
|
serviceRef.current = null;
|
|
470
478
|
setState('idle');
|
|
479
|
+
setSessionEnded(true);
|
|
471
480
|
setTranscript('');
|
|
472
481
|
setUserTranscript('');
|
|
473
482
|
setTranscriptHistory([]);
|
|
@@ -483,6 +492,15 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale,
|
|
|
483
492
|
(0, react_1.useEffect)(() => {
|
|
484
493
|
disconnectRef.current = disconnect;
|
|
485
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]);
|
|
486
504
|
const pauseMic = (0, react_1.useCallback)(async () => {
|
|
487
505
|
await serviceRef.current?.pauseMic();
|
|
488
506
|
}, []);
|
|
@@ -559,5 +577,8 @@ function useVoiceChat({ apiUrl, apiKey, userContext, contextDescription, locale,
|
|
|
559
577
|
dismissNavigationSuggestion,
|
|
560
578
|
voiceCloseReason,
|
|
561
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),
|
|
562
583
|
};
|
|
563
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);
|
|
@@ -298,6 +299,10 @@ You may optionally restyle the chips via `voiceComponents.dataChipList` / `voice
|
|
|
298
299
|
<Qafka onUnavailable={() => setChatAvailable(false)} />
|
|
299
300
|
{chatAvailable && <LauncherButton onPress={openChat} />}
|
|
300
301
|
```
|
|
302
|
+
- **`onVoiceClose`** — optional; fires when the close button on the voice page is pressed.
|
|
303
|
+
That button only leaves voice: the voice session ends and the widget returns to the chat
|
|
304
|
+
page, which stays open. `onClose` is not called — it still belongs to the chat page's own
|
|
305
|
+
close button. Restyle the button via `voiceComponents.VoiceCloseButton`.
|
|
301
306
|
|
|
302
307
|
---
|
|
303
308
|
|
package/package.json
CHANGED