@aslaluroba/help-center-react 3.2.1 → 3.2.3

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.
@@ -5,6 +5,7 @@ interface ChatWindowFooterProps {
5
5
  handleSendMessage: (attachmentIds: string[]) => void;
6
6
  isLoading: boolean;
7
7
  onEnsureSession: () => Promise<string>;
8
+ sessionId?: string | null;
8
9
  }
9
10
  declare const ChatWindowFooter: React.FC<ChatWindowFooterProps>;
10
11
  export default ChatWindowFooter;
@@ -7,6 +7,7 @@ interface ChatWindowProps {
7
7
  assistantStatus: string;
8
8
  needsAgent: boolean;
9
9
  isAblyConnected: boolean;
10
+ sessionId?: string | null;
10
11
  }
11
- export declare const ChatWindow: React.MemoExoticComponent<({ onSendMessage, onEnsureSession, messages, assistantStatus, needsAgent }: ChatWindowProps) => import("react/jsx-runtime").JSX.Element>;
12
+ export declare const ChatWindow: React.MemoExoticComponent<({ onSendMessage, onEnsureSession, messages, assistantStatus, needsAgent, sessionId, }: ChatWindowProps) => import("react/jsx-runtime").JSX.Element>;
12
13
  export {};
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "main": "dist/index.js",
4
4
  "module": "dist/index.esm.js",
5
5
  "types": "dist/index.d.ts",
6
- "version": "3.2.1",
6
+ "version": "3.2.3",
7
7
  "description": "BabylAI Help Center Widget for React and Next.js",
8
8
  "private": false,
