@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,105 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { StyleSheet } from 'react-native';
|
|
3
|
+
import { useAgentAssistant } from '../context/AgentAssistantContext';
|
|
4
|
+
import {
|
|
5
|
+
applyRemoteAudioPlaybackGain,
|
|
6
|
+
DEFAULT_REMOTE_AUDIO_GAIN,
|
|
7
|
+
} from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
8
|
+
|
|
9
|
+
type RTCViewComponent = React.ComponentType<{
|
|
10
|
+
streamURL: string;
|
|
11
|
+
objectFit?: string;
|
|
12
|
+
style?: object;
|
|
13
|
+
zOrder?: number;
|
|
14
|
+
}>;
|
|
15
|
+
|
|
16
|
+
let RTCView: RTCViewComponent | null = null;
|
|
17
|
+
try {
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
|
+
RTCView = require('react-native-webrtc').RTCView as RTCViewComponent;
|
|
20
|
+
} catch {
|
|
21
|
+
RTCView = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type StreamWithUrl = {
|
|
25
|
+
toURL?: () => string;
|
|
26
|
+
getAudioTracks?: () => Array<{ enabled: boolean; kind?: string }>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type AgentRemoteAudioProps = {
|
|
30
|
+
/**
|
|
31
|
+
* Playback gain for assistant WebRTC audio (`MediaStreamTrack._setVolume`, 0–10).
|
|
32
|
+
* Text TTS uses react-native-video at volume 1 and sounds louder by default.
|
|
33
|
+
*/
|
|
34
|
+
remoteAudioGain?: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Plays remote WebRTC assistant audio. On iOS/Android, {@link RTCView} is required
|
|
39
|
+
* (enabling tracks alone is not enough).
|
|
40
|
+
*/
|
|
41
|
+
export function AgentRemoteAudio({
|
|
42
|
+
remoteAudioGain = DEFAULT_REMOTE_AUDIO_GAIN,
|
|
43
|
+
}: AgentRemoteAudioProps): React.ReactElement | null {
|
|
44
|
+
const { getRemoteAudioStream, interactionMode, voiceSessionId, voiceStatus } =
|
|
45
|
+
useAgentAssistant();
|
|
46
|
+
const [streamUrl, setStreamUrl] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
if (interactionMode !== 'voice' || !voiceSessionId) {
|
|
50
|
+
setStreamUrl(null);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const sync = () => {
|
|
55
|
+
const stream = getRemoteAudioStream() as StreamWithUrl | null;
|
|
56
|
+
applyRemoteAudioPlaybackGain(
|
|
57
|
+
stream as Parameters<typeof applyRemoteAudioPlaybackGain>[0],
|
|
58
|
+
remoteAudioGain,
|
|
59
|
+
);
|
|
60
|
+
const tracks = stream?.getAudioTracks?.() ?? [];
|
|
61
|
+
for (const track of tracks) {
|
|
62
|
+
if (track.kind === 'audio' || track.kind == null) {
|
|
63
|
+
track.enabled = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const url = stream?.toURL?.() ?? null;
|
|
67
|
+
setStreamUrl((prev) => (prev === url ? prev : url));
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
sync();
|
|
71
|
+
const id = setInterval(sync, 150);
|
|
72
|
+
return () => clearInterval(id);
|
|
73
|
+
}, [
|
|
74
|
+
getRemoteAudioStream,
|
|
75
|
+
interactionMode,
|
|
76
|
+
voiceSessionId,
|
|
77
|
+
voiceStatus,
|
|
78
|
+
remoteAudioGain,
|
|
79
|
+
]);
|
|
80
|
+
|
|
81
|
+
if (!RTCView || !streamUrl || interactionMode !== 'voice') {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<RTCView
|
|
87
|
+
key={streamUrl}
|
|
88
|
+
streamURL={streamUrl}
|
|
89
|
+
objectFit="cover"
|
|
90
|
+
zOrder={-1}
|
|
91
|
+
style={styles.hidden}
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const styles = StyleSheet.create({
|
|
97
|
+
hidden: {
|
|
98
|
+
position: 'absolute',
|
|
99
|
+
width: 1,
|
|
100
|
+
height: 1,
|
|
101
|
+
opacity: 0.01,
|
|
102
|
+
left: -1000,
|
|
103
|
+
top: -1000,
|
|
104
|
+
},
|
|
105
|
+
});
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
ActivityIndicator,
|
|
4
|
+
Pressable,
|
|
5
|
+
StyleSheet,
|
|
6
|
+
Text,
|
|
7
|
+
View,
|
|
8
|
+
} from 'react-native';
|
|
9
|
+
import {
|
|
10
|
+
useAgentAssistant,
|
|
11
|
+
voiceStatusLabel,
|
|
12
|
+
} from '../context/AgentAssistantContext';
|
|
13
|
+
import { SPEAKER_ACTIVE_STATUSES } from '../voice/voiceMicConstants';
|
|
14
|
+
import { MicIcon, MicOffIcon, SpeakerIcon, StopIcon } from './VoiceIcons';
|
|
15
|
+
|
|
16
|
+
function statusDotColor(
|
|
17
|
+
status: ReturnType<typeof useAgentAssistant>['voiceStatus'],
|
|
18
|
+
theme: ReturnType<typeof useAgentAssistant>['theme'],
|
|
19
|
+
): string {
|
|
20
|
+
switch (status) {
|
|
21
|
+
case 'listening':
|
|
22
|
+
return theme.colors.voiceActive;
|
|
23
|
+
case 'speaking':
|
|
24
|
+
return theme.colors.voiceSpeaking;
|
|
25
|
+
case 'idle':
|
|
26
|
+
return theme.colors.mutedText;
|
|
27
|
+
default:
|
|
28
|
+
return theme.colors.primary;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function AgentVoiceBar(): React.ReactElement | null {
|
|
33
|
+
const {
|
|
34
|
+
theme,
|
|
35
|
+
interactionMode,
|
|
36
|
+
voiceStatus,
|
|
37
|
+
voiceSessionId,
|
|
38
|
+
isVoiceConnecting,
|
|
39
|
+
isVoiceChannelReady,
|
|
40
|
+
stopVoice,
|
|
41
|
+
interrupt,
|
|
42
|
+
isVoiceAvailable,
|
|
43
|
+
isMicMuted,
|
|
44
|
+
isMicHeldForAssistant,
|
|
45
|
+
toggleVoiceMicMute,
|
|
46
|
+
} = useAgentAssistant();
|
|
47
|
+
|
|
48
|
+
const [speakerPulseOpacity, setSpeakerPulseOpacity] = useState(1);
|
|
49
|
+
const isSpeakerActive = SPEAKER_ACTIVE_STATUSES.has(voiceStatus);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!isSpeakerActive) {
|
|
53
|
+
setSpeakerPulseOpacity(1);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
let high = true;
|
|
57
|
+
const id = setInterval(() => {
|
|
58
|
+
setSpeakerPulseOpacity(high ? 0.35 : 1);
|
|
59
|
+
high = !high;
|
|
60
|
+
}, 600);
|
|
61
|
+
return () => clearInterval(id);
|
|
62
|
+
}, [isSpeakerActive]);
|
|
63
|
+
|
|
64
|
+
const returnToTextMode = useCallback(() => {
|
|
65
|
+
void stopVoice();
|
|
66
|
+
}, [stopVoice]);
|
|
67
|
+
|
|
68
|
+
if (!isVoiceAvailable) return null;
|
|
69
|
+
if (interactionMode !== 'voice' && !isVoiceConnecting) return null;
|
|
70
|
+
|
|
71
|
+
const showConnecting = isVoiceConnecting;
|
|
72
|
+
const awaitingChannel =
|
|
73
|
+
Boolean(voiceSessionId) &&
|
|
74
|
+
!isVoiceConnecting &&
|
|
75
|
+
!isVoiceChannelReady &&
|
|
76
|
+
voiceStatus === 'idle';
|
|
77
|
+
|
|
78
|
+
const statusHint = showConnecting
|
|
79
|
+
? 'Tap Back to text mode below to cancel'
|
|
80
|
+
: awaitingChannel
|
|
81
|
+
? 'Waiting for voice channel…'
|
|
82
|
+
: isMicHeldForAssistant
|
|
83
|
+
? 'Mic paused while assistant responds (use Interrupt to speak)'
|
|
84
|
+
: isMicMuted
|
|
85
|
+
? 'Mic is off — tap the mic when ready to speak'
|
|
86
|
+
: voiceStatus === 'listening'
|
|
87
|
+
? 'Start speaking'
|
|
88
|
+
: voiceStatus === 'speaking'
|
|
89
|
+
? 'Assistant is speaking…'
|
|
90
|
+
: voiceStatus === 'thinking'
|
|
91
|
+
? 'Thinking…'
|
|
92
|
+
: '';
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<View
|
|
96
|
+
style={[
|
|
97
|
+
styles.container,
|
|
98
|
+
{
|
|
99
|
+
borderTopColor: theme.colors.border,
|
|
100
|
+
backgroundColor: theme.colors.surface,
|
|
101
|
+
padding: theme.spacing.md,
|
|
102
|
+
},
|
|
103
|
+
]}
|
|
104
|
+
>
|
|
105
|
+
<View style={styles.mainRow}>
|
|
106
|
+
<View
|
|
107
|
+
style={[
|
|
108
|
+
styles.statusPill,
|
|
109
|
+
{ borderColor: theme.colors.border, backgroundColor: theme.colors.background },
|
|
110
|
+
]}
|
|
111
|
+
>
|
|
112
|
+
<View style={styles.statusPillHeader}>
|
|
113
|
+
<View
|
|
114
|
+
style={[
|
|
115
|
+
styles.dot,
|
|
116
|
+
{ backgroundColor: statusDotColor(voiceStatus, theme) },
|
|
117
|
+
]}
|
|
118
|
+
/>
|
|
119
|
+
<Text
|
|
120
|
+
style={{
|
|
121
|
+
color: theme.colors.assistantText,
|
|
122
|
+
fontSize: theme.typography.captionSize,
|
|
123
|
+
fontWeight: '600',
|
|
124
|
+
flexShrink: 1,
|
|
125
|
+
}}
|
|
126
|
+
numberOfLines={1}
|
|
127
|
+
>
|
|
128
|
+
{showConnecting
|
|
129
|
+
? 'Connecting'
|
|
130
|
+
: awaitingChannel
|
|
131
|
+
? 'Connecting'
|
|
132
|
+
: voiceStatusLabel(voiceStatus)}
|
|
133
|
+
</Text>
|
|
134
|
+
{showConnecting ? (
|
|
135
|
+
<ActivityIndicator color={theme.colors.primary} size="small" />
|
|
136
|
+
) : null}
|
|
137
|
+
</View>
|
|
138
|
+
{statusHint ? (
|
|
139
|
+
<Text
|
|
140
|
+
style={{
|
|
141
|
+
color: theme.colors.mutedText,
|
|
142
|
+
fontSize: theme.typography.captionSize - 1,
|
|
143
|
+
flexShrink: 1,
|
|
144
|
+
alignSelf: 'stretch',
|
|
145
|
+
}}
|
|
146
|
+
numberOfLines={2}
|
|
147
|
+
>
|
|
148
|
+
{statusHint}
|
|
149
|
+
</Text>
|
|
150
|
+
) : null}
|
|
151
|
+
</View>
|
|
152
|
+
|
|
153
|
+
<View style={styles.iconRow}>
|
|
154
|
+
<View style={{ opacity: isSpeakerActive ? speakerPulseOpacity : 0.35 }}>
|
|
155
|
+
<SpeakerIcon size={24} color={theme.colors.voiceSpeaking} />
|
|
156
|
+
</View>
|
|
157
|
+
|
|
158
|
+
<Pressable
|
|
159
|
+
onPress={toggleVoiceMicMute}
|
|
160
|
+
disabled={showConnecting || awaitingChannel || isMicHeldForAssistant}
|
|
161
|
+
style={({ pressed }) => [
|
|
162
|
+
styles.iconButton,
|
|
163
|
+
{
|
|
164
|
+
opacity:
|
|
165
|
+
pressed || showConnecting || awaitingChannel || isMicHeldForAssistant
|
|
166
|
+
? 0.45
|
|
167
|
+
: 1,
|
|
168
|
+
},
|
|
169
|
+
]}
|
|
170
|
+
>
|
|
171
|
+
{isMicMuted ? (
|
|
172
|
+
<MicOffIcon size={24} color="#ef4444" />
|
|
173
|
+
) : (
|
|
174
|
+
<MicIcon size={24} color={theme.colors.assistantText} />
|
|
175
|
+
)}
|
|
176
|
+
</Pressable>
|
|
177
|
+
|
|
178
|
+
<Pressable
|
|
179
|
+
onPress={() => interrupt()}
|
|
180
|
+
disabled={showConnecting || awaitingChannel}
|
|
181
|
+
style={({ pressed }) => [
|
|
182
|
+
styles.iconButton,
|
|
183
|
+
{ opacity: pressed || showConnecting || awaitingChannel ? 0.45 : 1 },
|
|
184
|
+
]}
|
|
185
|
+
>
|
|
186
|
+
<StopIcon size={24} color={theme.colors.assistantText} />
|
|
187
|
+
</Pressable>
|
|
188
|
+
</View>
|
|
189
|
+
</View>
|
|
190
|
+
|
|
191
|
+
<Pressable onPress={returnToTextMode} style={styles.textLink}>
|
|
192
|
+
<Text style={{ color: theme.colors.primary, fontSize: theme.typography.captionSize }}>
|
|
193
|
+
Back to text mode
|
|
194
|
+
</Text>
|
|
195
|
+
</Pressable>
|
|
196
|
+
</View>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const styles = StyleSheet.create({
|
|
201
|
+
container: { borderTopWidth: StyleSheet.hairlineWidth },
|
|
202
|
+
mainRow: {
|
|
203
|
+
flexDirection: 'row',
|
|
204
|
+
alignItems: 'center',
|
|
205
|
+
justifyContent: 'space-between',
|
|
206
|
+
gap: 10,
|
|
207
|
+
},
|
|
208
|
+
statusPill: {
|
|
209
|
+
flex: 1,
|
|
210
|
+
minWidth: 0,
|
|
211
|
+
flexDirection: 'column',
|
|
212
|
+
alignItems: 'flex-start',
|
|
213
|
+
gap: 4,
|
|
214
|
+
paddingHorizontal: 10,
|
|
215
|
+
paddingVertical: 8,
|
|
216
|
+
borderRadius: 12,
|
|
217
|
+
borderWidth: 1,
|
|
218
|
+
},
|
|
219
|
+
statusPillHeader: {
|
|
220
|
+
flexDirection: 'row',
|
|
221
|
+
alignItems: 'center',
|
|
222
|
+
gap: 6,
|
|
223
|
+
alignSelf: 'stretch',
|
|
224
|
+
},
|
|
225
|
+
dot: { width: 8, height: 8, borderRadius: 4 },
|
|
226
|
+
iconRow: { flexDirection: 'row', alignItems: 'center', gap: 2 },
|
|
227
|
+
iconButton: {
|
|
228
|
+
padding: 8,
|
|
229
|
+
borderRadius: 8,
|
|
230
|
+
},
|
|
231
|
+
textLink: { marginTop: 10, alignSelf: 'center' },
|
|
232
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import { useAgentAssistant } from '../context/AgentAssistantContext';
|
|
4
|
+
|
|
5
|
+
export function PresetMessageChips(): React.ReactElement | null {
|
|
6
|
+
const { presetMessages, selectPreset, theme, interactionMode } = useAgentAssistant();
|
|
7
|
+
|
|
8
|
+
if (!presetMessages.length || interactionMode === 'voice') {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<View
|
|
14
|
+
style={[
|
|
15
|
+
styles.container,
|
|
16
|
+
{
|
|
17
|
+
borderBottomColor: theme.colors.border,
|
|
18
|
+
backgroundColor: theme.colors.surface,
|
|
19
|
+
},
|
|
20
|
+
]}
|
|
21
|
+
>
|
|
22
|
+
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.scroll}>
|
|
23
|
+
{presetMessages.map((preset, index) => (
|
|
24
|
+
<Pressable
|
|
25
|
+
key={`${preset.text}-${index}`}
|
|
26
|
+
onPress={() => void selectPreset(preset)}
|
|
27
|
+
style={({ pressed }: { pressed: boolean }) => [
|
|
28
|
+
styles.chip,
|
|
29
|
+
{
|
|
30
|
+
borderColor: theme.colors.border,
|
|
31
|
+
backgroundColor: pressed ? theme.colors.border : theme.colors.background,
|
|
32
|
+
borderRadius: theme.radius.button,
|
|
33
|
+
},
|
|
34
|
+
]}
|
|
35
|
+
>
|
|
36
|
+
<Text
|
|
37
|
+
style={{
|
|
38
|
+
color: theme.colors.assistantText,
|
|
39
|
+
fontSize: theme.typography.captionSize,
|
|
40
|
+
}}
|
|
41
|
+
numberOfLines={1}
|
|
42
|
+
>
|
|
43
|
+
{preset.text}
|
|
44
|
+
</Text>
|
|
45
|
+
</Pressable>
|
|
46
|
+
))}
|
|
47
|
+
</ScrollView>
|
|
48
|
+
</View>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const styles = StyleSheet.create({
|
|
53
|
+
container: {
|
|
54
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
55
|
+
paddingVertical: 8,
|
|
56
|
+
},
|
|
57
|
+
scroll: { paddingHorizontal: 12, gap: 8 },
|
|
58
|
+
chip: {
|
|
59
|
+
borderWidth: 1,
|
|
60
|
+
paddingHorizontal: 12,
|
|
61
|
+
paddingVertical: 8,
|
|
62
|
+
marginRight: 8,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
View,
|
|
9
|
+
} from 'react-native';
|
|
10
|
+
import { useAgentAssistant } from '../context/AgentAssistantContext';
|
|
11
|
+
import type { VoiceAutoGreetConfig } from '../voice/useVoiceAutoGreet';
|
|
12
|
+
|
|
13
|
+
function buildGreetingText(config: VoiceAutoGreetConfig): string {
|
|
14
|
+
if (config.text?.trim()) return config.text.trim();
|
|
15
|
+
const product = config.productName?.trim();
|
|
16
|
+
return product
|
|
17
|
+
? `Hi! Please give me a brief introduction to ${product}.`
|
|
18
|
+
: 'Hi! Please say hello and introduce yourself.';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type VoiceGreetTriggerProps = {
|
|
22
|
+
config: VoiceAutoGreetConfig;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* P0 / QA: manually fire the same payload as {@link useVoiceAutoGreet} (product image + intro text).
|
|
27
|
+
*/
|
|
28
|
+
export function VoiceGreetTrigger({ config }: VoiceGreetTriggerProps): React.ReactElement | null {
|
|
29
|
+
const {
|
|
30
|
+
theme,
|
|
31
|
+
interactionMode,
|
|
32
|
+
isVoiceActive,
|
|
33
|
+
isVoiceChannelReady,
|
|
34
|
+
triggerVoiceGreeting,
|
|
35
|
+
} = useAgentAssistant();
|
|
36
|
+
const [busy, setBusy] = useState(false);
|
|
37
|
+
const [error, setError] = useState<string | null>(null);
|
|
38
|
+
const [hint, setHint] = useState<string | null>(null);
|
|
39
|
+
|
|
40
|
+
const sendGreet = useCallback(async () => {
|
|
41
|
+
if (!isVoiceActive) {
|
|
42
|
+
setError('Start voice mode first');
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (!isVoiceChannelReady) {
|
|
46
|
+
setError('Voice channel not ready — wait for Listening');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
setBusy(true);
|
|
50
|
+
setError(null);
|
|
51
|
+
setHint(null);
|
|
52
|
+
try {
|
|
53
|
+
const imageUrl = config.productImageUrl?.trim();
|
|
54
|
+
let attachments = config.attachments;
|
|
55
|
+
if (!attachments?.length && imageUrl) {
|
|
56
|
+
try {
|
|
57
|
+
attachments = [await uriToVoiceImageAttachment(imageUrl, 'product.jpg')];
|
|
58
|
+
} catch (imgErr) {
|
|
59
|
+
console.warn('[nxtlinq] greet image skipped:', imgErr);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
await triggerVoiceGreeting(
|
|
63
|
+
{
|
|
64
|
+
text: buildGreetingText(config),
|
|
65
|
+
attachments,
|
|
66
|
+
skipUserMessage: config.skipUserMessage ?? true,
|
|
67
|
+
},
|
|
68
|
+
{ waitForChannel: true, timeoutMs: 12000 },
|
|
69
|
+
);
|
|
70
|
+
setHint('Opening greet sent');
|
|
71
|
+
} catch (e) {
|
|
72
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
73
|
+
setError(message);
|
|
74
|
+
console.warn('[nxtlinq] triggerVoiceGreeting failed:', message);
|
|
75
|
+
} finally {
|
|
76
|
+
setBusy(false);
|
|
77
|
+
}
|
|
78
|
+
}, [
|
|
79
|
+
config,
|
|
80
|
+
isVoiceActive,
|
|
81
|
+
isVoiceChannelReady,
|
|
82
|
+
triggerVoiceGreeting,
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
if (interactionMode !== 'voice') return null;
|
|
86
|
+
|
|
87
|
+
const imageNote = config.productImageUrl?.trim()
|
|
88
|
+
? '(含產品圖)'
|
|
89
|
+
: '(僅文字)';
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<View
|
|
93
|
+
style={[
|
|
94
|
+
styles.box,
|
|
95
|
+
{
|
|
96
|
+
borderTopColor: theme.colors.border,
|
|
97
|
+
backgroundColor: theme.colors.background,
|
|
98
|
+
padding: theme.spacing.sm,
|
|
99
|
+
},
|
|
100
|
+
]}
|
|
101
|
+
>
|
|
102
|
+
<Text style={{ color: theme.colors.mutedText, fontSize: theme.typography.captionSize }}>
|
|
103
|
+
Voice opening greet (manual test){imageNote}
|
|
104
|
+
</Text>
|
|
105
|
+
{!isVoiceChannelReady ? (
|
|
106
|
+
<Text
|
|
107
|
+
style={{
|
|
108
|
+
color: theme.colors.mutedText,
|
|
109
|
+
fontSize: theme.typography.captionSize,
|
|
110
|
+
marginTop: theme.spacing.xs,
|
|
111
|
+
}}
|
|
112
|
+
>
|
|
113
|
+
Waiting for voice channel…
|
|
114
|
+
</Text>
|
|
115
|
+
) : null}
|
|
116
|
+
{error ? (
|
|
117
|
+
<Text style={{ color: theme.colors.error, fontSize: theme.typography.captionSize }}>
|
|
118
|
+
{error}
|
|
119
|
+
</Text>
|
|
120
|
+
) : null}
|
|
121
|
+
{hint ? (
|
|
122
|
+
<Text style={{ color: theme.colors.primary, fontSize: theme.typography.captionSize }}>
|
|
123
|
+
{hint}
|
|
124
|
+
</Text>
|
|
125
|
+
) : null}
|
|
126
|
+
<Pressable
|
|
127
|
+
onPress={() => void sendGreet()}
|
|
128
|
+
disabled={busy || !isVoiceChannelReady}
|
|
129
|
+
style={({ pressed }) => [
|
|
130
|
+
styles.btn,
|
|
131
|
+
{
|
|
132
|
+
backgroundColor: theme.colors.voiceSpeaking,
|
|
133
|
+
marginTop: theme.spacing.xs,
|
|
134
|
+
opacity: pressed || busy || !isVoiceChannelReady ? 0.6 : 1,
|
|
135
|
+
},
|
|
136
|
+
]}
|
|
137
|
+
>
|
|
138
|
+
{busy ? (
|
|
139
|
+
<ActivityIndicator color={theme.colors.primaryText} size="small" />
|
|
140
|
+
) : (
|
|
141
|
+
<Text style={{ color: theme.colors.primaryText, fontWeight: '600' }}>
|
|
142
|
+
Send opening greet
|
|
143
|
+
</Text>
|
|
144
|
+
)}
|
|
145
|
+
</Pressable>
|
|
146
|
+
</View>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const styles = StyleSheet.create({
|
|
151
|
+
box: { borderTopWidth: StyleSheet.hairlineWidth },
|
|
152
|
+
btn: {
|
|
153
|
+
paddingHorizontal: 14,
|
|
154
|
+
paddingVertical: 10,
|
|
155
|
+
borderRadius: 8,
|
|
156
|
+
alignItems: 'center',
|
|
157
|
+
},
|
|
158
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
|
3
|
+
|
|
4
|
+
type IconProps = {
|
|
5
|
+
size?: number;
|
|
6
|
+
color?: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type MaterialIconProps = IconProps & { name: string };
|
|
10
|
+
|
|
11
|
+
/** Typed wrapper — avoids React 18 JSX/classic Component mismatch in package `tsc`. */
|
|
12
|
+
const MaterialIcon = MaterialIcons as unknown as React.ComponentType<MaterialIconProps>;
|
|
13
|
+
|
|
14
|
+
export function MicIcon({ size = 24, color = '#4b5563' }: IconProps): React.ReactElement {
|
|
15
|
+
return <MaterialIcon name="mic" size={size} color={color} />;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function MicOffIcon({ size = 24, color = '#ef4444' }: IconProps): React.ReactElement {
|
|
19
|
+
return <MaterialIcon name="mic-off" size={size} color={color} />;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function StopIcon({ size = 24, color = '#4b5563' }: IconProps): React.ReactElement {
|
|
23
|
+
return <MaterialIcon name="stop" size={size} color={color} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Loudspeaker — assistant audio output (Material volume-up). */
|
|
27
|
+
export function SpeakerIcon({
|
|
28
|
+
size = 24,
|
|
29
|
+
color = '#ec4899',
|
|
30
|
+
}: IconProps): React.ReactElement {
|
|
31
|
+
return <MaterialIcon name="volume-up" size={size} color={color} />;
|
|
32
|
+
}
|