@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.
- package/dist/components/ui/image-attachment.d.ts +2 -1
- package/dist/index.css +1 -1
- package/dist/index.esm.js +295 -125
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +295 -125
- 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/lib/custom-hooks/useTypewriter.ts +5 -3
- package/src/lib/types.ts +2 -1
- package/src/ui/chatbot-popup/chat-window-screen/footer.tsx +71 -44
- 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;
|
|
@@ -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) => {
|
|
@@ -34,15 +35,13 @@ const ChatWindowFooter: React.FC<ChatWindowFooterProps> = (props) => {
|
|
|
34
35
|
fileInputRef.current?.click();
|
|
35
36
|
}, []);
|
|
36
37
|
|
|
37
|
-
const handleFileSelect = useCallback(
|
|
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
|
-
//
|
|
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
|
-
//
|
|
62
|
-
await handleUploadFiles(newFiles);
|
|
60
|
+
// Don't upload files immediately - wait for send button click
|
|
63
61
|
}, []);
|
|
64
62
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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;
|
|
194
|
+
// Check if there are files with errors
|
|
195
|
+
const hasFileErrors = selectedFiles.some((f) => f.error !== null);
|
|
170
196
|
|
|
171
|
-
|
|
172
|
-
|
|
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) => {
|