@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,178 @@
|
|
|
1
|
+
import { uriToVoiceImageAttachment } from '@bytexbyte/nxtlinq-ai-agent-react-native-development';
|
|
2
|
+
import React, { useCallback, useState } from 'react';
|
|
3
|
+
import {
|
|
4
|
+
ActivityIndicator,
|
|
5
|
+
Pressable,
|
|
6
|
+
StyleSheet,
|
|
7
|
+
Text,
|
|
8
|
+
TextInput,
|
|
9
|
+
View,
|
|
10
|
+
} from 'react-native';
|
|
11
|
+
import { useAgentAssistant } from '../context/AgentAssistantContext';
|
|
12
|
+
|
|
13
|
+
export type VoiceImageInputProps = {
|
|
14
|
+
/** Demo default image URL (HTTPS) for one-tap send. */
|
|
15
|
+
demoImageUrl?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const IMAGE_ONLY_HINT =
|
|
19
|
+
'Please describe this image or tell me what you would like to know about it.';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* P0 test UI: send image (optional text) during an active voice session (SDK-2).
|
|
23
|
+
*/
|
|
24
|
+
export function VoiceImageInput({ demoImageUrl }: VoiceImageInputProps): React.ReactElement | null {
|
|
25
|
+
const {
|
|
26
|
+
theme,
|
|
27
|
+
interactionMode,
|
|
28
|
+
isVoiceChannelReady,
|
|
29
|
+
sendVoiceUserInput,
|
|
30
|
+
} = useAgentAssistant();
|
|
31
|
+
const [imageUrl, setImageUrl] = useState(demoImageUrl ?? '');
|
|
32
|
+
const [busy, setBusy] = useState(false);
|
|
33
|
+
const [error, setError] = useState<string | null>(null);
|
|
34
|
+
const [sentHint, setSentHint] = useState<string | null>(null);
|
|
35
|
+
|
|
36
|
+
const sendImage = useCallback(
|
|
37
|
+
async (url: string) => {
|
|
38
|
+
const trimmed = url.trim();
|
|
39
|
+
if (!trimmed) {
|
|
40
|
+
setError('Enter an image URL');
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!isVoiceChannelReady) {
|
|
44
|
+
setError('Voice channel is not ready yet — wait for Listening');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
setBusy(true);
|
|
48
|
+
setError(null);
|
|
49
|
+
setSentHint(null);
|
|
50
|
+
try {
|
|
51
|
+
const attachment = await uriToVoiceImageAttachment(trimmed, 'voice-image.jpg');
|
|
52
|
+
sendVoiceUserInput({
|
|
53
|
+
text: IMAGE_ONLY_HINT,
|
|
54
|
+
attachments: [attachment],
|
|
55
|
+
clientMessageId: `user_img_${Date.now()}`,
|
|
56
|
+
});
|
|
57
|
+
setSentHint('Image sent');
|
|
58
|
+
} catch (e) {
|
|
59
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
60
|
+
setError(message);
|
|
61
|
+
console.warn('[nxtlinq] sendVoiceUserInput image failed:', message);
|
|
62
|
+
} finally {
|
|
63
|
+
setBusy(false);
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
[isVoiceChannelReady, sendVoiceUserInput],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (interactionMode !== 'voice') return null;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<View
|
|
73
|
+
style={[
|
|
74
|
+
styles.box,
|
|
75
|
+
{
|
|
76
|
+
borderTopColor: theme.colors.border,
|
|
77
|
+
backgroundColor: theme.colors.background,
|
|
78
|
+
padding: theme.spacing.sm,
|
|
79
|
+
},
|
|
80
|
+
]}
|
|
81
|
+
>
|
|
82
|
+
<Text style={{ color: theme.colors.mutedText, fontSize: theme.typography.captionSize }}>
|
|
83
|
+
Send image during voice (P0 test)
|
|
84
|
+
</Text>
|
|
85
|
+
{!isVoiceChannelReady ? (
|
|
86
|
+
<Text
|
|
87
|
+
style={{
|
|
88
|
+
color: theme.colors.mutedText,
|
|
89
|
+
fontSize: theme.typography.captionSize,
|
|
90
|
+
marginTop: theme.spacing.xs,
|
|
91
|
+
}}
|
|
92
|
+
>
|
|
93
|
+
Waiting for voice channel…
|
|
94
|
+
</Text>
|
|
95
|
+
) : null}
|
|
96
|
+
<TextInput
|
|
97
|
+
value={imageUrl}
|
|
98
|
+
onChangeText={setImageUrl}
|
|
99
|
+
placeholder="HTTPS image URL or data URI"
|
|
100
|
+
placeholderTextColor={theme.colors.mutedText}
|
|
101
|
+
autoCapitalize="none"
|
|
102
|
+
style={[
|
|
103
|
+
styles.input,
|
|
104
|
+
{
|
|
105
|
+
borderColor: theme.colors.border,
|
|
106
|
+
color: theme.colors.assistantText,
|
|
107
|
+
marginTop: theme.spacing.xs,
|
|
108
|
+
},
|
|
109
|
+
]}
|
|
110
|
+
/>
|
|
111
|
+
{error ? (
|
|
112
|
+
<Text style={{ color: theme.colors.error, fontSize: theme.typography.captionSize }}>
|
|
113
|
+
{error}
|
|
114
|
+
</Text>
|
|
115
|
+
) : null}
|
|
116
|
+
{sentHint ? (
|
|
117
|
+
<Text style={{ color: theme.colors.primary, fontSize: theme.typography.captionSize }}>
|
|
118
|
+
{sentHint}
|
|
119
|
+
</Text>
|
|
120
|
+
) : null}
|
|
121
|
+
<View style={[styles.row, { marginTop: theme.spacing.xs }]}>
|
|
122
|
+
<Pressable
|
|
123
|
+
onPress={() => sendImage(imageUrl)}
|
|
124
|
+
disabled={busy || !isVoiceChannelReady}
|
|
125
|
+
style={({ pressed }) => [
|
|
126
|
+
styles.btn,
|
|
127
|
+
{
|
|
128
|
+
backgroundColor: theme.colors.primary,
|
|
129
|
+
opacity: pressed || busy || !isVoiceChannelReady ? 0.6 : 1,
|
|
130
|
+
},
|
|
131
|
+
]}
|
|
132
|
+
>
|
|
133
|
+
{busy ? (
|
|
134
|
+
<ActivityIndicator color={theme.colors.primaryText} size="small" />
|
|
135
|
+
) : (
|
|
136
|
+
<Text style={{ color: theme.colors.primaryText, fontWeight: '600' }}>Send image</Text>
|
|
137
|
+
)}
|
|
138
|
+
</Pressable>
|
|
139
|
+
{demoImageUrl ? (
|
|
140
|
+
<Pressable
|
|
141
|
+
onPress={() => sendImage(demoImageUrl)}
|
|
142
|
+
disabled={busy || !isVoiceChannelReady}
|
|
143
|
+
style={({ pressed }) => [
|
|
144
|
+
styles.btn,
|
|
145
|
+
{
|
|
146
|
+
backgroundColor: theme.colors.surface,
|
|
147
|
+
borderWidth: 1,
|
|
148
|
+
borderColor: theme.colors.border,
|
|
149
|
+
opacity: pressed || busy || !isVoiceChannelReady ? 0.6 : 1,
|
|
150
|
+
},
|
|
151
|
+
]}
|
|
152
|
+
>
|
|
153
|
+
<Text style={{ color: theme.colors.assistantText }}>Product image</Text>
|
|
154
|
+
</Pressable>
|
|
155
|
+
) : null}
|
|
156
|
+
</View>
|
|
157
|
+
</View>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const styles = StyleSheet.create({
|
|
162
|
+
box: { borderTopWidth: StyleSheet.hairlineWidth },
|
|
163
|
+
input: {
|
|
164
|
+
borderWidth: 1,
|
|
165
|
+
borderRadius: 8,
|
|
166
|
+
paddingHorizontal: 10,
|
|
167
|
+
paddingVertical: 8,
|
|
168
|
+
fontSize: 14,
|
|
169
|
+
},
|
|
170
|
+
row: { flexDirection: 'row', gap: 8 },
|
|
171
|
+
btn: {
|
|
172
|
+
paddingHorizontal: 14,
|
|
173
|
+
paddingVertical: 8,
|
|
174
|
+
borderRadius: 8,
|
|
175
|
+
minWidth: 72,
|
|
176
|
+
alignItems: 'center',
|
|
177
|
+
},
|
|
178
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { StyleSheet, View } from 'react-native';
|
|
3
|
+
import { useAgentAssistant } from '../context/AgentAssistantContext';
|
|
4
|
+
import {
|
|
5
|
+
WAVEFORM_ACTIVE_STATUSES,
|
|
6
|
+
WAVEFORM_AUDIBLE_HOLD_MS,
|
|
7
|
+
WAVEFORM_AUDIBLE_THRESHOLD,
|
|
8
|
+
} from '../voice/voiceWaveformConstants';
|
|
9
|
+
|
|
10
|
+
const BAR_COUNT = 12;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Polls `getOutputAudioLevel()` while the assistant is speaking (Berify-aligned).
|
|
14
|
+
* JeannieVoiceWaveform uses `active && audible` — not during user mic / idle.
|
|
15
|
+
*/
|
|
16
|
+
export function VoiceWaveform(): React.ReactElement | null {
|
|
17
|
+
const { theme, interactionMode, voiceStatus, getOutputAudioLevel } = useAgentAssistant();
|
|
18
|
+
const [levels, setLevels] = useState<number[]>(() => Array(BAR_COUNT).fill(0));
|
|
19
|
+
const lastAudibleAtRef = useRef(0);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (interactionMode !== 'voice') {
|
|
23
|
+
setLevels(Array(BAR_COUNT).fill(0));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const id = setInterval(() => {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const statusActive = WAVEFORM_ACTIVE_STATUSES.has(voiceStatus);
|
|
30
|
+
const rawLevel = getOutputAudioLevel();
|
|
31
|
+
if (rawLevel > WAVEFORM_AUDIBLE_THRESHOLD) {
|
|
32
|
+
lastAudibleAtRef.current = now;
|
|
33
|
+
}
|
|
34
|
+
const audible = now - lastAudibleAtRef.current < WAVEFORM_AUDIBLE_HOLD_MS;
|
|
35
|
+
const effectiveActive = statusActive && audible;
|
|
36
|
+
const scaled = effectiveActive ? rawLevel : 0;
|
|
37
|
+
setLevels((prev) => [...prev.slice(1), scaled]);
|
|
38
|
+
}, 100);
|
|
39
|
+
return () => clearInterval(id);
|
|
40
|
+
}, [interactionMode, voiceStatus, getOutputAudioLevel]);
|
|
41
|
+
|
|
42
|
+
if (interactionMode !== 'voice') return null;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<View
|
|
46
|
+
style={[
|
|
47
|
+
styles.row,
|
|
48
|
+
{
|
|
49
|
+
paddingHorizontal: theme.spacing.md,
|
|
50
|
+
paddingBottom: theme.spacing.sm,
|
|
51
|
+
backgroundColor: theme.colors.surface,
|
|
52
|
+
},
|
|
53
|
+
]}
|
|
54
|
+
>
|
|
55
|
+
{levels.map((level, i) => (
|
|
56
|
+
<View
|
|
57
|
+
key={`bar-${i}`}
|
|
58
|
+
style={[
|
|
59
|
+
styles.bar,
|
|
60
|
+
{
|
|
61
|
+
height: 8 + level * 28,
|
|
62
|
+
backgroundColor: theme.colors.voiceSpeaking,
|
|
63
|
+
opacity: 0.35 + level * 0.65,
|
|
64
|
+
},
|
|
65
|
+
]}
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
</View>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const styles = StyleSheet.create({
|
|
73
|
+
row: {
|
|
74
|
+
flexDirection: 'row',
|
|
75
|
+
alignItems: 'flex-end',
|
|
76
|
+
justifyContent: 'center',
|
|
77
|
+
gap: 3,
|
|
78
|
+
minHeight: 40,
|
|
79
|
+
},
|
|
80
|
+
bar: {
|
|
81
|
+
width: 4,
|
|
82
|
+
borderRadius: 2,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import type { VoiceStatus } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
+
import {
|
|
3
|
+
useNxtlinqAgent,
|
|
4
|
+
useNxtlinqVoice,
|
|
5
|
+
type UseNxtlinqAgentResult,
|
|
6
|
+
type UseNxtlinqVoiceResult,
|
|
7
|
+
} from '@bytexbyte/nxtlinq-ai-agent-react-native-development';
|
|
8
|
+
import React, {
|
|
9
|
+
createContext,
|
|
10
|
+
useCallback,
|
|
11
|
+
useContext,
|
|
12
|
+
useEffect,
|
|
13
|
+
useMemo,
|
|
14
|
+
useState,
|
|
15
|
+
type ReactNode,
|
|
16
|
+
} from 'react';
|
|
17
|
+
import { defaultAgentAssistantTheme } from '../theme/defaultTheme';
|
|
18
|
+
import type { AgentAssistantTheme, NxtlinqAgentAssistantProps, PresetMessage } from '../types';
|
|
19
|
+
import { useVoiceMicState } from '../voice/useVoiceMicState';
|
|
20
|
+
import { waitForIOSVoiceCaptureRelease } from '@bytexbyte/nxtlinq-ai-agent-react-native-development';
|
|
21
|
+
import { isTextTtsPlayerSupported, TextTtsPlayer, type TextTtsPlayerHandle } from '../voice/TextTtsPlayer';
|
|
22
|
+
|
|
23
|
+
export type InteractionMode = 'text' | 'voice';
|
|
24
|
+
|
|
25
|
+
export type AgentAssistantContextValue = UseNxtlinqAgentResult &
|
|
26
|
+
UseNxtlinqVoiceResult & {
|
|
27
|
+
theme: AgentAssistantTheme;
|
|
28
|
+
title: string;
|
|
29
|
+
placeholder: string;
|
|
30
|
+
presetMessages: PresetMessage[];
|
|
31
|
+
interactionMode: InteractionMode;
|
|
32
|
+
setInteractionMode: (mode: InteractionMode) => void;
|
|
33
|
+
inputText: string;
|
|
34
|
+
setInputText: (text: string) => void;
|
|
35
|
+
isVoiceAvailable: boolean;
|
|
36
|
+
isVoiceConnecting: boolean;
|
|
37
|
+
isMicMuted: boolean;
|
|
38
|
+
isMicHeldForAssistant: boolean;
|
|
39
|
+
toggleVoiceMicMute: () => void;
|
|
40
|
+
sendText: () => Promise<void>;
|
|
41
|
+
selectPreset: (preset: PresetMessage) => Promise<void>;
|
|
42
|
+
/** True when voice data channel is open and ready for user_input. */
|
|
43
|
+
isVoiceChannelReady: boolean;
|
|
44
|
+
postTextTts: (text: string) => Promise<{ audio: ArrayBuffer; mimeType: string }>;
|
|
45
|
+
buildTextTtsPlaybackUri: (result: { audio: ArrayBuffer; mimeType: string }) => string;
|
|
46
|
+
playMessageTts: (messageId: string, text: string) => Promise<void>;
|
|
47
|
+
playingMessageId: string | null;
|
|
48
|
+
isTextTtsAvailable: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const AgentAssistantContext = createContext<AgentAssistantContextValue | null>(null);
|
|
52
|
+
|
|
53
|
+
export type AgentAssistantProviderProps = {
|
|
54
|
+
children: ReactNode;
|
|
55
|
+
ui: Pick<
|
|
56
|
+
NxtlinqAgentAssistantProps,
|
|
57
|
+
| 'title'
|
|
58
|
+
| 'placeholder'
|
|
59
|
+
| 'presetMessages'
|
|
60
|
+
| 'enableVoice'
|
|
61
|
+
| 'theme'
|
|
62
|
+
| 'startWithMicMuted'
|
|
63
|
+
| 'holdMicDuringAssistant'
|
|
64
|
+
| 'textTtsVolume'
|
|
65
|
+
| 'voiceRemoteAudioGain'
|
|
66
|
+
> & {
|
|
67
|
+
webrtcEnabled: boolean;
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function AgentAssistantProvider({
|
|
72
|
+
children,
|
|
73
|
+
ui,
|
|
74
|
+
}: AgentAssistantProviderProps): React.ReactElement {
|
|
75
|
+
const agent = useNxtlinqAgent();
|
|
76
|
+
const voice = useNxtlinqVoice();
|
|
77
|
+
|
|
78
|
+
const theme = useMemo(
|
|
79
|
+
() => ({
|
|
80
|
+
...defaultAgentAssistantTheme,
|
|
81
|
+
...ui.theme,
|
|
82
|
+
colors: { ...defaultAgentAssistantTheme.colors, ...ui.theme?.colors },
|
|
83
|
+
spacing: { ...defaultAgentAssistantTheme.spacing, ...ui.theme?.spacing },
|
|
84
|
+
radius: { ...defaultAgentAssistantTheme.radius, ...ui.theme?.radius },
|
|
85
|
+
typography: {
|
|
86
|
+
...defaultAgentAssistantTheme.typography,
|
|
87
|
+
...ui.theme?.typography,
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
[ui.theme],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const [interactionMode, setInteractionModeState] = useState<InteractionMode>('text');
|
|
94
|
+
const [inputText, setInputText] = useState('');
|
|
95
|
+
const [isVoiceConnecting, setIsVoiceConnecting] = useState(false);
|
|
96
|
+
const [playingMessageId, setPlayingMessageId] = useState<string | null>(null);
|
|
97
|
+
const textTtsPlayerRef = React.useRef<TextTtsPlayerHandle | null>(null);
|
|
98
|
+
const ttsRequestIdRef = React.useRef(0);
|
|
99
|
+
const voiceConnectChainRef = React.useRef<Promise<unknown>>(Promise.resolve());
|
|
100
|
+
|
|
101
|
+
const isVoiceAvailable = Boolean(ui.webrtcEnabled && ui.enableVoice !== false);
|
|
102
|
+
const micStartsMuted = ui.startWithMicMuted ?? true;
|
|
103
|
+
|
|
104
|
+
const {
|
|
105
|
+
isMicMuted,
|
|
106
|
+
isMicHeldForAssistant,
|
|
107
|
+
toggleVoiceMicMute,
|
|
108
|
+
prepareForVoiceConnect,
|
|
109
|
+
resetMicState,
|
|
110
|
+
clearAssistantMicHold,
|
|
111
|
+
} = useVoiceMicState(voice, isVoiceConnecting, {
|
|
112
|
+
startWithMicMuted: micStartsMuted,
|
|
113
|
+
// Decoupled: open-mic demo can start unmuted yet still mute during assistant TTS.
|
|
114
|
+
holdMicDuringAssistant: ui.holdMicDuringAssistant ?? true,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const setInteractionMode = useCallback(
|
|
118
|
+
(mode: InteractionMode) => {
|
|
119
|
+
if (mode === 'text' && interactionMode === 'voice') {
|
|
120
|
+
void (async () => {
|
|
121
|
+
await voice.stopVoice('switch_to_text');
|
|
122
|
+
await waitForIOSVoiceCaptureRelease();
|
|
123
|
+
resetMicState();
|
|
124
|
+
})();
|
|
125
|
+
}
|
|
126
|
+
setInteractionModeState(mode);
|
|
127
|
+
},
|
|
128
|
+
[interactionMode, voice, resetMicState],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (interactionMode === 'text' && voice.voiceSessionId != null) {
|
|
133
|
+
void (async () => {
|
|
134
|
+
await voice.stopVoice('mode_text_cleanup');
|
|
135
|
+
await waitForIOSVoiceCaptureRelease();
|
|
136
|
+
resetMicState();
|
|
137
|
+
})();
|
|
138
|
+
}
|
|
139
|
+
}, [interactionMode, voice.voiceSessionId, voice, resetMicState]);
|
|
140
|
+
|
|
141
|
+
const sendText = useCallback(async () => {
|
|
142
|
+
const text = inputText.trim();
|
|
143
|
+
if (!text || agent.isLoading) return;
|
|
144
|
+
setInputText('');
|
|
145
|
+
await agent.sendMessage(text);
|
|
146
|
+
}, [agent, inputText]);
|
|
147
|
+
|
|
148
|
+
const selectPreset = useCallback(
|
|
149
|
+
async (preset: PresetMessage) => {
|
|
150
|
+
if (preset.autoSend) {
|
|
151
|
+
await agent.sendMessage(preset.text);
|
|
152
|
+
} else {
|
|
153
|
+
setInputText(preset.text);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
[agent],
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const wrappedStartVoice = useCallback(
|
|
160
|
+
(options?: Parameters<typeof voice.startVoice>[0]) => {
|
|
161
|
+
const connect = async () => {
|
|
162
|
+
if (voice.voiceSessionId != null) {
|
|
163
|
+
await voice.stopVoice('restart_voice');
|
|
164
|
+
await waitForIOSVoiceCaptureRelease();
|
|
165
|
+
}
|
|
166
|
+
prepareForVoiceConnect();
|
|
167
|
+
setInteractionModeState('voice');
|
|
168
|
+
setIsVoiceConnecting(true);
|
|
169
|
+
try {
|
|
170
|
+
const session = await voice.startVoice({
|
|
171
|
+
startWithMicMuted: micStartsMuted,
|
|
172
|
+
...options,
|
|
173
|
+
onOpen: () => {
|
|
174
|
+
if (!micStartsMuted) {
|
|
175
|
+
voice.muteMic(false);
|
|
176
|
+
}
|
|
177
|
+
options?.onOpen?.();
|
|
178
|
+
},
|
|
179
|
+
onClose: (reason) => {
|
|
180
|
+
resetMicState();
|
|
181
|
+
const userInitiated =
|
|
182
|
+
reason === 'switch_to_text' ||
|
|
183
|
+
reason === 'client_stop' ||
|
|
184
|
+
reason === 'mode_text_cleanup';
|
|
185
|
+
if (userInitiated) {
|
|
186
|
+
setInteractionModeState('text');
|
|
187
|
+
} else {
|
|
188
|
+
console.warn('[nxtlinq] voice session closed:', reason);
|
|
189
|
+
}
|
|
190
|
+
options?.onClose?.(reason);
|
|
191
|
+
},
|
|
192
|
+
onError: (err) => {
|
|
193
|
+
resetMicState();
|
|
194
|
+
console.warn('[nxtlinq] voice session error:', err.message);
|
|
195
|
+
options?.onError?.(err);
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
return session;
|
|
199
|
+
} catch (err) {
|
|
200
|
+
setInteractionModeState('text');
|
|
201
|
+
resetMicState();
|
|
202
|
+
throw err;
|
|
203
|
+
} finally {
|
|
204
|
+
setIsVoiceConnecting(false);
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
const next = voiceConnectChainRef.current.then(connect, connect);
|
|
208
|
+
voiceConnectChainRef.current = next.catch(() => undefined);
|
|
209
|
+
return next;
|
|
210
|
+
},
|
|
211
|
+
[voice, prepareForVoiceConnect, resetMicState, micStartsMuted, ui.voiceRemoteAudioGain],
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
const wrappedStopVoice = useCallback(async () => {
|
|
215
|
+
await voice.stopVoice('client_stop');
|
|
216
|
+
await waitForIOSVoiceCaptureRelease();
|
|
217
|
+
resetMicState();
|
|
218
|
+
setInteractionModeState('text');
|
|
219
|
+
}, [voice, resetMicState]);
|
|
220
|
+
|
|
221
|
+
const wrappedInterrupt = useCallback(() => {
|
|
222
|
+
voice.interrupt();
|
|
223
|
+
clearAssistantMicHold();
|
|
224
|
+
}, [voice, clearAssistantMicHold]);
|
|
225
|
+
|
|
226
|
+
const isVoiceChannelReady =
|
|
227
|
+
interactionMode === 'voice' &&
|
|
228
|
+
voice.voiceSessionId != null &&
|
|
229
|
+
(Boolean(agent.agent.getVoiceSession()?.isAppChannelOpen()) ||
|
|
230
|
+
voice.voiceStatus === 'listening');
|
|
231
|
+
|
|
232
|
+
const postTextTts = useCallback(
|
|
233
|
+
(text: string) => agent.agent.postTextTts(text),
|
|
234
|
+
[agent.agent],
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
const buildTextTtsPlaybackUri = useCallback(
|
|
238
|
+
(result: { audio: ArrayBuffer; mimeType: string }) =>
|
|
239
|
+
agent.agent.buildTextTtsPlaybackUri(result),
|
|
240
|
+
[agent.agent],
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
const playMessageTts = useCallback(
|
|
244
|
+
async (messageId: string, text: string) => {
|
|
245
|
+
const trimmed = text.trim();
|
|
246
|
+
if (!trimmed) return;
|
|
247
|
+
const player = textTtsPlayerRef.current;
|
|
248
|
+
if (!player?.isAvailable) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
'Text TTS playback requires react-native-video in your app (see Berify Jeannie).',
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
const requestId = ++ttsRequestIdRef.current;
|
|
254
|
+
player.stop();
|
|
255
|
+
setPlayingMessageId(messageId);
|
|
256
|
+
try {
|
|
257
|
+
const result = await postTextTts(trimmed);
|
|
258
|
+
if (ttsRequestIdRef.current !== requestId) return;
|
|
259
|
+
player.play(result);
|
|
260
|
+
} catch (err) {
|
|
261
|
+
if (ttsRequestIdRef.current === requestId) {
|
|
262
|
+
setPlayingMessageId(null);
|
|
263
|
+
}
|
|
264
|
+
throw err;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
[postTextTts],
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const isTextTtsAvailable = isTextTtsPlayerSupported();
|
|
271
|
+
|
|
272
|
+
const value = useMemo<AgentAssistantContextValue>(
|
|
273
|
+
() => ({
|
|
274
|
+
...agent,
|
|
275
|
+
...voice,
|
|
276
|
+
isVoiceActive:
|
|
277
|
+
interactionMode === 'voice' && voice.voiceSessionId != null,
|
|
278
|
+
startVoice: wrappedStartVoice,
|
|
279
|
+
stopVoice: wrappedStopVoice,
|
|
280
|
+
interrupt: wrappedInterrupt,
|
|
281
|
+
isMicMuted,
|
|
282
|
+
isMicHeldForAssistant,
|
|
283
|
+
toggleVoiceMicMute,
|
|
284
|
+
theme,
|
|
285
|
+
title: ui.title ?? 'AI Assistant',
|
|
286
|
+
placeholder: ui.placeholder ?? 'Type a message…',
|
|
287
|
+
presetMessages: ui.presetMessages ?? [],
|
|
288
|
+
interactionMode,
|
|
289
|
+
setInteractionMode,
|
|
290
|
+
inputText,
|
|
291
|
+
setInputText,
|
|
292
|
+
isVoiceAvailable,
|
|
293
|
+
isVoiceConnecting,
|
|
294
|
+
isVoiceChannelReady,
|
|
295
|
+
postTextTts,
|
|
296
|
+
buildTextTtsPlaybackUri,
|
|
297
|
+
playMessageTts,
|
|
298
|
+
playingMessageId,
|
|
299
|
+
isTextTtsAvailable,
|
|
300
|
+
sendText,
|
|
301
|
+
selectPreset,
|
|
302
|
+
}),
|
|
303
|
+
[
|
|
304
|
+
agent,
|
|
305
|
+
voice,
|
|
306
|
+
wrappedStartVoice,
|
|
307
|
+
wrappedStopVoice,
|
|
308
|
+
wrappedInterrupt,
|
|
309
|
+
isMicMuted,
|
|
310
|
+
isMicHeldForAssistant,
|
|
311
|
+
toggleVoiceMicMute,
|
|
312
|
+
theme,
|
|
313
|
+
ui.title,
|
|
314
|
+
ui.placeholder,
|
|
315
|
+
ui.presetMessages,
|
|
316
|
+
interactionMode,
|
|
317
|
+
inputText,
|
|
318
|
+
isVoiceAvailable,
|
|
319
|
+
isVoiceConnecting,
|
|
320
|
+
isVoiceChannelReady,
|
|
321
|
+
postTextTts,
|
|
322
|
+
buildTextTtsPlaybackUri,
|
|
323
|
+
playMessageTts,
|
|
324
|
+
playingMessageId,
|
|
325
|
+
isTextTtsAvailable,
|
|
326
|
+
sendText,
|
|
327
|
+
selectPreset,
|
|
328
|
+
voice.voiceStatus,
|
|
329
|
+
],
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<AgentAssistantContext.Provider value={value}>
|
|
334
|
+
{children}
|
|
335
|
+
<TextTtsPlayer
|
|
336
|
+
playerRef={textTtsPlayerRef}
|
|
337
|
+
volume={ui.textTtsVolume ?? 1}
|
|
338
|
+
onPlayingChange={(playing) => {
|
|
339
|
+
if (!playing) setPlayingMessageId(null);
|
|
340
|
+
}}
|
|
341
|
+
onError={(message) => {
|
|
342
|
+
console.warn('[nxtlinq] TTS playback:', message);
|
|
343
|
+
}}
|
|
344
|
+
/>
|
|
345
|
+
</AgentAssistantContext.Provider>
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function useAgentAssistant(): AgentAssistantContextValue {
|
|
350
|
+
const ctx = useContext(AgentAssistantContext);
|
|
351
|
+
if (!ctx) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
'useAgentAssistant must be used within <NxtlinqAgentAssistant> or <AgentAssistantProvider>',
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
return ctx;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function voiceStatusLabel(status: VoiceStatus): string {
|
|
360
|
+
const labels: Record<VoiceStatus, string> = {
|
|
361
|
+
idle: 'Ready',
|
|
362
|
+
listening: 'Listening',
|
|
363
|
+
transcribing: 'Transcribing',
|
|
364
|
+
thinking: 'Thinking',
|
|
365
|
+
generating: 'Generating',
|
|
366
|
+
speaking: 'Speaking',
|
|
367
|
+
};
|
|
368
|
+
return labels[status] ?? status;
|
|
369
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export { NxtlinqAgentAssistant } from './NxtlinqAgentAssistant';
|
|
2
|
+
|
|
3
|
+
export type {
|
|
4
|
+
NxtlinqAgentAssistantProps,
|
|
5
|
+
AgentAssistantTheme,
|
|
6
|
+
PresetMessage,
|
|
7
|
+
Message,
|
|
8
|
+
ToolUse,
|
|
9
|
+
} from './types';
|
|
10
|
+
|
|
11
|
+
export { defaultAgentAssistantTheme } from './theme/defaultTheme';
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
AgentAssistantProvider,
|
|
15
|
+
useAgentAssistant,
|
|
16
|
+
voiceStatusLabel,
|
|
17
|
+
type AgentAssistantContextValue,
|
|
18
|
+
type InteractionMode,
|
|
19
|
+
} from './context/AgentAssistantContext';
|
|
20
|
+
|
|
21
|
+
export { AgentMessageList } from './components/AgentMessageList';
|
|
22
|
+
export { AgentComposer } from './components/AgentComposer';
|
|
23
|
+
export { AgentVoiceBar } from './components/AgentVoiceBar';
|
|
24
|
+
export { AgentRemoteAudio } from './components/AgentRemoteAudio';
|
|
25
|
+
export { AudioSessionWaker } from './voice/AudioSessionWaker';
|
|
26
|
+
export { PresetMessageChips } from './components/PresetMessageChips';
|
|
27
|
+
export { AgentAssistantShell } from './components/AgentAssistantShell';
|
|
28
|
+
export { VoiceGreetTrigger } from './components/VoiceGreetTrigger';
|
|
29
|
+
|
|
30
|
+
// Headless SDK re-exports for apps that compose custom layouts
|
|
31
|
+
export {
|
|
32
|
+
NxtlinqAgentProvider,
|
|
33
|
+
useNxtlinqAgent,
|
|
34
|
+
useNxtlinqVoice,
|
|
35
|
+
createNxtlinqAgentRN,
|
|
36
|
+
type NxtlinqAgentProviderProps,
|
|
37
|
+
type RNWebRTCModule,
|
|
38
|
+
} from '@bytexbyte/nxtlinq-ai-agent-react-native-development';
|
|
39
|
+
|
|
40
|
+
export type {
|
|
41
|
+
AgentEnvironment,
|
|
42
|
+
AgentConfig,
|
|
43
|
+
Attachment,
|
|
44
|
+
AgentResponse,
|
|
45
|
+
SendMessageOptions,
|
|
46
|
+
NxtlinqAgentSnapshot,
|
|
47
|
+
StartVoiceSessionOptions,
|
|
48
|
+
VoiceSession,
|
|
49
|
+
VoiceStatus,
|
|
50
|
+
} from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
51
|
+
|
|
52
|
+
export {
|
|
53
|
+
NxtlinqAgent,
|
|
54
|
+
setApiHosts,
|
|
55
|
+
VoiceNotSupportedError,
|
|
56
|
+
mapServerHistoryToMessages,
|
|
57
|
+
appendServerHistoryIntoMessages,
|
|
58
|
+
STORAGE_KEYS,
|
|
59
|
+
} from '@bytexbyte/nxtlinq-ai-agent-core-development';
|