9
9
  "exports": {
@@ -12,12 +12,14 @@ interface AgentResponseProps {
12
12
  const seenMessagesRef = new Set<number>();
13
13
 
14
14
  const AgentResponse = ({ senderType, messageContent, messageId, onType }: AgentResponseProps) => {
15
+ // Ensure messageContent is always a string to prevent errors
16
+ const safeMessageContent = messageContent ?? '';
15
17
  const shouldAnimate = (senderType === 2 || senderType === 3) && !seenMessagesRef.has(messageId);
16
- const animatedText = useTypewriter(messageContent, 20, onType);
17
- const finalMessage = shouldAnimate ? animatedText : messageContent;
18
+ const animatedText = useTypewriter(safeMessageContent, 20, onType);
19
+ const finalMessage = shouldAnimate ? animatedText : safeMessageContent;
18
20
 
19
21
  // Mark message as "seen" after full animation
20
- if (shouldAnimate && finalMessage === messageContent) {
22
+ if (shouldAnimate && finalMessage === safeMessageContent) {
21
23
  seenMessagesRef.add(messageId);
22
24
  }
23
25
 
@@ -5,7 +5,8 @@ import { useLocalTranslation } from '../../useLocalTranslation';
5
5
  import { ImagePreviewDialog } from './image-preview-dialog';
6
6
 
7
7
  interface ImageAttachmentProps {
8
- fileId: string;
8
+ fileId?: string; // File ID (for user-sent messages, requires presignDownload)
9
+ imageUrl?: string; // Direct URL (for received messages from Ably)
9
10
  className?: string;
10
11
  enablePreview?: boolean;
11
12
  onClick?: () => void;
@@ -13,32 +14,43 @@ interface ImageAttachmentProps {
13
14
 
14
15
  export const ImageAttachment: React.FC<ImageAttachmentProps> = ({
15
16
  fileId,
17
+ imageUrl: propImageUrl,
16
18
  className,
17
19
  enablePreview = true,
18
20
  onClick,
19
21
  }) => {
20
22
  const { i18n } = useLocalTranslation();
21
- const [imageUrl, setImageUrl] = useState<string | null>(null);
22
- const [loading, setLoading] = useState(true);
23
+ const [imageUrl, setImageUrl] = useState<string | null>(propImageUrl || null);
24
+ const [loading, setLoading] = useState(!propImageUrl && !!fileId);
23
25
  const [error, setError] = useState(false);
24
26
  const [isPreviewOpen, setIsPreviewOpen] = useState(false);
25
27
 
26
28
  useEffect(() => {
27
- const fetchImageUrl = async () => {
28
- try {
29
- setLoading(true);
30
- setError(false);
31
- const response = await presignDownload(fileId, i18n.language);
32
- setImageUrl(response.downloadUrl);
33
- } catch (err) {
34
- setError(true);
35
- } finally {
36
- setLoading(false);
37
- }
38
- };
29
+ // If we have a direct URL, use it immediately
30
+ if (propImageUrl) {
31
+ setImageUrl(propImageUrl);
32
+ setLoading(false);
33
+ return;
34
+ }
35
+
36
+ // If we only have a fileId, fetch the URL using presignDownload
37
+ if (fileId) {
38
+ const fetchImageUrl = async () => {
39
+ try {
40
+ setLoading(true);
41
+ setError(false);
42
+ const response = await presignDownload(fileId, i18n.language);
43
+ setImageUrl(response.downloadUrl);
44
+ } catch (err) {
45
+ setError(true);
46
+ } finally {
47
+ setLoading(false);
48
+ }
49
+ };
39
50
 
40
- fetchImageUrl();
41
- }, [fileId, i18n.language]);
51
+ fetchImageUrl();
52
+ }
53
+ }, [fileId, propImageUrl, i18n.language]);
42
54
 
43
55
  const handleImageClick = () => {
44
56
  if (onClick) {
@@ -70,6 +70,38 @@ export const ImagePreviewDialog: React.FC<ImagePreviewDialogProps> = ({
70
70
  setImagePosition({ x: 0, y: 0 });
71
71
  }, []);
72
72
 
73
+ const handleDownload = useCallback(async () => {
74
+ if (!currentImageUrl) return;
75
+
76
+ try {
77
+ // Fetch the image as a blob
78
+ const response = await fetch(currentImageUrl);
79
+ const blob = await response.blob();
80
+
81
+ // Create a temporary URL for the blob
82
+ const blobUrl = URL.createObjectURL(blob);
83
+
84
+ // Extract filename from URL or use a default
85
+ const urlParts = currentImageUrl.split('/');
86
+ const filename = urlParts[urlParts.length - 1].split('?')[0] || 'image.png';
87
+
88
+ // Create a temporary anchor element and trigger download
89
+ const link = document.createElement('a');
90
+ link.href = blobUrl;
91
+ link.download = filename;
92
+ document.body.appendChild(link);
93
+ link.click();
94
+
95
+ // Cleanup
96
+ document.body.removeChild(link);
97
+ URL.revokeObjectURL(blobUrl);
98
+ } catch (error) {
99
+ console.error('Failed to download image:', error);
100
+ // Fallback: open in new tab if download fails
101
+ window.open(currentImageUrl, '_blank');
102
+ }
103
+ }, [currentImageUrl]);
104
+
73
105
  const handleClose = useCallback(() => {
74
106
  setZoomLevel(1);
75
107
  setImagePosition({ x: 0, y: 0 });
@@ -307,6 +339,20 @@ export const ImagePreviewDialog: React.FC<ImagePreviewDialogProps> = ({
307
339
  Reset
308
340
  </Button>
309
341
  )}
342
+ {/* Download Button */}
343
+ <div className='babylai-h-9 babylai-w-px babylai-bg-white/20 babylai-mx-1' />
344
+ <Button
345
+ variant='ghost'
346
+ size='icon'
347
+ onClick={handleDownload}
348
+ className='babylai-text-white hover:babylai-text-white/80 hover:babylai-bg-white/10 babylai-h-9 babylai-w-9'
349
+ aria-label='Download image'
350
+ type='button'
351
+ >
352
+ <svg className='babylai-w-5 babylai-h-5' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
353
+ <path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4' />
354
+ </svg>
355
+ </Button>
310
356
  </div>
311
357
 
312
358
  {/* Image Counter */}
@@ -26,10 +26,19 @@ export class ClientAblyService {
26
26
  autoConnect: true,
27
27
  });
28
28
 
29
+ this.client.connection.on('failed', (stateChange) => {
30
+ console.error('[AblyService] Connection state: failed', {
31
+ reason: stateChange.reason?.message,
32
+ error: stateChange.reason,
33
+ });
34
+ });
35
+
29
36
  // Wait for connection to be established
30
37
  await new Promise<void>((resolve, reject) => {
31
38
  if (!this.client) {
32
- reject(new Error('Failed to initialize Ably client'));
39
+ const error = new Error('Failed to initialize Ably client');
40
+ console.error('[AblyService]', error);
41
+ reject(error);
33
42
  return;
34
43
  }
35
44
 
@@ -40,17 +49,26 @@ export class ClientAblyService {
40
49
  });
41
50
 
42
51
  this.client.connection.once('failed', (stateChange) => {
43
- reject(new Error(`Ably connection failed: ${stateChange.reason?.message || 'Unknown error'}`));
52
+ const error = new Error(`Ably connection failed: ${stateChange.reason?.message || 'Unknown error'}`);
53
+ console.error('[AblyService] Connection failed', {
54
+ reason: stateChange.reason?.message,
55
+ error: stateChange.reason,
56
+ });
57
+ reject(error);
44
58
  });
45
59
 
46
60
  this.client.connection.once('disconnected', (stateChange) => {
47
- reject(new Error(`Ably connection disconnected: ${stateChange.reason?.message || 'Unknown error'}`));
61
+ const error = new Error(`Ably connection disconnected: ${stateChange.reason?.message || 'Unknown error'}`);
62
+ console.error('[AblyService] Connection disconnected', { reason: stateChange.reason?.message });
63
+ reject(error);
48
64
  });
49
65
 
50
66
  // Set a timeout for connection
51
67
  setTimeout(() => {
52
68
  if (!this.isConnected) {
53
- reject(new Error('Ably connection timeout'));
69
+ const error = new Error('Ably connection timeout');
70
+ console.error('[AblyService] Connection timeout after 10 seconds');
71
+ reject(error);
54
72
  }
55
73
  }, 10000);
56
74
  });
@@ -58,6 +76,7 @@ export class ClientAblyService {
58
76
  // Subscribe to the session room
59
77
  await this.joinChannel(sessionId, onMessageReceived, tenantId);
60
78
  } catch (error) {
79
+ console.error('[AblyService] Error in startConnection', { error, sessionId });
61
80
  this.isConnected = false;
62
81
  this.sessionId = null;
63
82
  throw error;
@@ -66,7 +85,9 @@ export class ClientAblyService {
66
85
 
67
86
  private static async joinChannel(sessionId: string, onMessageReceived: Function, tenantId: string) {
68
87
  if (!this.client) {
69
- throw new Error('Chat client not initialized');
88
+ const error = new Error('Chat client not initialized');
89
+ console.error('[AblyService] joinChannel error:', error);
90
+ throw error;
70
91
  }
71
92
 
72
93
  const roomName = `session:${tenantId}:${sessionId}`;
@@ -75,21 +96,46 @@ export class ClientAblyService {
75
96
  if (this.client) {
76
97
  this.channel = this.client.channels.get(roomName);
77
98
 
99
+ this.channel.on('failed', (stateChange) => {
100
+ console.error('[AblyService] Channel failed', {
101
+ roomName,
102
+ reason: stateChange.reason?.message,
103
+ error: stateChange.reason,
104
+ });
105
+ });
106
+
78
107
  // Subscribe to assistant/system responses
79
108
  this.channel.subscribe('ReceiveMessage', (message) => {
80
109
  try {
110
+ // Ensure messageContent is always a string (default to empty string if undefined)
81
111
  const messageContent =
82
- typeof message.data === 'string' ? message.data : message.data?.content || message.data?.message;
112
+ typeof message.data === 'string' ? message.data : message.data?.content ?? message.data?.message ?? '';
83
113
  const senderType = message.data?.senderType || 3; // Assistant
84
- const needsAgent =
85
- message.data?.needsAgent ||
86
- message.data?.actionType == "needs_agent" ||
87
- false;
114
+ const needsAgent = message.data?.needsAgent || message.data?.actionType == 'needs_agent' || false;
88
115
  const attachments = message.data?.attachments || [];
89
116
 
90
- onMessageReceived(messageContent, senderType, needsAgent, attachments);
117
+ // Extract downloadUrl from attachments (Ably now returns downloadUrl directly)
118
+ // Attachments can be: strings (URLs), objects with downloadUrl, or objects with id
119
+ const attachmentUrls = attachments
120
+ .map((attachment: any) => {
121
+ if (typeof attachment === 'string') {
122
+ // If it's already a string, it's a URL
123
+ return attachment;
124
+ } else if (attachment?.downloadUrl) {
125
+ // If it's an object with downloadUrl, use that
126
+ return attachment.downloadUrl;
127
+ } else if (attachment?.url) {
128
+ // Fallback to url property
129
+ return attachment.url;
130
+ }
131
+ // If it's an object with id, we'll need to keep it for backward compatibility
132
+ return null;
133
+ })
134
+ .filter((url: string | null): url is string => url !== null);
135
+
136
+ onMessageReceived(messageContent, senderType, needsAgent, attachmentUrls);
91
137
  } catch (error) {
92
- // Handle error silently
138
+ console.error('[AblyService] Error processing message', { error, message });
93
139
  }
94
140
  });
95
141
 
@@ -121,6 +167,7 @@ export class ClientAblyService {
121
167
  this.isConnected = false;
122
168
  this.sessionId = null;
123
169
  } catch (error) {
170
+ console.error('[AblyService] Error in stopConnection', { error });
124
171
  // Reset state even if there's an error
125
172
  this.isConnected = false;
126
173
  this.sessionId = null;
@@ -4,21 +4,23 @@ export function useTypewriter(text: string, speed: number = 20, onType?: () => v
4
4
  const [displayedText, setDisplayedText] = useState('')
5
5
 
6
6
  useEffect(() => {
7
+ // Ensure text is always a string to prevent errors
8
+ const safeText = text ?? ''
7
9
  let index = 0
8
10
  setDisplayedText('')
9
11
 
10
12
  const interval = setInterval(() => {
11
13
  setDisplayedText(() => {
12
- const next = text.slice(0, index + 1)
14
+ const next = safeText.slice(0, index + 1)
13
15
  index++
14
16
  if (onType) onType()
15
- if (index >= text.length) clearInterval(interval)
17
+ if (index >= safeText.length) clearInterval(interval)
16
18
  return next
17
19
  })
18
20
  }, speed)
19
21
 
20
22
  return () => clearInterval(interval)
21
- }, [text])
23
+ }, [text, onType])
22
24
 
23
25
  return displayedText
24
26
  }
package/src/lib/types.ts CHANGED
@@ -27,7 +27,8 @@ export interface Message {
27
27
  messageContent: string;
28
28
  sentAt: Date;
29
29
  isSeen: boolean;
30
- attachmentIds?: string[];
30
+ attachmentIds?: string[]; // For user-sent messages (file IDs)
31
+ attachmentUrls?: string[]; // For received messages from Ably (download URLs)
31
32
  }
32
33
 
33
34
  export interface ChatSession {
@@ -22,6 +22,7 @@ interface ChatWindowFooterProps {
22
22
  handleSendMessage: (attachmentIds: string[]) => void;
23
23
  isLoading: boolean;
24
24
  onEnsureSession: () => Promise<string>;
25
+ sessionId?: string | null; // Pass existing sessionId to avoid creating new sessions
25
26
  }
26
27
 
27
28
  const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
@@ -34,15 +35,13 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
34
35
  fileInputRef.current?.click();
35
36
  }, []);
36
37
 
37
- const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
38
+ const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
38
39
  const files = Array.from(e.target.files || []);
39
40
 
40
41
  // Validate that all files are images
41
42
  const imageFiles = files.filter((file) => file.type.startsWith('image/'));
42
43
 
43
- // Only image files are allowed
44
-
45
- // Create preview URLs and add to selected files
44
+ // Create preview URLs and add to selected files (don't upload yet)
46
45
  const newFiles: SelectedFileDto[] = imageFiles.map((file) => ({
47
46
  file,
48
47
  previewUrl: URL.createObjectURL(file),
@@ -58,17 +57,55 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
58
57
  fileInputRef.current.value = '';
59
58
  }
60
59
 
61
- // Start uploading immediately
62
- await handleUploadFiles(newFiles);
60
+ // Don't upload files immediately - wait for send button click
63
61
  }, []);
64
62
 
65
- const handleUploadFiles = useCallback(
66
- async (filesToUpload: SelectedFileDto[]) => {
67
- // Get session ID
68
- let sessionId: string;
63
+ // Removed handleUploadFiles - files are now uploaded in handleSendMessageWithAttachments
64
+
65
+ const handleRemoveFile = useCallback((previewUrl: string) => {
66
+ setSelectedFiles((prev) => {
67
+ const fileToRemove = prev.find((f) => f.previewUrl === previewUrl);
68
+ if (fileToRemove) {
69
+ URL.revokeObjectURL(fileToRemove.previewUrl);
70
+ }
71
+ return prev.filter((f) => f.previewUrl !== previewUrl);
72
+ });
73
+ }, []);
74
+
75
+ const handleSendMessageWithAttachments = useCallback(async () => {
76
+ // Prevent sending if already loading
77
+ if (props.isLoading) {
78
+ return;
79
+ }
80
+
81
+ // Get files that need to be uploaded (those without uploadedId)
82
+ const filesToUpload = selectedFiles.filter((f) => f.uploadedId === null && !f.error);
83
+ const alreadyUploadedIds = selectedFiles.filter((f) => f.uploadedId !== null).map((f) => f.uploadedId as string);
84
+
85
+ // Declare uploadedIds outside the if block so it's accessible later
86
+ let uploadedIds: string[] = [];
87
+
88
+ // If there are files to upload, upload them first
89
+ if (filesToUpload.length > 0) {
90
+ // Get session ID - only use existing, never create new one
91
+ let sessionId: string | null = null;
69
92
  try {
70
- sessionId = await props.onEnsureSession();
93
+ // Only use existing sessionId, never call onEnsureSession
94
+ if (props.sessionId) {
95
+ sessionId = props.sessionId;
96
+ } else {
97
+ // Mark all files as error
98
+ setSelectedFiles((prev) =>
99
+ prev.map((f) =>
100
+ filesToUpload.some((ftl) => ftl.previewUrl === f.previewUrl)
101
+ ? { ...f, error: 'No session available', uploading: false }
102
+ : f
103
+ )
104
+ );
105
+ return;
106
+ }
71
107
  } catch (error) {
108
+ console.error('[ChatWindowFooter] Failed to get sessionId for file upload:', error);
72
109
  // Mark all files as error
73
110
  setSelectedFiles((prev) =>
74
111
  prev.map((f) =>
@@ -80,7 +117,10 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
80
117
  return;
81
118
  }
82
119
 
83
- // Upload each file
120
+ // Upload each file and collect uploaded IDs
121
+ uploadedIds = [];
122
+ let hasUploadErrors = false;
123
+
84
124
  for (const fileDto of filesToUpload) {
85
125
  try {
86
126
  // Mark as uploading
@@ -92,7 +132,6 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
92
132
  const presignResponse = await presignUpload(sessionId, fileDto.file, i18n.language);
93
133
 
94
134
  // Upload file to presigned URL using axios
95
- // Important: Content-Type must match the file type (e.g., 'image/png'), not 'multipart/form-data'
96
135
  const uploadResponse = await axios.put(presignResponse.uploadUrl, fileDto.file, {
97
136
  headers: {
98
137
  'Content-Type': fileDto.file.type,
@@ -106,6 +145,9 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
106
145
  throw new Error(`Upload failed with status ${uploadResponse.status}`);
107
146
  }
108
147
 
148
+ // Collect uploaded ID
149
+ uploadedIds.push(presignResponse.id);
150
+
109
151
  // Update with uploaded ID
110
152
  setSelectedFiles((prev) =>
111
153
  prev.map((f) =>
@@ -115,6 +157,8 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
115
157
  )
116
158
  );
117
159
  } catch (error) {
160
+ console.error('[ChatWindowFooter] File upload failed:', error);
161
+ hasUploadErrors = true;
118
162
  setSelectedFiles((prev) =>
119
163
  prev.map((f) =>
120
164
  f.previewUrl === fileDto.previewUrl
@@ -124,52 +168,35 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
124
168
  );
125
169
  }
126
170
  }
127
- },
128
- [props.onEnsureSession, i18n.language]
129
- );
130
171
 
131
- const handleRemoveFile = useCallback((previewUrl: string) => {
132
- setSelectedFiles((prev) => {
133
- const fileToRemove = prev.find((f) => f.previewUrl === previewUrl);
134
- if (fileToRemove) {
135
- URL.revokeObjectURL(fileToRemove.previewUrl);
172
+ // If any uploads failed, don't send the message
173
+ if (hasUploadErrors) {
174
+ console.error('[ChatWindowFooter] Some files failed to upload, not sending message');
175
+ return;
136
176
  }
137
- return prev.filter((f) => f.previewUrl !== previewUrl);
138
- });
139
- }, []);
140
-
141
- const handleSendMessageWithAttachments = useCallback(() => {
142
- // Only allow sending if all files have finished uploading (either successfully or with error)
143
- const hasUploadingFiles = selectedFiles.some((f) => f.uploading);
144
- if (hasUploadingFiles) {
145
- return; // Prevent sending if any files are still uploading
146
177
  }
147
178
 
148
- // Get all successfully uploaded file IDs
149
- const attachmentIds = selectedFiles.filter((f) => f.uploadedId !== null).map((f) => f.uploadedId as string);
179
+ // Get all successfully uploaded file IDs (already uploaded + newly uploaded)
180
+ // Use uploadedIds from the upload loop instead of reading from state
181
+ const allAttachmentIds = [...alreadyUploadedIds, ...uploadedIds];
150
182
 
151
183
  // Call the original send message with attachment IDs
152
- props.handleSendMessage(attachmentIds);
184
+ props.handleSendMessage(allAttachmentIds);
153
185
 
154
186
  // Clear selected files and revoke URLs
155
187
  selectedFiles.forEach((f) => URL.revokeObjectURL(f.previewUrl));
156
188
  setSelectedFiles([]);
157
- }, [selectedFiles, props]);
189
+ }, [selectedFiles, props, i18n.language]);
158
190
 
159
191
  // Check if any files are currently uploading
160
192
  const hasUploadingFiles = selectedFiles.some((f) => f.uploading);
161
193
 
162
- // Check if there are files that haven't finished (no uploadedId, no error, not uploading)
163
- // This shouldn't happen in normal flow, but we check for safety
164
- const hasPendingFiles = selectedFiles.some((f) => !f.uploading && f.uploadedId === null && f.error === null);
165
-
166
- // Check if all files have errors (no successful uploads)
167
- const hasSuccessfulUploads = selectedFiles.some((f) => f.uploadedId !== null);
168
- const allFilesHaveErrors =
169
- selectedFiles.length > 0 && !hasSuccessfulUploads && !hasUploadingFiles && !hasPendingFiles;
194
+ // Check if there are files with errors
195
+ const hasFileErrors = selectedFiles.some((f) => f.error !== null);
170
196
 
171
- const isSendDisabled =
172
- props.isLoading || props.inputMessage.trim() === '' || hasUploadingFiles || hasPendingFiles || allFilesHaveErrors;
197
+ // Allow sending if there's text OR files selected (files will be uploaded on send)
198
+ const hasContentToSend = props.inputMessage.trim() !== '' || selectedFiles.length > 0;
199
+ const isSendDisabled = props.isLoading || !hasContentToSend || hasUploadingFiles || hasFileErrors;
173
200
 
174
201
  const handleKeyDown = useCallback(
175
202
  (e: React.KeyboardEvent) => {