@bytexbyte/nxtlinq-ai-agent-ui-react-native-development 0.2.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/dist/NxtlinqAgentAssistant.d.ts +29 -0
- package/dist/NxtlinqAgentAssistant.d.ts.map +1 -0
- package/dist/NxtlinqAgentAssistant.js +32 -0
- package/dist/components/AgentAssistantShell.d.ts +7 -0
- package/dist/components/AgentAssistantShell.d.ts.map +1 -0
- package/dist/components/AgentAssistantShell.js +77 -0
- package/dist/components/AgentComposer.d.ts +3 -0
- package/dist/components/AgentComposer.d.ts.map +1 -0
- package/dist/components/AgentComposer.js +56 -0
- package/dist/components/AgentMessageList.d.ts +3 -0
- package/dist/components/AgentMessageList.d.ts.map +1 -0
- package/dist/components/AgentMessageList.js +91 -0
- package/dist/components/AgentRemoteAudio.d.ts +14 -0
- package/dist/components/AgentRemoteAudio.d.ts.map +1 -0
- package/dist/components/AgentRemoteAudio.js +62 -0
- package/dist/components/AgentVoiceBar.d.ts +3 -0
- package/dist/components/AgentVoiceBar.d.ts.map +1 -0
- package/dist/components/AgentVoiceBar.js +133 -0
- package/dist/components/PresetMessageChips.d.ts +3 -0
- package/dist/components/PresetMessageChips.d.ts.map +1 -0
- package/dist/components/PresetMessageChips.js +39 -0
- package/dist/components/VoiceGreetTrigger.d.ts +10 -0
- package/dist/components/VoiceGreetTrigger.d.ts.map +1 -0
- package/dist/components/VoiceGreetTrigger.js +99 -0
- package/dist/components/VoiceIcons.d.ts +12 -0
- package/dist/components/VoiceIcons.d.ts.map +1 -0
- package/dist/components/VoiceIcons.js +17 -0
- package/dist/components/VoiceImageInput.d.ts +10 -0
- package/dist/components/VoiceImageInput.d.ts.map +1 -0
- package/dist/components/VoiceImageInput.js +100 -0
- package/dist/components/VoiceWaveform.d.ts +7 -0
- package/dist/components/VoiceWaveform.d.ts.map +1 -0
- package/dist/components/VoiceWaveform.js +64 -0
- package/dist/context/AgentAssistantContext.d.ts +45 -0
- package/dist/context/AgentAssistantContext.d.ts.map +1 -0
- package/dist/context/AgentAssistantContext.js +244 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/theme/defaultTheme.d.ts +3 -0
- package/dist/theme/defaultTheme.d.ts.map +1 -0
- package/dist/theme/defaultTheme.js +33 -0
- package/dist/types.d.ts +103 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/voice/AudioSessionWaker.d.ts +18 -0
- package/dist/voice/AudioSessionWaker.d.ts.map +1 -0
- package/dist/voice/AudioSessionWaker.js +49 -0
- package/dist/voice/TextTtsPlayer.d.ts +21 -0
- package/dist/voice/TextTtsPlayer.d.ts.map +1 -0
- package/dist/voice/TextTtsPlayer.js +91 -0
- package/dist/voice/VoiceAutoGreetBinder.d.ts +6 -0
- package/dist/voice/VoiceAutoGreetBinder.d.ts.map +1 -0
- package/dist/voice/VoiceAutoGreetBinder.js +25 -0
- package/dist/voice/useVoiceAutoGreet.d.ts +24 -0
- package/dist/voice/useVoiceAutoGreet.d.ts.map +1 -0
- package/dist/voice/useVoiceAutoGreet.js +64 -0
- package/dist/voice/useVoiceMicState.d.ts +24 -0
- package/dist/voice/useVoiceMicState.d.ts.map +1 -0
- package/dist/voice/useVoiceMicState.js +84 -0
- package/dist/voice/voiceMicConstants.d.ts +5 -0
- package/dist/voice/voiceMicConstants.d.ts.map +1 -0
- package/dist/voice/voiceMicConstants.js +11 -0
- package/dist/voice/voiceWaveformConstants.d.ts +6 -0
- package/dist/voice/voiceWaveformConstants.d.ts.map +1 -0
- package/dist/voice/voiceWaveformConstants.js +7 -0
- package/dist/voice/webrtcAudioGain.d.ts +6 -0
- package/dist/voice/webrtcAudioGain.d.ts.map +1 -0
- package/dist/voice/webrtcAudioGain.js +11 -0
- package/dist/voice/writeTtsCacheFile.d.ts +9 -0
- package/dist/voice/writeTtsCacheFile.d.ts.map +1 -0
- package/dist/voice/writeTtsCacheFile.js +37 -0
- package/package.json +64 -0
- package/src/NxtlinqAgentAssistant.tsx +103 -0
- package/src/components/AgentAssistantShell.tsx +167 -0
- package/src/components/AgentComposer.tsx +117 -0
- package/src/components/AgentMessageList.tsx +187 -0
- package/src/components/AgentRemoteAudio.tsx +105 -0
- package/src/components/AgentVoiceBar.tsx +232 -0
- package/src/components/PresetMessageChips.tsx +64 -0
- package/src/components/VoiceGreetTrigger.tsx +158 -0
- package/src/components/VoiceIcons.tsx +32 -0
- package/src/components/VoiceImageInput.tsx +178 -0
- package/src/components/VoiceWaveform.tsx +84 -0
- package/src/context/AgentAssistantContext.tsx +369 -0
- package/src/index.ts +59 -0
- package/src/react-native.d.ts +42 -0
- package/src/theme/defaultTheme.ts +35 -0
- package/src/types.ts +107 -0
- package/src/voice/AudioSessionWaker.tsx +94 -0
- package/src/voice/TextTtsPlayer.tsx +151 -0
- package/src/voice/VoiceAutoGreetBinder.tsx +38 -0
- package/src/voice/useVoiceAutoGreet.ts +95 -0
- package/src/voice/useVoiceMicState.ts +116 -0
- package/src/voice/voiceMicConstants.ts +14 -0
- package/src/voice/voiceWaveformConstants.ts +10 -0
- package/src/voice/webrtcAudioGain.ts +21 -0
- package/src/voice/writeTtsCacheFile.ts +47 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
declare module 'react-native' {
|
|
2
|
+
import type * as React from 'react';
|
|
3
|
+
|
|
4
|
+
export type StyleProp<T> = T | RecursiveArray<T | false | null | undefined> | false | null | undefined;
|
|
5
|
+
type RecursiveArray<T> = T | ReadonlyArray<RecursiveArray<T>>;
|
|
6
|
+
export type ViewStyle = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
type PressableState = { pressed: boolean };
|
|
9
|
+
type PressableStyle = StyleProp<ViewStyle> | ((state: PressableState) => StyleProp<ViewStyle>);
|
|
10
|
+
|
|
11
|
+
export const View: React.ComponentType<{
|
|
12
|
+
style?: StyleProp<ViewStyle>;
|
|
13
|
+
children?: React.ReactNode;
|
|
14
|
+
}>;
|
|
15
|
+
export const Text: React.ComponentType<{
|
|
16
|
+
style?: StyleProp<ViewStyle>;
|
|
17
|
+
children?: React.ReactNode;
|
|
18
|
+
numberOfLines?: number;
|
|
19
|
+
}>;
|
|
20
|
+
export const TextInput: React.ComponentType<Record<string, unknown>>;
|
|
21
|
+
export const Pressable: React.ComponentType<{
|
|
22
|
+
onPress?: () => void;
|
|
23
|
+
disabled?: boolean;
|
|
24
|
+
style?: PressableStyle;
|
|
25
|
+
children?: React.ReactNode;
|
|
26
|
+
}>;
|
|
27
|
+
export const ScrollView: React.ComponentType<Record<string, unknown>>;
|
|
28
|
+
export const FlatList: <ItemT>(props: {
|
|
29
|
+
ref?: React.Ref<{ scrollToEnd: (opts: { animated: boolean }) => void }>;
|
|
30
|
+
data: ItemT[] | null | undefined;
|
|
31
|
+
keyExtractor?: (item: ItemT, index: number) => string;
|
|
32
|
+
renderItem: (info: { item: ItemT; index: number }) => React.ReactElement | null;
|
|
33
|
+
contentContainerStyle?: StyleProp<ViewStyle>;
|
|
34
|
+
ListEmptyComponent?: React.ReactElement | null;
|
|
35
|
+
ListFooterComponent?: React.ReactElement | null;
|
|
36
|
+
}) => React.ReactElement | null;
|
|
37
|
+
export const ActivityIndicator: React.ComponentType<Record<string, unknown>>;
|
|
38
|
+
export const StyleSheet: {
|
|
39
|
+
create<T extends Record<string, ViewStyle>>(styles: T): T;
|
|
40
|
+
hairlineWidth: number;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { AgentAssistantTheme } from '../types';
|
|
2
|
+
|
|
3
|
+
export const defaultAgentAssistantTheme: AgentAssistantTheme = {
|
|
4
|
+
colors: {
|
|
5
|
+
background: '#ffffff',
|
|
6
|
+
surface: '#f8fafc',
|
|
7
|
+
border: '#e2e8f0',
|
|
8
|
+
primary: '#2563eb',
|
|
9
|
+
primaryText: '#ffffff',
|
|
10
|
+
userBubble: '#2563eb',
|
|
11
|
+
userText: '#ffffff',
|
|
12
|
+
assistantBubble: '#f1f5f9',
|
|
13
|
+
assistantText: '#0f172a',
|
|
14
|
+
mutedText: '#64748b',
|
|
15
|
+
error: '#dc2626',
|
|
16
|
+
voiceActive: '#22c55e',
|
|
17
|
+
voiceSpeaking: '#ec4899',
|
|
18
|
+
},
|
|
19
|
+
spacing: {
|
|
20
|
+
xs: 4,
|
|
21
|
+
sm: 8,
|
|
22
|
+
md: 12,
|
|
23
|
+
lg: 16,
|
|
24
|
+
},
|
|
25
|
+
radius: {
|
|
26
|
+
bubble: 16,
|
|
27
|
+
panel: 12,
|
|
28
|
+
button: 8,
|
|
29
|
+
},
|
|
30
|
+
typography: {
|
|
31
|
+
titleSize: 17,
|
|
32
|
+
bodySize: 15,
|
|
33
|
+
captionSize: 12,
|
|
34
|
+
},
|
|
35
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { Message, ToolUse as CoreToolUse } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
+
import type { NxtlinqAgentProviderProps } from '@bytexbyte/nxtlinq-ai-agent-react-native-development';
|
|
3
|
+
import type { StyleProp, ViewStyle } from 'react-native';
|
|
4
|
+
import type { VoiceAutoGreetConfig } from './voice/useVoiceAutoGreet';
|
|
5
|
+
|
|
6
|
+
export type PresetMessage = {
|
|
7
|
+
text: string;
|
|
8
|
+
autoSend?: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type AgentAssistantTheme = {
|
|
12
|
+
colors: {
|
|
13
|
+
background: string;
|
|
14
|
+
surface: string;
|
|
15
|
+
border: string;
|
|
16
|
+
primary: string;
|
|
17
|
+
primaryText: string;
|
|
18
|
+
userBubble: string;
|
|
19
|
+
userText: string;
|
|
20
|
+
assistantBubble: string;
|
|
21
|
+
assistantText: string;
|
|
22
|
+
mutedText: string;
|
|
23
|
+
error: string;
|
|
24
|
+
voiceActive: string;
|
|
25
|
+
voiceSpeaking: string;
|
|
26
|
+
};
|
|
27
|
+
spacing: {
|
|
28
|
+
xs: number;
|
|
29
|
+
sm: number;
|
|
30
|
+
md: number;
|
|
31
|
+
lg: number;
|
|
32
|
+
};
|
|
33
|
+
radius: {
|
|
34
|
+
bubble: number;
|
|
35
|
+
panel: number;
|
|
36
|
+
button: number;
|
|
37
|
+
};
|
|
38
|
+
typography: {
|
|
39
|
+
titleSize: number;
|
|
40
|
+
bodySize: number;
|
|
41
|
+
captionSize: number;
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type NxtlinqAgentAssistantProps = Omit<NxtlinqAgentProviderProps, 'children'> & {
|
|
46
|
+
children?: NxtlinqAgentProviderProps['children'];
|
|
47
|
+
/** Panel title in the header. */
|
|
48
|
+
title?: string;
|
|
49
|
+
placeholder?: string;
|
|
50
|
+
presetMessages?: PresetMessage[];
|
|
51
|
+
/** Fetch history on mount when `pseudoId` is set. */
|
|
52
|
+
loadHistoryOnMount?: boolean;
|
|
53
|
+
/** Max messages to prefetch (default 50). */
|
|
54
|
+
historyLast?: number;
|
|
55
|
+
/** Show voice mode controls (requires `webrtcModule`). */
|
|
56
|
+
enableVoice?: boolean;
|
|
57
|
+
/** Start in voice interaction mode. */
|
|
58
|
+
startInVoiceMode?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* Mute mic when voice connects (Berify hold-to-talk). Set false for open-mic demos.
|
|
61
|
+
* @default true
|
|
62
|
+
*/
|
|
63
|
+
startWithMicMuted?: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Mute mic while assistant is transcribing/speaking (echo guard).
|
|
66
|
+
* Independent of `startWithMicMuted`; defaults true.
|
|
67
|
+
*/
|
|
68
|
+
holdMicDuringAssistant?: boolean;
|
|
69
|
+
/** Show output RMS waveform while in voice mode (P0 / SDK-1). */
|
|
70
|
+
showVoiceWaveform?: boolean;
|
|
71
|
+
/** Show voice image URL input for P0 testing (SDK-2). */
|
|
72
|
+
showVoiceImageInput?: boolean;
|
|
73
|
+
/** Demo product image URL for greet + quick send. */
|
|
74
|
+
voiceDemoProductImageUrl?: string;
|
|
75
|
+
/** Auto-greet on first voice connect when no user history (SDK-3). */
|
|
76
|
+
voiceAutoGreet?: VoiceAutoGreetConfig | boolean;
|
|
77
|
+
/**
|
|
78
|
+
* iOS: bundled silent MP3 (`require('./assets/silent.mp3')`) to wake AVAudioSession.
|
|
79
|
+
* Strongly recommended — without it WebRTC/TTS may be silent on cold start.
|
|
80
|
+
*/
|
|
81
|
+
iosSilentAudioSource?: number | { uri: string };
|
|
82
|
+
/**
|
|
83
|
+
* Voice-mode assistant playback gain (WebRTC `_setVolume`, 0–10). Default 5 to
|
|
84
|
+
* match text TTS loudness (react-native-video volume 1). Default 10.
|
|
85
|
+
*/
|
|
86
|
+
voiceRemoteAudioGain?: number;
|
|
87
|
+
/** Text-mode TTS volume (react-native-video, 0–1). @default 1 */
|
|
88
|
+
textTtsVolume?: number;
|
|
89
|
+
theme?: Partial<AgentAssistantTheme>;
|
|
90
|
+
style?: StyleProp<ViewStyle>;
|
|
91
|
+
headerStyle?: StyleProp<ViewStyle>;
|
|
92
|
+
onMessage?: (message: Message) => void;
|
|
93
|
+
onError?: (error: Error) => void;
|
|
94
|
+
onToolUse?: (
|
|
95
|
+
toolUse: CoreToolUse,
|
|
96
|
+
onProgress?: (update: {
|
|
97
|
+
status?: string;
|
|
98
|
+
progress?: number;
|
|
99
|
+
partialResult?: string;
|
|
100
|
+
steps?: string[];
|
|
101
|
+
partialContent?: string;
|
|
102
|
+
}) => void,
|
|
103
|
+
) => Promise<Message | void>;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
export type ToolUse = CoreToolUse;
|
|
107
|
+
export type { Message };
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import {
|
|
2
|
+
markIOSAudioSessionReady,
|
|
3
|
+
setIOSAudioSessionPrewarmHandler,
|
|
4
|
+
} from '@bytexbyte/nxtlinq-ai-agent-react-native-development';
|
|
5
|
+
import React, { useEffect, useRef } from 'react';
|
|
6
|
+
|
|
7
|
+
type VideoSource = number | { uri: string };
|
|
8
|
+
|
|
9
|
+
type VideoRef = {
|
|
10
|
+
seek: (time: number) => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type VideoModule = {
|
|
14
|
+
default: React.ComponentType<{
|
|
15
|
+
ref?: React.Ref<VideoRef>;
|
|
16
|
+
source: VideoSource;
|
|
17
|
+
paused?: boolean;
|
|
18
|
+
volume?: number;
|
|
19
|
+
playInBackground?: boolean;
|
|
20
|
+
repeat?: boolean;
|
|
21
|
+
ignoreSilentSwitch?: 'ignore' | 'obey' | 'inherit';
|
|
22
|
+
style?: object;
|
|
23
|
+
onLoad?: () => void;
|
|
24
|
+
onProgress?: (data: { currentTime: number }) => void;
|
|
25
|
+
}>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function isIOS(): boolean {
|
|
29
|
+
try {
|
|
30
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
31
|
+
const { Platform } = require('react-native') as { Platform: { OS: string } };
|
|
32
|
+
return Platform.OS === 'ios';
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let VideoComponent: VideoModule['default'] | null = null;
|
|
39
|
+
try {
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
|
+
VideoComponent = (require('react-native-video') as VideoModule).default;
|
|
42
|
+
} catch {
|
|
43
|
+
VideoComponent = null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type AudioSessionWakerProps = {
|
|
47
|
+
/**
|
|
48
|
+
* Bundled silent MP3 (e.g. `require('./assets/silent.mp3')`).
|
|
49
|
+
* Required on iOS for WebRTC + TTS until AVAudioSession is activated.
|
|
50
|
+
*/
|
|
51
|
+
silentSource: VideoSource;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* iOS-only: loop silent audio at volume 0 so react-native-video keeps AVAudioSession active.
|
|
56
|
+
* View stays mounted for app lifetime (Berify pattern — never unmount).
|
|
57
|
+
*/
|
|
58
|
+
export function AudioSessionWaker({ silentSource }: AudioSessionWakerProps): React.ReactElement | null {
|
|
59
|
+
const videoRef = useRef<VideoRef | null>(null);
|
|
60
|
+
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
if (!isIOS() || !VideoComponent) return undefined;
|
|
63
|
+
// Seek-only replay — pausing react-native-video can drop AVAudioSession on repeated reconnects.
|
|
64
|
+
setIOSAudioSessionPrewarmHandler(() => {
|
|
65
|
+
try {
|
|
66
|
+
videoRef.current?.seek(0);
|
|
67
|
+
} catch {
|
|
68
|
+
/* noop */
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
return () => setIOSAudioSessionPrewarmHandler(null);
|
|
72
|
+
}, []);
|
|
73
|
+
|
|
74
|
+
if (!isIOS() || !VideoComponent) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<VideoComponent
|
|
80
|
+
ref={videoRef}
|
|
81
|
+
source={silentSource}
|
|
82
|
+
paused={false}
|
|
83
|
+
volume={0}
|
|
84
|
+
playInBackground={false}
|
|
85
|
+
repeat
|
|
86
|
+
ignoreSilentSwitch="ignore"
|
|
87
|
+
style={{ width: 0, height: 0, position: 'absolute', opacity: 0 }}
|
|
88
|
+
onLoad={() => markIOSAudioSessionReady()}
|
|
89
|
+
onProgress={({ currentTime }: { currentTime: number }) => {
|
|
90
|
+
if (currentTime > 0) markIOSAudioSessionReady();
|
|
91
|
+
}}
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import React, { useCallback, useRef, useState } from 'react';
|
|
2
|
+
import { StyleSheet } from 'react-native';
|
|
3
|
+
import type { PostTextTtsResult } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
4
|
+
import { isTtsCacheFileSupported, removeTtsCacheFile, writeTtsCacheFile } from './writeTtsCacheFile';
|
|
5
|
+
|
|
6
|
+
type VideoModule = {
|
|
7
|
+
default: React.ComponentType<{
|
|
8
|
+
ref?: React.Ref<{ seek: (time: number) => void }>;
|
|
9
|
+
source?: { uri: string };
|
|
10
|
+
style?: object;
|
|
11
|
+
paused?: boolean;
|
|
12
|
+
volume?: number;
|
|
13
|
+
audioOnly?: boolean;
|
|
14
|
+
ignoreSilentSwitch?: 'ignore' | 'obey' | 'inherit';
|
|
15
|
+
playInBackground?: boolean;
|
|
16
|
+
playWhenInactive?: boolean;
|
|
17
|
+
onEnd?: () => void;
|
|
18
|
+
onError?: (e: { error?: { errorString?: string } }) => void;
|
|
19
|
+
bufferConfig?: Record<string, number>;
|
|
20
|
+
preferredForwardBufferDuration?: number;
|
|
21
|
+
}>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
let VideoComponent: VideoModule['default'] | null = null;
|
|
25
|
+
try {
|
|
26
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
27
|
+
VideoComponent = (require('react-native-video') as VideoModule).default;
|
|
28
|
+
} catch {
|
|
29
|
+
VideoComponent = null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isTextTtsPlayerSupported(): boolean {
|
|
33
|
+
return VideoComponent != null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type TextTtsPlayerHandle = {
|
|
37
|
+
play: (result: PostTextTtsResult) => Promise<void>;
|
|
38
|
+
stop: () => void;
|
|
39
|
+
isAvailable: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type Props = {
|
|
43
|
+
/** react-native-video volume 0–1. @default 1 */
|
|
44
|
+
volume?: number;
|
|
45
|
+
onPlayingChange?: (playing: boolean) => void;
|
|
46
|
+
onError?: (message: string) => void;
|
|
47
|
+
playerRef: React.MutableRefObject<TextTtsPlayerHandle | null>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Hidden audio player for text-mode TTS (Berify uses react-native-video the same way).
|
|
52
|
+
*/
|
|
53
|
+
export function TextTtsPlayer({
|
|
54
|
+
volume = 1,
|
|
55
|
+
onPlayingChange,
|
|
56
|
+
onError,
|
|
57
|
+
playerRef,
|
|
58
|
+
}: Props): React.ReactElement | null {
|
|
59
|
+
const videoRef = useRef<{ seek: (time: number) => void } | null>(null);
|
|
60
|
+
const [source, setSource] = useState<{ uri: string } | undefined>();
|
|
61
|
+
const lastFileRef = useRef<string | null>(null);
|
|
62
|
+
|
|
63
|
+
const stop = useCallback(() => {
|
|
64
|
+
const prev = lastFileRef.current;
|
|
65
|
+
lastFileRef.current = null;
|
|
66
|
+
setSource(undefined);
|
|
67
|
+
onPlayingChange?.(false);
|
|
68
|
+
if (prev && isTtsCacheFileSupported()) {
|
|
69
|
+
void removeTtsCacheFile(prev);
|
|
70
|
+
}
|
|
71
|
+
}, [onPlayingChange]);
|
|
72
|
+
|
|
73
|
+
const play = useCallback(
|
|
74
|
+
async (result: PostTextTtsResult) => {
|
|
75
|
+
if (!VideoComponent) return;
|
|
76
|
+
try {
|
|
77
|
+
const uri = await writeTtsCacheFile(result);
|
|
78
|
+
if (lastFileRef.current && lastFileRef.current !== uri) {
|
|
79
|
+
void removeTtsCacheFile(lastFileRef.current);
|
|
80
|
+
}
|
|
81
|
+
lastFileRef.current = uri;
|
|
82
|
+
setSource({ uri });
|
|
83
|
+
onPlayingChange?.(true);
|
|
84
|
+
requestAnimationFrame(() => {
|
|
85
|
+
try {
|
|
86
|
+
videoRef.current?.seek(0);
|
|
87
|
+
} catch {
|
|
88
|
+
/* noop */
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
} catch (e) {
|
|
92
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
93
|
+
onError?.(message);
|
|
94
|
+
stop();
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
[onPlayingChange, onError, stop],
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
React.useEffect(() => {
|
|
101
|
+
playerRef.current = {
|
|
102
|
+
play,
|
|
103
|
+
stop,
|
|
104
|
+
isAvailable: VideoComponent != null,
|
|
105
|
+
};
|
|
106
|
+
return () => {
|
|
107
|
+
playerRef.current = null;
|
|
108
|
+
stop();
|
|
109
|
+
};
|
|
110
|
+
}, [play, stop, playerRef]);
|
|
111
|
+
|
|
112
|
+
if (!VideoComponent) return null;
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<VideoComponent
|
|
116
|
+
ref={videoRef}
|
|
117
|
+
source={source}
|
|
118
|
+
style={styles.hidden}
|
|
119
|
+
paused={!source}
|
|
120
|
+
volume={volume}
|
|
121
|
+
audioOnly
|
|
122
|
+
ignoreSilentSwitch="ignore"
|
|
123
|
+
playInBackground={false}
|
|
124
|
+
playWhenInactive
|
|
125
|
+
onEnd={stop}
|
|
126
|
+
onError={(e) => {
|
|
127
|
+
const msg = e?.error?.errorString ?? 'TTS playback failed';
|
|
128
|
+
onError?.(msg);
|
|
129
|
+
stop();
|
|
130
|
+
}}
|
|
131
|
+
bufferConfig={{
|
|
132
|
+
minBufferMs: 800,
|
|
133
|
+
maxBufferMs: 4000,
|
|
134
|
+
bufferForPlaybackMs: 150,
|
|
135
|
+
bufferForPlaybackAfterRebufferMs: 500,
|
|
136
|
+
}}
|
|
137
|
+
preferredForwardBufferDuration={0.2}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const styles = StyleSheet.create({
|
|
143
|
+
hidden: {
|
|
144
|
+
width: 1,
|
|
145
|
+
height: 1,
|
|
146
|
+
opacity: 0.01,
|
|
147
|
+
position: 'absolute',
|
|
148
|
+
left: -1000,
|
|
149
|
+
top: -1000,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { uriToVoiceImageAttachment } from '@bytexbyte/nxtlinq-ai-agent-react-native-development';
|
|
2
|
+
import React, { useCallback, useMemo } from 'react';
|
|
3
|
+
import { useAgentAssistant } from '../context/AgentAssistantContext';
|
|
4
|
+
import type { VoiceAutoGreetConfig } from './useVoiceAutoGreet';
|
|
5
|
+
import { useVoiceAutoGreet } from './useVoiceAutoGreet';
|
|
6
|
+
|
|
7
|
+
export function VoiceAutoGreetBinder({
|
|
8
|
+
config,
|
|
9
|
+
historyReady,
|
|
10
|
+
}: {
|
|
11
|
+
config: VoiceAutoGreetConfig;
|
|
12
|
+
historyReady: boolean;
|
|
13
|
+
}): null {
|
|
14
|
+
const voice = useAgentAssistant();
|
|
15
|
+
const hasUserMessage = useMemo(
|
|
16
|
+
() => voice.messages.some((m) => m.role === 'user'),
|
|
17
|
+
[voice.messages],
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
const resolveAttachments = useCallback(async () => {
|
|
21
|
+
const url = config.productImageUrl?.trim();
|
|
22
|
+
if (!url) return config.attachments;
|
|
23
|
+
const image = await uriToVoiceImageAttachment(url, 'product.jpg');
|
|
24
|
+
return config.attachments?.length
|
|
25
|
+
? [...config.attachments, image]
|
|
26
|
+
: [image];
|
|
27
|
+
}, [config.attachments, config.productImageUrl]);
|
|
28
|
+
|
|
29
|
+
useVoiceAutoGreet(voice, {
|
|
30
|
+
enabled: true,
|
|
31
|
+
config,
|
|
32
|
+
historyReady,
|
|
33
|
+
hasUserMessage,
|
|
34
|
+
resolveAttachments: config.productImageUrl ? resolveAttachments : undefined,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Attachment } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
+
import { useEffect, useRef } from 'react';
|
|
3
|
+
import type { UseNxtlinqVoiceResult } from '@bytexbyte/nxtlinq-ai-agent-react-native-development';
|
|
4
|
+
|
|
5
|
+
export type VoiceAutoGreetConfig = {
|
|
6
|
+
/** Greeting user text (e.g. product intro prompt). */
|
|
7
|
+
text?: string;
|
|
8
|
+
productName?: string;
|
|
9
|
+
/** HTTPS or data URI attached on first greet. */
|
|
10
|
+
productImageUrl?: string;
|
|
11
|
+
/** Pre-built attachments (overrides productImageUrl). */
|
|
12
|
+
attachments?: Attachment[];
|
|
13
|
+
skipUserMessage?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function buildGreetingText(config: VoiceAutoGreetConfig): string {
|
|
17
|
+
if (config.text?.trim()) return config.text.trim();
|
|
18
|
+
const product = config.productName?.trim();
|
|
19
|
+
return product
|
|
20
|
+
? `Hi! Please give me a brief introduction to ${product}.`
|
|
21
|
+
: 'Hi! Please say hello and introduce yourself.';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Berify Jeannie auto-greet: after voice connects + history loaded, trigger one
|
|
26
|
+
* synthetic user turn when there is no prior real user message.
|
|
27
|
+
*/
|
|
28
|
+
export function useVoiceAutoGreet(
|
|
29
|
+
voice: Pick<
|
|
30
|
+
UseNxtlinqVoiceResult,
|
|
31
|
+
'isVoiceActive' | 'triggerVoiceGreeting'
|
|
32
|
+
>,
|
|
33
|
+
options: {
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
config?: VoiceAutoGreetConfig;
|
|
36
|
+
historyReady: boolean;
|
|
37
|
+
hasUserMessage: boolean;
|
|
38
|
+
resolveAttachments?: () => Promise<Attachment[] | undefined>;
|
|
39
|
+
},
|
|
40
|
+
): void {
|
|
41
|
+
const sentRef = useRef(false);
|
|
42
|
+
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
if (!options.enabled || !options.config) {
|
|
45
|
+
sentRef.current = false;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!voice.isVoiceActive || !options.historyReady) return;
|
|
49
|
+
if (sentRef.current) return;
|
|
50
|
+
if (options.hasUserMessage) {
|
|
51
|
+
sentRef.current = true;
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let cancelled = false;
|
|
56
|
+
sentRef.current = true;
|
|
57
|
+
|
|
58
|
+
(async () => {
|
|
59
|
+
try {
|
|
60
|
+
const attachments =
|
|
61
|
+
options.config?.attachments ??
|
|
62
|
+
(await options.resolveAttachments?.());
|
|
63
|
+
if (cancelled) return;
|
|
64
|
+
await voice.triggerVoiceGreeting(
|
|
65
|
+
{
|
|
66
|
+
text: buildGreetingText(options.config!),
|
|
67
|
+
attachments,
|
|
68
|
+
skipUserMessage: options.config?.skipUserMessage ?? true,
|
|
69
|
+
},
|
|
70
|
+
{ waitForChannel: true, timeoutMs: 12000 },
|
|
71
|
+
);
|
|
72
|
+
} catch {
|
|
73
|
+
sentRef.current = false;
|
|
74
|
+
}
|
|
75
|
+
})();
|
|
76
|
+
|
|
77
|
+
return () => {
|
|
78
|
+
cancelled = true;
|
|
79
|
+
};
|
|
80
|
+
}, [
|
|
81
|
+
voice.isVoiceActive,
|
|
82
|
+
voice.triggerVoiceGreeting,
|
|
83
|
+
options.enabled,
|
|
84
|
+
options.config,
|
|
85
|
+
options.historyReady,
|
|
86
|
+
options.hasUserMessage,
|
|
87
|
+
options.resolveAttachments,
|
|
88
|
+
]);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!voice.isVoiceActive) {
|
|
92
|
+
sentRef.current = false;
|
|
93
|
+
}
|
|
94
|
+
}, [voice.isVoiceActive]);
|
|
95
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { VoiceStatus } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
+
import type { UseNxtlinqVoiceResult } from '@bytexbyte/nxtlinq-ai-agent-react-native-development';
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import { ASSISTANT_MIC_HOLD_STATUSES } from './voiceMicConstants';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Mic mute / hold behavior aligned with web SDK {@link useVoiceMode}.
|
|
8
|
+
* - Starts muted on connect (`startWithMicMuted`)
|
|
9
|
+
* - Holds mic during assistant transcribing → speaking
|
|
10
|
+
*/
|
|
11
|
+
export type UseVoiceMicStateOptions = {
|
|
12
|
+
/** When false, mic is open after connect (demo-friendly). Default true (Berify hold-to-talk). */
|
|
13
|
+
startWithMicMuted?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* When false, do not mute the mic (local + server) while the assistant is responding.
|
|
16
|
+
* Use with open-mic demos (`startWithMicMuted={false}`). Berify hold-to-talk keeps this true.
|
|
17
|
+
*/
|
|
18
|
+
holdMicDuringAssistant?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function useVoiceMicState(
|
|
22
|
+
voice: UseNxtlinqVoiceResult,
|
|
23
|
+
isVoiceConnecting: boolean,
|
|
24
|
+
options?: UseVoiceMicStateOptions,
|
|
25
|
+
) {
|
|
26
|
+
const connectMuted = options?.startWithMicMuted !== false;
|
|
27
|
+
const holdDuringAssistant = options?.holdMicDuringAssistant !== false;
|
|
28
|
+
const userMicMutedRef = useRef(connectMuted);
|
|
29
|
+
const assistantMicHoldRef = useRef(false);
|
|
30
|
+
const userMicOptInRef = useRef(!connectMuted);
|
|
31
|
+
const [isMicMuted, setIsMicMuted] = useState(connectMuted);
|
|
32
|
+
|
|
33
|
+
const applyMicState = useCallback(() => {
|
|
34
|
+
const shouldMute = userMicMutedRef.current || assistantMicHoldRef.current;
|
|
35
|
+
voice.muteMic(shouldMute);
|
|
36
|
+
setIsMicMuted(shouldMute);
|
|
37
|
+
}, [voice]);
|
|
38
|
+
|
|
39
|
+
const resetMicState = useCallback(() => {
|
|
40
|
+
userMicMutedRef.current = false;
|
|
41
|
+
assistantMicHoldRef.current = false;
|
|
42
|
+
userMicOptInRef.current = false;
|
|
43
|
+
setIsMicMuted(false);
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const prepareForVoiceConnect = useCallback(() => {
|
|
47
|
+
userMicMutedRef.current = connectMuted;
|
|
48
|
+
userMicOptInRef.current = !connectMuted;
|
|
49
|
+
assistantMicHoldRef.current = false;
|
|
50
|
+
setIsMicMuted(connectMuted);
|
|
51
|
+
voice.muteMic(connectMuted);
|
|
52
|
+
}, [voice, connectMuted]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!isVoiceConnecting) return;
|
|
56
|
+
userMicMutedRef.current = connectMuted;
|
|
57
|
+
voice.muteMic(connectMuted);
|
|
58
|
+
setIsMicMuted(connectMuted);
|
|
59
|
+
}, [isVoiceConnecting, voice, connectMuted]);
|
|
60
|
+
|
|
61
|
+
const prevVoiceStatusRef = useRef(voice.voiceStatus);
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const status = voice.voiceStatus;
|
|
65
|
+
const prev = prevVoiceStatusRef.current;
|
|
66
|
+
prevVoiceStatusRef.current = status;
|
|
67
|
+
|
|
68
|
+
if (holdDuringAssistant && ASSISTANT_MIC_HOLD_STATUSES.has(status)) {
|
|
69
|
+
assistantMicHoldRef.current = true;
|
|
70
|
+
applyMicState();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (status === 'listening' || status === 'idle') {
|
|
74
|
+
assistantMicHoldRef.current = false;
|
|
75
|
+
if (
|
|
76
|
+
connectMuted
|
|
77
|
+
&& status === 'listening'
|
|
78
|
+
&& prev === 'speaking'
|
|
79
|
+
&& !userMicOptInRef.current
|
|
80
|
+
) {
|
|
81
|
+
userMicMutedRef.current = true;
|
|
82
|
+
}
|
|
83
|
+
applyMicState();
|
|
84
|
+
}
|
|
85
|
+
}, [voice.voiceStatus, applyMicState, holdDuringAssistant]);
|
|
86
|
+
|
|
87
|
+
const toggleVoiceMicMute = useCallback(() => {
|
|
88
|
+
if (!voice.isVoiceActive && !isVoiceConnecting) return;
|
|
89
|
+
if (assistantMicHoldRef.current && userMicMutedRef.current) return;
|
|
90
|
+
if (assistantMicHoldRef.current) {
|
|
91
|
+
userMicMutedRef.current = true;
|
|
92
|
+
applyMicState();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const nextMuted = !userMicMutedRef.current;
|
|
96
|
+
userMicMutedRef.current = nextMuted;
|
|
97
|
+
userMicOptInRef.current = !nextMuted;
|
|
98
|
+
applyMicState();
|
|
99
|
+
}, [voice.isVoiceActive, isVoiceConnecting, applyMicState]);
|
|
100
|
+
|
|
101
|
+
const clearAssistantMicHold = useCallback(() => {
|
|
102
|
+
assistantMicHoldRef.current = false;
|
|
103
|
+
applyMicState();
|
|
104
|
+
}, [applyMicState]);
|
|
105
|
+
|
|
106
|
+
const isMicHeldForAssistant = ASSISTANT_MIC_HOLD_STATUSES.has(voice.voiceStatus);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
isMicMuted,
|
|
110
|
+
isMicHeldForAssistant,
|
|
111
|
+
toggleVoiceMicMute,
|
|
112
|
+
prepareForVoiceConnect,
|
|
113
|
+
resetMicState,
|
|
114
|
+
clearAssistantMicHold,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { VoiceStatus } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
+
|
|
3
|
+
/** Pause mic while assistant pipeline runs (aligned with web {@link useVoiceMode}). */
|
|
4
|
+
export const ASSISTANT_MIC_HOLD_STATUSES: ReadonlySet<VoiceStatus> = new Set([
|
|
5
|
+
'transcribing',
|
|
6
|
+
'thinking',
|
|
7
|
+
'generating',
|
|
8
|
+
'speaking',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export const SPEAKER_ACTIVE_STATUSES: ReadonlySet<VoiceStatus> = new Set([
|
|
12
|
+
'generating',
|
|
13
|
+
'speaking',
|
|
14
|
+
]);
|