@bytexbyte/nxtlinq-ai-agent-ui-react-native-development 0.2.0 → 0.3.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/dist/NxtlinqAgentAssistant.d.ts +4 -4
- package/dist/NxtlinqAgentAssistant.d.ts.map +1 -1
- package/dist/NxtlinqAgentAssistant.js +5 -6
- package/dist/components/AgentAssistantShell.d.ts +1 -3
- package/dist/components/AgentAssistantShell.d.ts.map +1 -1
- package/dist/components/AgentAssistantShell.js +3 -7
- package/dist/components/AgentMessageList.d.ts.map +1 -1
- package/dist/components/AgentMessageList.js +7 -9
- package/dist/components/AgentVoiceBar.d.ts.map +1 -1
- package/dist/components/AgentVoiceBar.js +14 -34
- package/dist/components/MessageAttachmentPreview.d.ts +10 -0
- package/dist/components/MessageAttachmentPreview.d.ts.map +1 -0
- package/dist/components/MessageAttachmentPreview.js +15 -0
- package/dist/components/VoiceAddMediaModal.d.ts +12 -0
- package/dist/components/VoiceAddMediaModal.d.ts.map +1 -0
- package/dist/components/VoiceAddMediaModal.js +31 -0
- package/dist/components/VoiceAttachmentButton.d.ts +3 -0
- package/dist/components/VoiceAttachmentButton.d.ts.map +1 -0
- package/dist/components/VoiceAttachmentButton.js +58 -0
- package/dist/components/VoiceIcons.d.ts +1 -0
- package/dist/components/VoiceIcons.d.ts.map +1 -1
- package/dist/components/VoiceIcons.js +3 -0
- package/dist/components/VoiceWaveform.d.ts +2 -2
- package/dist/components/VoiceWaveform.d.ts.map +1 -1
- package/dist/components/VoiceWaveform.js +16 -5
- package/dist/components/useMessageListAutoScroll.d.ts +12 -0
- package/dist/components/useMessageListAutoScroll.d.ts.map +1 -0
- package/dist/components/useMessageListAutoScroll.js +42 -0
- package/dist/context/AgentAssistantContext.d.ts +3 -3
- package/dist/context/AgentAssistantContext.d.ts.map +1 -1
- package/dist/context/AgentAssistantContext.js +76 -29
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/types.d.ts +3 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/voice/float32ToPcm16.d.ts +2 -0
- package/dist/voice/float32ToPcm16.d.ts.map +1 -0
- package/dist/voice/float32ToPcm16.js +8 -0
- package/dist/voice/loadImageCropPicker.d.ts +11 -0
- package/dist/voice/loadImageCropPicker.d.ts.map +1 -0
- package/dist/voice/loadImageCropPicker.js +12 -0
- package/dist/voice/sendVoiceImageAttachment.d.ts +15 -0
- package/dist/voice/sendVoiceImageAttachment.d.ts.map +1 -0
- package/dist/voice/sendVoiceImageAttachment.js +29 -0
- package/dist/voice/useVoiceImagePicker.d.ts +11 -0
- package/dist/voice/useVoiceImagePicker.d.ts.map +1 -0
- package/dist/voice/useVoiceImagePicker.js +38 -0
- package/dist/voice/useVoiceMicState.d.ts +4 -0
- package/dist/voice/useVoiceMicState.d.ts.map +1 -1
- package/dist/voice/useVoiceMicState.js +32 -3
- package/dist/voice/useVoiceSilenceCommit.d.ts +10 -0
- package/dist/voice/useVoiceSilenceCommit.d.ts.map +1 -0
- package/dist/voice/useVoiceSilenceCommit.js +76 -0
- package/dist/voice/useVoiceTranscriptMessages.d.ts +16 -0
- package/dist/voice/useVoiceTranscriptMessages.d.ts.map +1 -0
- package/dist/voice/useVoiceTranscriptMessages.js +129 -0
- package/dist/voice/useWsRealtimeAudio.d.ts +17 -0
- package/dist/voice/useWsRealtimeAudio.d.ts.map +1 -0
- package/dist/voice/useWsRealtimeAudio.js +165 -0
- package/dist/voice/voiceImagePickerOptions.d.ts +11 -0
- package/dist/voice/voiceImagePickerOptions.d.ts.map +1 -0
- package/dist/voice/voiceImagePickerOptions.js +10 -0
- package/dist/voice/voiceSilenceConstants.d.ts +8 -0
- package/dist/voice/voiceSilenceConstants.d.ts.map +1 -0
- package/dist/voice/voiceSilenceConstants.js +7 -0
- package/dist/voice/wsPcmPlayer.d.ts +24 -0
- package/dist/voice/wsPcmPlayer.d.ts.map +1 -0
- package/dist/voice/wsPcmPlayer.js +146 -0
- package/dist/voice/wsPcmRecorder.d.ts +26 -0
- package/dist/voice/wsPcmRecorder.d.ts.map +1 -0
- package/dist/voice/wsPcmRecorder.js +145 -0
- package/dist/voice/wsRealtimeConstants.d.ts +2 -0
- package/dist/voice/wsRealtimeConstants.d.ts.map +1 -0
- package/dist/voice/wsRealtimeConstants.js +1 -0
- package/package.json +8 -5
- package/src/NxtlinqAgentAssistant.tsx +3 -12
- package/src/components/AgentAssistantShell.tsx +2 -18
- package/src/components/AgentMessageList.tsx +18 -15
- package/src/components/AgentVoiceBar.tsx +35 -70
- package/src/components/MessageAttachmentPreview.tsx +43 -0
- package/src/components/VoiceAddMediaModal.tsx +69 -0
- package/src/components/VoiceAttachmentButton.tsx +100 -0
- package/src/components/VoiceIcons.tsx +4 -0
- package/src/components/VoiceWaveform.tsx +15 -5
- package/src/components/useMessageListAutoScroll.ts +57 -0
- package/src/context/AgentAssistantContext.tsx +100 -32
- package/src/index.ts +2 -2
- package/src/react-native.d.ts +18 -1
- package/src/types.ts +3 -8
- package/src/voice/float32ToPcm16.ts +8 -0
- package/src/voice/loadImageCropPicker.ts +18 -0
- package/src/voice/sendVoiceImageAttachment.ts +49 -0
- package/src/voice/useVoiceImagePicker.ts +54 -0
- package/src/voice/useVoiceMicState.ts +38 -3
- package/src/voice/useVoiceSilenceCommit.ts +94 -0
- package/src/voice/useVoiceTranscriptMessages.ts +173 -0
- package/src/voice/useWsRealtimeAudio.ts +200 -0
- package/src/voice/voiceImagePickerOptions.ts +10 -0
- package/src/voice/voiceSilenceConstants.ts +10 -0
- package/src/voice/wsPcmPlayer.ts +166 -0
- package/src/voice/wsPcmRecorder.ts +152 -0
- package/src/voice/wsRealtimeConstants.ts +1 -0
- package/src/components/AgentRemoteAudio.tsx +0 -105
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { AudioManager, AudioRecorder, } from 'react-native-audio-api';
|
|
2
|
+
import { float32ToPcm16 } from './float32ToPcm16';
|
|
3
|
+
import { WS_REALTIME_SAMPLE_RATE } from './wsRealtimeConstants';
|
|
4
|
+
/**
|
|
5
|
+
* Berify {@link WavRecorder}-aligned lifecycle: reuse one native AudioRecorder,
|
|
6
|
+
* configure playAndRecord on start, reset to playback on stop.
|
|
7
|
+
*/
|
|
8
|
+
export class WsPcmRecorder {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.recorder = null;
|
|
11
|
+
this.recording = false;
|
|
12
|
+
this.audioReadyAttached = false;
|
|
13
|
+
this.session = null;
|
|
14
|
+
}
|
|
15
|
+
async initialize(options) {
|
|
16
|
+
if (options?.force && this.recorder) {
|
|
17
|
+
this.disposeRecorderInternal();
|
|
18
|
+
}
|
|
19
|
+
if (this.recorder)
|
|
20
|
+
return;
|
|
21
|
+
await AudioManager.requestRecordingPermissions();
|
|
22
|
+
this.recorder = new AudioRecorder({
|
|
23
|
+
sampleRate: WS_REALTIME_SAMPLE_RATE,
|
|
24
|
+
bufferLengthInSamples: WS_REALTIME_SAMPLE_RATE,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
bindSession(session) {
|
|
28
|
+
this.session = session;
|
|
29
|
+
}
|
|
30
|
+
setOnRms(handler) {
|
|
31
|
+
this.onRms = handler;
|
|
32
|
+
}
|
|
33
|
+
disposeRecorderInternal() {
|
|
34
|
+
if (!this.recorder)
|
|
35
|
+
return;
|
|
36
|
+
try {
|
|
37
|
+
this.recorder.stop();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
/* noop */
|
|
41
|
+
}
|
|
42
|
+
this.recorder = null;
|
|
43
|
+
this.audioReadyAttached = false;
|
|
44
|
+
this.recording = false;
|
|
45
|
+
this.resetSession();
|
|
46
|
+
}
|
|
47
|
+
async start() {
|
|
48
|
+
await this.initialize();
|
|
49
|
+
const recorder = this.recorder;
|
|
50
|
+
if (!recorder)
|
|
51
|
+
return;
|
|
52
|
+
if (this.recording) {
|
|
53
|
+
this.recording = false;
|
|
54
|
+
try {
|
|
55
|
+
recorder.stop();
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
/* noop */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
this.configureSession();
|
|
62
|
+
if (!this.audioReadyAttached) {
|
|
63
|
+
recorder.onAudioReady(({ buffer }) => {
|
|
64
|
+
this.handleAudioReady(buffer);
|
|
65
|
+
});
|
|
66
|
+
this.audioReadyAttached = true;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
recorder.start();
|
|
70
|
+
this.recording = true;
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
this.recording = false;
|
|
74
|
+
console.error('[WsPcmRecorder] start failed', err);
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
stop() {
|
|
79
|
+
if (!this.recorder || !this.recording)
|
|
80
|
+
return;
|
|
81
|
+
try {
|
|
82
|
+
this.recorder.stop();
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
console.warn('[WsPcmRecorder] stop', err);
|
|
86
|
+
}
|
|
87
|
+
this.recording = false;
|
|
88
|
+
this.resetSession();
|
|
89
|
+
}
|
|
90
|
+
cleanup() {
|
|
91
|
+
if (this.recorder) {
|
|
92
|
+
try {
|
|
93
|
+
if (this.recording) {
|
|
94
|
+
this.stop();
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
this.recorder.stop();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
/* noop */
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
this.recorder = null;
|
|
105
|
+
this.audioReadyAttached = false;
|
|
106
|
+
this.recording = false;
|
|
107
|
+
this.session = null;
|
|
108
|
+
this.onRms = undefined;
|
|
109
|
+
}
|
|
110
|
+
get isRecording() {
|
|
111
|
+
return this.recording;
|
|
112
|
+
}
|
|
113
|
+
configureSession() {
|
|
114
|
+
AudioManager.setAudioSessionOptions({
|
|
115
|
+
iosCategory: 'playAndRecord',
|
|
116
|
+
iosMode: 'voiceChat',
|
|
117
|
+
iosOptions: ['defaultToSpeaker', 'allowBluetoothA2DP'],
|
|
118
|
+
iosAllowHaptics: true,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
resetSession() {
|
|
122
|
+
try {
|
|
123
|
+
AudioManager.setAudioSessionOptions({
|
|
124
|
+
iosCategory: 'playback',
|
|
125
|
+
iosMode: 'default',
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
/* noop */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
handleAudioReady(buffer) {
|
|
133
|
+
const channel = buffer.getChannelData(0);
|
|
134
|
+
let sum = 0;
|
|
135
|
+
for (let i = 0; i < channel.length; i += 1) {
|
|
136
|
+
const sample = channel[i];
|
|
137
|
+
sum += sample * sample;
|
|
138
|
+
}
|
|
139
|
+
const rms = channel.length > 0 ? Math.sqrt(sum / channel.length) : 0;
|
|
140
|
+
this.onRms?.(rms);
|
|
141
|
+
if (!this.recording || !this.session?.appendInputAudio)
|
|
142
|
+
return;
|
|
143
|
+
this.session.appendInputAudio(float32ToPcm16(channel));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"wsRealtimeConstants.d.ts","sourceRoot":"","sources":["../../src/voice/wsRealtimeConstants.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,uBAAuB,QAAQ,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const WS_REALTIME_SAMPLE_RATE = 24000;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytexbyte/nxtlinq-ai-agent-ui-react-native-development",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Official React Native UI for nxtlinq AI Agent — drop-in assistant widget",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"react-native": "src/index.ts",
|
|
@@ -37,20 +37,22 @@
|
|
|
37
37
|
"@react-native-async-storage/async-storage": ">=1.17.0",
|
|
38
38
|
"react": ">=18.0.0",
|
|
39
39
|
"react-native": ">=0.72.0",
|
|
40
|
+
"react-native-audio-api": ">=0.10.3",
|
|
41
|
+
"react-native-image-crop-picker": ">=0.40.0",
|
|
40
42
|
"react-native-vector-icons": ">=10.0.0",
|
|
41
43
|
"react-native-video": ">=6.0.0"
|
|
42
44
|
},
|
|
43
45
|
"peerDependenciesMeta": {
|
|
44
|
-
"react-native-
|
|
46
|
+
"react-native-image-crop-picker": {
|
|
45
47
|
"optional": true
|
|
46
48
|
},
|
|
47
|
-
"react-native-
|
|
49
|
+
"react-native-video": {
|
|
48
50
|
"optional": true
|
|
49
51
|
}
|
|
50
52
|
},
|
|
51
53
|
"dependencies": {
|
|
52
|
-
"@bytexbyte/nxtlinq-ai-agent-core-development": "
|
|
53
|
-
"@bytexbyte/nxtlinq-ai-agent-react-native-development": "
|
|
54
|
+
"@bytexbyte/nxtlinq-ai-agent-core-development": "0.3.8",
|
|
55
|
+
"@bytexbyte/nxtlinq-ai-agent-react-native-development": "0.3.8"
|
|
54
56
|
},
|
|
55
57
|
"devDependencies": {
|
|
56
58
|
"@bytexbyte/nxtlinq-ai-agent-core-development": "workspace:^",
|
|
@@ -58,6 +60,7 @@
|
|
|
58
60
|
"@types/react": "^18.2.64",
|
|
59
61
|
"@types/react-native-vector-icons": "^6.4.18",
|
|
60
62
|
"react": "^18.2.0",
|
|
63
|
+
"react-native-audio-api": "^0.10.3",
|
|
61
64
|
"react-native-vector-icons": "^10.2.0",
|
|
62
65
|
"typescript": "^5.4.2"
|
|
63
66
|
}
|
|
@@ -9,18 +9,18 @@ import type { NxtlinqAgentAssistantProps } from './types';
|
|
|
9
9
|
* @example
|
|
10
10
|
* ```tsx
|
|
11
11
|
* import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
12
|
-
* import { RTCPeerConnection, mediaDevices } from 'react-native-webrtc';
|
|
13
12
|
* import { NxtlinqAgentAssistant } from '@bytexbyte/nxtlinq-ai-agent-ui-react-native-development';
|
|
14
13
|
*
|
|
15
14
|
* export default function Screen() {
|
|
16
15
|
* return (
|
|
17
16
|
* <NxtlinqAgentAssistant
|
|
18
17
|
* storage={AsyncStorage}
|
|
19
|
-
* webrtcModule={{ RTCPeerConnection, mediaDevices }}
|
|
20
18
|
* serviceId="..."
|
|
21
19
|
* apiKey="..."
|
|
22
20
|
* apiSecret="..."
|
|
23
|
-
*
|
|
21
|
+
* environment="staging"
|
|
22
|
+
* pseudoId={userId}
|
|
23
|
+
* conversationId={roomId}
|
|
24
24
|
* enableVoice
|
|
25
25
|
* loadHistoryOnMount
|
|
26
26
|
* />
|
|
@@ -43,7 +43,6 @@ export function NxtlinqAgentAssistant({
|
|
|
43
43
|
voiceDemoProductImageUrl,
|
|
44
44
|
voiceAutoGreet,
|
|
45
45
|
iosSilentAudioSource,
|
|
46
|
-
voiceRemoteAudioGain,
|
|
47
46
|
textTtsVolume,
|
|
48
47
|
theme,
|
|
49
48
|
style,
|
|
@@ -55,20 +54,14 @@ export function NxtlinqAgentAssistant({
|
|
|
55
54
|
storage,
|
|
56
55
|
fetchImpl,
|
|
57
56
|
getTimezone,
|
|
58
|
-
webrtcModule,
|
|
59
|
-
webrtc,
|
|
60
57
|
resetOnIdentityChange,
|
|
61
58
|
...agentConfig
|
|
62
59
|
}: NxtlinqAgentAssistantProps): React.ReactElement {
|
|
63
|
-
const webrtcEnabled = Boolean(webrtcModule || webrtc);
|
|
64
|
-
|
|
65
60
|
return (
|
|
66
61
|
<NxtlinqAgentProvider
|
|
67
62
|
storage={storage}
|
|
68
63
|
fetchImpl={fetchImpl}
|
|
69
64
|
getTimezone={getTimezone}
|
|
70
|
-
webrtcModule={webrtcModule}
|
|
71
|
-
webrtc={webrtc}
|
|
72
65
|
resetOnIdentityChange={resetOnIdentityChange}
|
|
73
66
|
onMessage={onMessage}
|
|
74
67
|
onError={onError}
|
|
@@ -90,12 +83,10 @@ export function NxtlinqAgentAssistant({
|
|
|
90
83
|
voiceDemoProductImageUrl={voiceDemoProductImageUrl}
|
|
91
84
|
voiceAutoGreet={voiceAutoGreet}
|
|
92
85
|
iosSilentAudioSource={iosSilentAudioSource}
|
|
93
|
-
voiceRemoteAudioGain={voiceRemoteAudioGain}
|
|
94
86
|
textTtsVolume={textTtsVolume}
|
|
95
87
|
theme={theme}
|
|
96
88
|
style={style}
|
|
97
89
|
headerStyle={headerStyle}
|
|
98
|
-
webrtcEnabled={webrtcEnabled}
|
|
99
90
|
/>
|
|
100
91
|
{children}
|
|
101
92
|
</NxtlinqAgentProvider>
|
|
@@ -7,11 +7,8 @@ import {
|
|
|
7
7
|
import type { NxtlinqAgentAssistantProps } from '../types';
|
|
8
8
|
import { AgentComposer } from './AgentComposer';
|
|
9
9
|
import { AgentMessageList } from './AgentMessageList';
|
|
10
|
-
import { AgentRemoteAudio } from './AgentRemoteAudio';
|
|
11
10
|
import { AgentVoiceBar } from './AgentVoiceBar';
|
|
12
11
|
import { PresetMessageChips } from './PresetMessageChips';
|
|
13
|
-
import { VoiceGreetTrigger } from './VoiceGreetTrigger';
|
|
14
|
-
import { VoiceImageInput } from './VoiceImageInput';
|
|
15
12
|
import { VoiceWaveform } from './VoiceWaveform';
|
|
16
13
|
import { AudioSessionWaker } from '../voice/AudioSessionWaker';
|
|
17
14
|
import { VoiceAutoGreetBinder } from '../voice/VoiceAutoGreetBinder';
|
|
@@ -35,11 +32,8 @@ export type AgentAssistantShellProps = Pick<
|
|
|
35
32
|
| 'voiceDemoProductImageUrl'
|
|
36
33
|
| 'voiceAutoGreet'
|
|
37
34
|
| 'iosSilentAudioSource'
|
|
38
|
-
| 'voiceRemoteAudioGain'
|
|
39
35
|
| 'textTtsVolume'
|
|
40
|
-
|
|
41
|
-
webrtcEnabled: boolean;
|
|
42
|
-
};
|
|
36
|
+
>;
|
|
43
37
|
|
|
44
38
|
function AgentAssistantInner({
|
|
45
39
|
title,
|
|
@@ -48,13 +42,11 @@ function AgentAssistantInner({
|
|
|
48
42
|
loadHistoryOnMount,
|
|
49
43
|
historyLast,
|
|
50
44
|
startInVoiceMode,
|
|
51
|
-
startWithMicMuted,
|
|
52
45
|
showVoiceWaveform,
|
|
53
46
|
showVoiceImageInput,
|
|
54
47
|
voiceDemoProductImageUrl,
|
|
55
48
|
voiceAutoGreet,
|
|
56
49
|
iosSilentAudioSource,
|
|
57
|
-
voiceRemoteAudioGain,
|
|
58
50
|
}: AgentAssistantShellProps): React.ReactElement {
|
|
59
51
|
const {
|
|
60
52
|
theme,
|
|
@@ -121,14 +113,7 @@ function AgentAssistantInner({
|
|
|
121
113
|
<AgentMessageList />
|
|
122
114
|
{showVoiceWaveform !== false ? <VoiceWaveform /> : null}
|
|
123
115
|
<AgentVoiceBar />
|
|
124
|
-
{showVoiceImageInput && autoGreetConfig ? (
|
|
125
|
-
<VoiceGreetTrigger config={autoGreetConfig} />
|
|
126
|
-
) : null}
|
|
127
|
-
{showVoiceImageInput ? (
|
|
128
|
-
<VoiceImageInput demoImageUrl={voiceDemoProductImageUrl} />
|
|
129
|
-
) : null}
|
|
130
116
|
<AgentComposer />
|
|
131
|
-
<AgentRemoteAudio remoteAudioGain={voiceRemoteAudioGain} />
|
|
132
117
|
{autoGreetConfig ? (
|
|
133
118
|
<VoiceAutoGreetBinder config={autoGreetConfig} historyReady={historyReady} />
|
|
134
119
|
) : null}
|
|
@@ -148,8 +133,7 @@ export function AgentAssistantShell(props: AgentAssistantShellProps): React.Reac
|
|
|
148
133
|
startWithMicMuted: props.startWithMicMuted,
|
|
149
134
|
holdMicDuringAssistant: props.holdMicDuringAssistant,
|
|
150
135
|
textTtsVolume: props.textTtsVolume,
|
|
151
|
-
|
|
152
|
-
webrtcEnabled: props.webrtcEnabled,
|
|
136
|
+
showVoiceImageInput: props.showVoiceImageInput,
|
|
153
137
|
}}
|
|
154
138
|
>
|
|
155
139
|
<AgentAssistantInner {...props} />
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Message } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
-
import React, { useCallback,
|
|
2
|
+
import React, { useCallback, useRef } from 'react';
|
|
3
3
|
import {
|
|
4
4
|
ActivityIndicator,
|
|
5
5
|
FlatList,
|
|
@@ -9,7 +9,9 @@ import {
|
|
|
9
9
|
View,
|
|
10
10
|
} from 'react-native';
|
|
11
11
|
import { useAgentAssistant } from '../context/AgentAssistantContext';
|
|
12
|
+
import { MessageAttachmentPreview } from './MessageAttachmentPreview';
|
|
12
13
|
import { SpeakerIcon } from './VoiceIcons';
|
|
14
|
+
import { useMessageListAutoScroll } from './useMessageListAutoScroll';
|
|
13
15
|
|
|
14
16
|
function MessageBubble({ message }: { message: Message }): React.ReactElement {
|
|
15
17
|
const {
|
|
@@ -77,14 +79,19 @@ function MessageBubble({ message }: { message: Message }): React.ReactElement {
|
|
|
77
79
|
},
|
|
78
80
|
]}
|
|
79
81
|
>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
{message.attachments?.length ? (
|
|
83
|
+
<MessageAttachmentPreview attachments={message.attachments} theme={theme} />
|
|
84
|
+
) : null}
|
|
85
|
+
{displayText?.trim() ? (
|
|
86
|
+
<Text
|
|
87
|
+
style={{
|
|
88
|
+
color: isUser ? theme.colors.userText : theme.colors.assistantText,
|
|
89
|
+
fontSize: theme.typography.bodySize,
|
|
90
|
+
}}
|
|
91
|
+
>
|
|
92
|
+
{displayText}
|
|
93
|
+
</Text>
|
|
94
|
+
) : null}
|
|
88
95
|
{message.isStreaming && message.streamingStatus ? (
|
|
89
96
|
<Text
|
|
90
97
|
style={{
|
|
@@ -142,12 +149,7 @@ function MessageBubble({ message }: { message: Message }): React.ReactElement {
|
|
|
142
149
|
export function AgentMessageList(): React.ReactElement {
|
|
143
150
|
const { messages, isLoading, theme } = useAgentAssistant();
|
|
144
151
|
const listRef = useRef<{ scrollToEnd: (opts: { animated: boolean }) => void } | null>(null);
|
|
145
|
-
|
|
146
|
-
useEffect(() => {
|
|
147
|
-
if (messages.length > 0) {
|
|
148
|
-
listRef.current?.scrollToEnd({ animated: true });
|
|
149
|
-
}
|
|
150
|
-
}, [messages.length, messages[messages.length - 1]?.content]);
|
|
152
|
+
const { onContentSizeChange } = useMessageListAutoScroll(messages, listRef);
|
|
151
153
|
|
|
152
154
|
return (
|
|
153
155
|
<View style={[styles.container, { backgroundColor: theme.colors.background }]}>
|
|
@@ -156,6 +158,7 @@ export function AgentMessageList(): React.ReactElement {
|
|
|
156
158
|
data={messages}
|
|
157
159
|
keyExtractor={(item: Message) => item.id}
|
|
158
160
|
renderItem={({ item }: { item: Message }) => <MessageBubble message={item} />}
|
|
161
|
+
onContentSizeChange={onContentSizeChange}
|
|
159
162
|
contentContainerStyle={{ padding: theme.spacing.md, paddingBottom: theme.spacing.lg }}
|
|
160
163
|
ListEmptyComponent={
|
|
161
164
|
<Text style={[styles.empty, { color: theme.colors.mutedText }]}>
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback
|
|
1
|
+
import React, { useCallback } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
ActivityIndicator,
|
|
4
4
|
Pressable,
|
|
@@ -10,8 +10,8 @@ import {
|
|
|
10
10
|
useAgentAssistant,
|
|
11
11
|
voiceStatusLabel,
|
|
12
12
|
} from '../context/AgentAssistantContext';
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
13
|
+
import { MicIcon, MicOffIcon } from './VoiceIcons';
|
|
14
|
+
import { VoiceAttachmentButton } from './VoiceAttachmentButton';
|
|
15
15
|
|
|
16
16
|
function statusDotColor(
|
|
17
17
|
status: ReturnType<typeof useAgentAssistant>['voiceStatus'],
|
|
@@ -38,29 +38,13 @@ export function AgentVoiceBar(): React.ReactElement | null {
|
|
|
38
38
|
isVoiceConnecting,
|
|
39
39
|
isVoiceChannelReady,
|
|
40
40
|
stopVoice,
|
|
41
|
-
interrupt,
|
|
42
41
|
isVoiceAvailable,
|
|
43
42
|
isMicMuted,
|
|
44
43
|
isMicHeldForAssistant,
|
|
45
44
|
toggleVoiceMicMute,
|
|
45
|
+
showVoiceImageInput,
|
|
46
46
|
} = useAgentAssistant();
|
|
47
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
48
|
const returnToTextMode = useCallback(() => {
|
|
65
49
|
void stopVoice();
|
|
66
50
|
}, [stopVoice]);
|
|
@@ -79,17 +63,17 @@ export function AgentVoiceBar(): React.ReactElement | null {
|
|
|
79
63
|
? 'Tap Back to text mode below to cancel'
|
|
80
64
|
: awaitingChannel
|
|
81
65
|
? 'Waiting for voice channel…'
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
66
|
+
: isMicHeldForAssistant
|
|
67
|
+
? 'Assistant is responding — tap mic to interrupt and speak'
|
|
68
|
+
: isMicMuted
|
|
69
|
+
? 'Mic is off — tap the mic when ready to speak'
|
|
70
|
+
: voiceStatus === 'listening'
|
|
71
|
+
? 'Start speaking'
|
|
72
|
+
: voiceStatus === 'speaking'
|
|
73
|
+
? 'Assistant is speaking…'
|
|
74
|
+
: voiceStatus === 'thinking'
|
|
75
|
+
? 'Thinking…'
|
|
76
|
+
: '';
|
|
93
77
|
|
|
94
78
|
return (
|
|
95
79
|
<View
|
|
@@ -150,42 +134,24 @@ export function AgentVoiceBar(): React.ReactElement | null {
|
|
|
150
134
|
) : null}
|
|
151
135
|
</View>
|
|
152
136
|
|
|
153
|
-
<
|
|
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>
|
|
137
|
+
{showVoiceImageInput ? <VoiceAttachmentButton /> : null}
|
|
177
138
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
139
|
+
<Pressable
|
|
140
|
+
onPress={toggleVoiceMicMute}
|
|
141
|
+
disabled={showConnecting || awaitingChannel}
|
|
142
|
+
style={({ pressed }) => [
|
|
143
|
+
styles.micButton,
|
|
144
|
+
{
|
|
145
|
+
opacity: pressed || showConnecting || awaitingChannel ? 0.45 : 1,
|
|
146
|
+
},
|
|
147
|
+
]}
|
|
148
|
+
>
|
|
149
|
+
{isMicMuted ? (
|
|
150
|
+
<MicOffIcon size={28} color="#ef4444" />
|
|
151
|
+
) : (
|
|
152
|
+
<MicIcon size={28} color={theme.colors.assistantText} />
|
|
153
|
+
)}
|
|
154
|
+
</Pressable>
|
|
189
155
|
</View>
|
|
190
156
|
|
|
191
157
|
<Pressable onPress={returnToTextMode} style={styles.textLink}>
|
|
@@ -223,10 +189,9 @@ const styles = StyleSheet.create({
|
|
|
223
189
|
alignSelf: 'stretch',
|
|
224
190
|
},
|
|
225
191
|
dot: { width: 8, height: 8, borderRadius: 4 },
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
borderRadius: 8,
|
|
192
|
+
micButton: {
|
|
193
|
+
padding: 10,
|
|
194
|
+
borderRadius: 12,
|
|
230
195
|
},
|
|
231
196
|
textLink: { marginTop: 10, alignSelf: 'center' },
|
|
232
197
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Attachment } from '@bytexbyte/nxtlinq-ai-agent-core-development';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { Image, StyleSheet, Text, View } from 'react-native';
|
|
4
|
+
import type { AgentAssistantTheme } from '../types';
|
|
5
|
+
|
|
6
|
+
type MessageAttachmentPreviewProps = {
|
|
7
|
+
attachments: Attachment[];
|
|
8
|
+
theme: AgentAssistantTheme;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function MessageAttachmentPreview({
|
|
12
|
+
attachments,
|
|
13
|
+
theme,
|
|
14
|
+
}: MessageAttachmentPreviewProps): React.ReactElement | null {
|
|
15
|
+
const images = attachments.filter((item) => item.type === 'image');
|
|
16
|
+
if (!images.length) return null;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<View style={styles.wrap}>
|
|
20
|
+
{images.map((item, index) => (
|
|
21
|
+
<Image
|
|
22
|
+
key={`${item.name}-${index}`}
|
|
23
|
+
source={{ uri: item.url }}
|
|
24
|
+
style={[
|
|
25
|
+
styles.image,
|
|
26
|
+
{ borderRadius: theme.radius.bubble, backgroundColor: theme.colors.surface },
|
|
27
|
+
]}
|
|
28
|
+
resizeMode="cover"
|
|
29
|
+
/>
|
|
30
|
+
))}
|
|
31
|
+
{attachments.some((item) => item.type === 'file') ? (
|
|
32
|
+
<Text style={{ color: theme.colors.mutedText, fontSize: theme.typography.captionSize }}>
|
|
33
|
+
{attachments.filter((item) => item.type === 'file').map((item) => item.name).join(', ')}
|
|
34
|
+
</Text>
|
|
35
|
+
) : null}
|
|
36
|
+
</View>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const styles = StyleSheet.create({
|
|
41
|
+
wrap: { marginBottom: 6, gap: 6 },
|
|
42
|
+
image: { width: 160, height: 160 },
|
|
43
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
|
3
|
+
import type { AgentAssistantTheme } from '../types';
|
|
4
|
+
|
|
5
|
+
type VoiceAddMediaModalProps = {
|
|
6
|
+
visible: boolean;
|
|
7
|
+
theme: AgentAssistantTheme;
|
|
8
|
+
onClose: () => void;
|
|
9
|
+
onPickPhotos: () => void;
|
|
10
|
+
onTakePhoto: () => void;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function VoiceAddMediaModal({
|
|
14
|
+
visible,
|
|
15
|
+
theme,
|
|
16
|
+
onClose,
|
|
17
|
+
onPickPhotos,
|
|
18
|
+
onTakePhoto,
|
|
19
|
+
}: VoiceAddMediaModalProps): React.ReactElement | null {
|
|
20
|
+
if (!visible) return null;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Modal transparent visible={visible} animationType="fade" onRequestClose={onClose}>
|
|
24
|
+
<Pressable style={styles.overlay} onPress={onClose}>
|
|
25
|
+
<Pressable
|
|
26
|
+
style={[
|
|
27
|
+
styles.modal,
|
|
28
|
+
{ backgroundColor: theme.colors.assistantBubble, borderRadius: theme.radius.panel },
|
|
29
|
+
]}
|
|
30
|
+
onPress={() => undefined}
|
|
31
|
+
>
|
|
32
|
+
<Pressable style={styles.option} onPress={onPickPhotos}>
|
|
33
|
+
<Text style={{ color: theme.colors.assistantText, fontSize: theme.typography.bodySize }}>
|
|
34
|
+
Photos
|
|
35
|
+
</Text>
|
|
36
|
+
</Pressable>
|
|
37
|
+
<View style={[styles.separator, { backgroundColor: theme.colors.border }]} />
|
|
38
|
+
<Pressable style={styles.option} onPress={onTakePhoto}>
|
|
39
|
+
<Text style={{ color: theme.colors.assistantText, fontSize: theme.typography.bodySize }}>
|
|
40
|
+
Camera
|
|
41
|
+
</Text>
|
|
42
|
+
</Pressable>
|
|
43
|
+
</Pressable>
|
|
44
|
+
</Pressable>
|
|
45
|
+
</Modal>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const styles = StyleSheet.create({
|
|
50
|
+
overlay: {
|
|
51
|
+
flex: 1,
|
|
52
|
+
backgroundColor: 'rgba(0,0,0,0.45)',
|
|
53
|
+
justifyContent: 'flex-end',
|
|
54
|
+
alignItems: 'flex-start',
|
|
55
|
+
paddingBottom: 120,
|
|
56
|
+
paddingLeft: 16,
|
|
57
|
+
},
|
|
58
|
+
modal: {
|
|
59
|
+
width: 160,
|
|
60
|
+
paddingVertical: 4,
|
|
61
|
+
shadowColor: '#000',
|
|
62
|
+
shadowOffset: { width: 0, height: -2 },
|
|
63
|
+
shadowOpacity: 0.2,
|
|
64
|
+
shadowRadius: 8,
|
|
65
|
+
elevation: 8,
|
|
66
|
+
},
|
|
67
|
+
option: { paddingVertical: 12, paddingHorizontal: 16 },
|
|
68
|
+
separator: { height: StyleSheet.hairlineWidth, marginHorizontal: 16 },
|
|
69
|
+
});
|