@aslaluroba/help-center-react 3.2.1 → 3.2.4
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/components/ui/image-attachment.d.ts +2 -1
- package/dist/index.css +1 -1
- package/dist/index.esm.js +352 -173
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +352 -173
- package/dist/index.js.map +1 -1
- package/dist/lib/types.d.ts +1 -0
- package/dist/services.esm.js +71 -14
- package/dist/services.esm.js.map +1 -1
- package/dist/services.js +71 -14
- package/dist/services.js.map +1 -1
- package/dist/ui/chatbot-popup/chat-window-screen/footer.d.ts +1 -0
- package/dist/ui/chatbot-popup/chat-window-screen/index.d.ts +2 -1
- package/package.json +1 -1
- package/src/components/ui/agent-response/agent-response.tsx +5 -3
- package/src/components/ui/image-attachment.tsx +29 -17
- package/src/components/ui/image-preview-dialog.tsx +46 -0
- package/src/core/AblyService.ts +59 -12
- package/src/core/api.ts +0 -1
- package/src/lib/custom-hooks/useTypewriter.ts +5 -3
- package/src/lib/types.ts +2 -1
- package/src/ui/chatbot-popup/chat-window-screen/footer.tsx +133 -95
- package/src/ui/chatbot-popup/chat-window-screen/index.tsx +69 -18
- package/src/ui/help-center.tsx +13 -8
- package/src/ui/help-popup.tsx +3 -1
|
@@ -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
|
@@ -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(
|
|
17
|
-
const finalMessage = shouldAnimate ? animatedText :
|
|
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 ===
|
|
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
|
|
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(
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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 */}
|
package/src/core/AblyService.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/src/core/api.ts
CHANGED
|
@@ -238,7 +238,6 @@ export async function presignUpload(
|
|
|
238
238
|
|
|
239
239
|
return await response.json();
|
|
240
240
|
}
|
|
241
|
-
|
|
242
241
|
export async function presignDownload(fileId: string, language: string): Promise<PresignDownloadResponse> {
|
|
243
242
|
try {
|
|
244
243
|
const response = await apiRequest(`NewFile/${fileId}/presign-download`, 'GET', 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 =
|
|
14
|
+
const next = safeText.slice(0, index + 1)
|
|
13
15
|
index++
|
|
14
16
|
if (onType) onType()
|
|
15
|
-
if (index >=
|
|
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) => {
|
|
@@ -29,20 +30,19 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
|
|
|
29
30
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
30
31
|
const [selectedFiles, setSelectedFiles] = useState<SelectedFileDto[]>([]);
|
|
31
32
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
|
33
|
+
const [isSending, setIsSending] = useState(false);
|
|
32
34
|
|
|
33
35
|
const handleAttachClick = useCallback(() => {
|
|
34
36
|
fileInputRef.current?.click();
|
|
35
37
|
}, []);
|
|
36
38
|
|
|
37
|
-
const handleFileSelect = useCallback(
|
|
39
|
+
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
38
40
|
const files = Array.from(e.target.files || []);
|
|
39
41
|
|
|
40
42
|
// Validate that all files are images
|
|
41
43
|
const imageFiles = files.filter((file) => file.type.startsWith('image/'));
|
|
42
44
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
// Create preview URLs and add to selected files
|
|
45
|
+
// Create preview URLs and add to selected files (don't upload yet)
|
|
46
46
|
const newFiles: SelectedFileDto[] = imageFiles.map((file) => ({
|
|
47
47
|
file,
|
|
48
48
|
previewUrl: URL.createObjectURL(file),
|
|
@@ -58,118 +58,150 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
|
|
|
58
58
|
fileInputRef.current.value = '';
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
//
|
|
62
|
-
await handleUploadFiles(newFiles);
|
|
61
|
+
// Don't upload files immediately - wait for send button click
|
|
63
62
|
}, []);
|
|
64
63
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// Mark all files as error
|
|
73
|
-
setSelectedFiles((prev) =>
|
|
74
|
-
prev.map((f) =>
|
|
75
|
-
filesToUpload.some((ftl) => ftl.previewUrl === f.previewUrl)
|
|
76
|
-
? { ...f, error: 'Failed to initialize session', uploading: false }
|
|
77
|
-
: f
|
|
78
|
-
)
|
|
79
|
-
);
|
|
80
|
-
return;
|
|
64
|
+
// Removed handleUploadFiles - files are now uploaded in handleSendMessageWithAttachments
|
|
65
|
+
|
|
66
|
+
const handleRemoveFile = useCallback((previewUrl: string) => {
|
|
67
|
+
setSelectedFiles((prev) => {
|
|
68
|
+
const fileToRemove = prev.find((f) => f.previewUrl === previewUrl);
|
|
69
|
+
if (fileToRemove) {
|
|
70
|
+
URL.revokeObjectURL(fileToRemove.previewUrl);
|
|
81
71
|
}
|
|
72
|
+
return prev.filter((f) => f.previewUrl !== previewUrl);
|
|
73
|
+
});
|
|
74
|
+
}, []);
|
|
82
75
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
prev.map((f) => (f.previewUrl === fileDto.previewUrl ? { ...f, uploading: true, error: null } : f))
|
|
89
|
-
);
|
|
76
|
+
const handleSendMessageWithAttachments = useCallback(async () => {
|
|
77
|
+
// Prevent sending if already loading
|
|
78
|
+
if (props.isLoading || isSending) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
90
81
|
|
|
91
|
-
|
|
92
|
-
const presignResponse = await presignUpload(sessionId, fileDto.file, i18n.language);
|
|
93
|
-
|
|
94
|
-
// 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
|
-
const uploadResponse = await axios.put(presignResponse.uploadUrl, fileDto.file, {
|
|
97
|
-
headers: {
|
|
98
|
-
'Content-Type': fileDto.file.type,
|
|
99
|
-
},
|
|
100
|
-
onUploadProgress: () => {
|
|
101
|
-
// Upload progress tracking (silent)
|
|
102
|
-
},
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
if (uploadResponse.status !== 200 && uploadResponse.status !== 204) {
|
|
106
|
-
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
|
107
|
-
}
|
|
82
|
+
setIsSending(true);
|
|
108
83
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
84
|
+
try {
|
|
85
|
+
// Get files that need to be uploaded (those without uploadedId)
|
|
86
|
+
const filesToUpload = selectedFiles.filter((f) => f.uploadedId === null && !f.error);
|
|
87
|
+
const alreadyUploadedIds = selectedFiles.filter((f) => f.uploadedId !== null).map((f) => f.uploadedId as string);
|
|
88
|
+
|
|
89
|
+
// Declare uploadedIds outside the if block so it's accessible later
|
|
90
|
+
let uploadedIds: string[] = [];
|
|
91
|
+
|
|
92
|
+
// If there are files to upload, upload them first
|
|
93
|
+
if (filesToUpload.length > 0) {
|
|
94
|
+
// Get session ID - ensure session exists if needed (for image-only messages)
|
|
95
|
+
let sessionId: string | null = null;
|
|
96
|
+
try {
|
|
97
|
+
// Use existing sessionId if available, otherwise ensure session is created
|
|
98
|
+
if (props.sessionId) {
|
|
99
|
+
sessionId = props.sessionId;
|
|
100
|
+
} else {
|
|
101
|
+
// Ensure session exists before uploading files (allows starting chat with image only)
|
|
102
|
+
sessionId = await props.onEnsureSession();
|
|
103
|
+
}
|
|
117
104
|
} catch (error) {
|
|
105
|
+
console.error('[ChatWindowFooter] Failed to get sessionId for file upload:', error);
|
|
106
|
+
// Mark all files as error
|
|
118
107
|
setSelectedFiles((prev) =>
|
|
119
108
|
prev.map((f) =>
|
|
120
|
-
|
|
121
|
-
? { ...f,
|
|
109
|
+
filesToUpload.some((ftl) => ftl.previewUrl === f.previewUrl)
|
|
110
|
+
? { ...f, error: 'Failed to initialize session', uploading: false }
|
|
122
111
|
: f
|
|
123
112
|
)
|
|
124
113
|
);
|
|
114
|
+
setIsSending(false);
|
|
115
|
+
return;
|
|
125
116
|
}
|
|
126
|
-
}
|
|
127
|
-
},
|
|
128
|
-
[props.onEnsureSession, i18n.language]
|
|
129
|
-
);
|
|
130
117
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (fileToRemove) {
|
|
135
|
-
URL.revokeObjectURL(fileToRemove.previewUrl);
|
|
136
|
-
}
|
|
137
|
-
return prev.filter((f) => f.previewUrl !== previewUrl);
|
|
138
|
-
});
|
|
139
|
-
}, []);
|
|
118
|
+
// Upload each file and collect uploaded IDs
|
|
119
|
+
uploadedIds = [];
|
|
120
|
+
let hasUploadErrors = false;
|
|
140
121
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
122
|
+
for (const fileDto of filesToUpload) {
|
|
123
|
+
try {
|
|
124
|
+
// Mark as uploading
|
|
125
|
+
setSelectedFiles((prev) =>
|
|
126
|
+
prev.map((f) => (f.previewUrl === fileDto.previewUrl ? { ...f, uploading: true, error: null } : f))
|
|
127
|
+
);
|
|
147
128
|
|
|
148
|
-
|
|
149
|
-
|
|
129
|
+
// Get presigned URL
|
|
130
|
+
const presignResponse = await presignUpload(sessionId, fileDto.file, i18n.language);
|
|
150
131
|
|
|
151
|
-
|
|
152
|
-
|
|
132
|
+
// Upload file to presigned URL using axios
|
|
133
|
+
const uploadResponse = await axios.put(presignResponse.uploadUrl, fileDto.file, {
|
|
134
|
+
headers: {
|
|
135
|
+
'Content-Type': fileDto.file.type,
|
|
136
|
+
},
|
|
137
|
+
onUploadProgress: () => {
|
|
138
|
+
// Upload progress tracking (silent)
|
|
139
|
+
},
|
|
140
|
+
});
|
|
153
141
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
142
|
+
if (uploadResponse.status !== 200 && uploadResponse.status !== 204) {
|
|
143
|
+
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Collect uploaded ID
|
|
147
|
+
uploadedIds.push(presignResponse.id);
|
|
148
|
+
|
|
149
|
+
// Update with uploaded ID
|
|
150
|
+
setSelectedFiles((prev) =>
|
|
151
|
+
prev.map((f) =>
|
|
152
|
+
f.previewUrl === fileDto.previewUrl
|
|
153
|
+
? { ...f, uploading: false, uploadedId: presignResponse.id, error: null }
|
|
154
|
+
: f
|
|
155
|
+
)
|
|
156
|
+
);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error('[ChatWindowFooter] File upload failed:', error);
|
|
159
|
+
hasUploadErrors = true;
|
|
160
|
+
setSelectedFiles((prev) =>
|
|
161
|
+
prev.map((f) =>
|
|
162
|
+
f.previewUrl === fileDto.previewUrl
|
|
163
|
+
? { ...f, uploading: false, error: 'Upload failed', uploadedId: null }
|
|
164
|
+
: f
|
|
165
|
+
)
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// If any uploads failed, don't send the message
|
|
171
|
+
if (hasUploadErrors) {
|
|
172
|
+
console.error('[ChatWindowFooter] Some files failed to upload, not sending message');
|
|
173
|
+
setIsSending(false);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Get all successfully uploaded file IDs (already uploaded + newly uploaded)
|
|
179
|
+
// Use uploadedIds from the upload loop instead of reading from state
|
|
180
|
+
const allAttachmentIds = [...alreadyUploadedIds, ...uploadedIds];
|
|
181
|
+
|
|
182
|
+
// Call the original send message with attachment IDs
|
|
183
|
+
props.handleSendMessage(allAttachmentIds);
|
|
184
|
+
|
|
185
|
+
// Clear selected files and revoke URLs
|
|
186
|
+
selectedFiles.forEach((f) => URL.revokeObjectURL(f.previewUrl));
|
|
187
|
+
setSelectedFiles([]);
|
|
188
|
+
setIsSending(false);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.error('[ChatWindowFooter] Error sending message:', error);
|
|
191
|
+
setIsSending(false);
|
|
192
|
+
}
|
|
193
|
+
}, [selectedFiles, props, i18n.language, isSending]);
|
|
158
194
|
|
|
159
195
|
// Check if any files are currently uploading
|
|
160
196
|
const hasUploadingFiles = selectedFiles.some((f) => f.uploading);
|
|
161
197
|
|
|
162
|
-
// Check if there are files
|
|
163
|
-
|
|
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;
|
|
198
|
+
// Check if there are files with errors
|
|
199
|
+
const hasFileErrors = selectedFiles.some((f) => f.error !== null);
|
|
170
200
|
|
|
171
|
-
|
|
172
|
-
|
|
201
|
+
// Allow sending if there's text OR files selected (files will be uploaded on send)
|
|
202
|
+
const hasContentToSend = props.inputMessage.trim() !== '' || selectedFiles.length > 0;
|
|
203
|
+
const isSendDisabled = props.isLoading || isSending || !hasContentToSend || hasUploadingFiles || hasFileErrors;
|
|
204
|
+
const showLoading = props.isLoading || isSending || hasUploadingFiles;
|
|
173
205
|
|
|
174
206
|
const handleKeyDown = useCallback(
|
|
175
207
|
(e: React.KeyboardEvent) => {
|
|
@@ -252,10 +284,16 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
|
|
|
252
284
|
size='icon'
|
|
253
285
|
onClick={handleSendMessageWithAttachments}
|
|
254
286
|
disabled={isSendDisabled}
|
|
255
|
-
className='babylai-rounded-full babylai-bg-primary-500 babylai-hover:babylai-bg-purple-600 babylai-w-8 babylai-h-8 disabled:babylai-opacity-50'
|
|
287
|
+
className='babylai-rounded-full babylai-bg-primary-500 babylai-hover:babylai-bg-purple-600 babylai-w-8 babylai-h-8 !babylai-p-0 babylai-flex babylai-items-center babylai-justify-center disabled:babylai-opacity-50'
|
|
256
288
|
type='button'
|
|
257
289
|
>
|
|
258
|
-
|
|
290
|
+
{showLoading ? (
|
|
291
|
+
<div className='babylai-inline-block babylai-animate-spin babylai-rounded-full babylai-h-4 babylai-w-4 babylai-aspect-square babylai-border-2 babylai-border-white babylai-border-t-transparent babylai-box-border'></div>
|
|
292
|
+
) : (
|
|
293
|
+
<EnvelopeIcon
|
|
294
|
+
className={`babylai-w-4 babylai-h-4 babylai-flex-shrink-0 ${dir === 'rtl' ? 'babylai-rotate-270' : ''}`}
|
|
295
|
+
/>
|
|
296
|
+
)}
|
|
259
297
|
</Button>
|
|
260
298
|
</div>
|
|
261
299
|
|