import { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { useApiRequest } from './useApi'; import api from '../api'; import { startChatbotStreamApi, stopChatbotApi, getChatbotThreadsApi, getChatbotThreadApi, deleteChatbotWorkflowApi, type ChatDataItem, type StartChatbotRequest, type ChatbotWorkflow } from '../api/chatbotApi'; import { Message } from '../components/UiComponents/Messages/MessagesTypes'; // Simple sort function for messages const sortMessages = (a: Message, b: Message) => { if (a.publishedAt !== undefined && b.publishedAt !== undefined) { return a.publishedAt - b.publishedAt; } if (a.publishedAt !== undefined) return -1; if (b.publishedAt !== undefined) return 1; if (a.sequenceNr !== undefined && b.sequenceNr !== undefined) { return a.sequenceNr - b.sequenceNr; } return 0; }; export function useChatbot() { const [inputValue, setInputValue] = useState(''); const [messages, setMessages] = useState([]); const [workflowId, setWorkflowId] = useState(null); const [isRunning, setIsRunning] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); // File upload state const [pendingFileIds, setPendingFileIds] = useState([]); const pendingFileIdsRef = useRef([]); // Ref to avoid closure issues const [uploadingFile, setUploadingFile] = useState(false); const [uploadError, setUploadError] = useState(null); const [uploadedFiles, setUploadedFiles] = useState>([]); // Chat history state const [threads, setThreads] = useState([]); const [selectedThreadId, setSelectedThreadId] = useState(null); const [threadsLoading, setThreadsLoading] = useState(false); const [threadsError, setThreadsError] = useState(null); const [deletingThreads, setDeletingThreads] = useState>(new Set()); const { request } = useApiRequest(); const streamAbortControllerRef = useRef(null); const processedMessageIdsRef = useRef>(new Set()); const thinkingMessageIdRef = useRef(null); const thinkingLogsRef = useRef([]); // Use ref instead of state to avoid batching const logQueueRef = useRef([]); // Queue for logs to process one by one const isProcessingLogsRef = useRef(false); // Flag to prevent concurrent processing const processedLogsRef = useRef>(new Set()); // Track processed logs to prevent duplicates // Clear processed message IDs when workflow changes const clearProcessedMessages = useCallback(() => { processedMessageIdsRef.current.clear(); }, []); // Clear thinking message when a new assistant message arrives const clearThinkingMessage = useCallback(() => { // Clear log queue and stop processing logQueueRef.current = []; isProcessingLogsRef.current = false; processedLogsRef.current.clear(); // Clear processed logs tracking // Reset thinking message refs const thinkingId = thinkingMessageIdRef.current; thinkingMessageIdRef.current = null; thinkingLogsRef.current = []; // Remove ALL thinking messages (not just the one with current ID) // This handles cases where multiple thinking messages might exist setMessages(prevMessages => { return prevMessages.filter(m => m.status !== 'thinking'); }); }, []); // Process logs from queue one by one (progressive display) const processLogQueue = useCallback(() => { if (isProcessingLogsRef.current || logQueueRef.current.length === 0) { return; } isProcessingLogsRef.current = true; const logMessage = logQueueRef.current.shift()!; // Add log to accumulated logs thinkingLogsRef.current = [...thinkingLogsRef.current, logMessage]; // Get or create thinking message ID const thinkingId = thinkingMessageIdRef.current || `thinking-${Date.now()}`; thinkingMessageIdRef.current = thinkingId; // Create/update thinking message with all accumulated logs // Format logs as Markdown list items so each log appears on a separate line const formattedLogs = thinkingLogsRef.current .map(log => `- ${log.trim()}`) .join('\n'); const thinkingMessage: Message = { id: thinkingId, workflowId: workflowId || '', role: 'assistant', message: formattedLogs, publishedAt: Date.now() - 1, status: 'thinking' }; // Update messages immediately setMessages(prevMessages => { // Remove ALL thinking messages first (to prevent duplicates from previous workflows) const filtered = prevMessages.filter(m => m.status !== 'thinking'); // Add updated thinking message const updated = [...filtered, thinkingMessage]; return updated.sort(sortMessages); }); // Process next log after a small delay (progressive display) if (logQueueRef.current.length > 0) { setTimeout(() => { isProcessingLogsRef.current = false; processLogQueue(); }, 50); // Small delay between logs for progressive display } else { isProcessingLogsRef.current = false; } }, [workflowId]); // Add a single log to thinking message (progressive display) const addLogToThinkingMessage = useCallback((logMessage: string, createdAt?: number) => { // Create a unique key for this log message to detect duplicates // Use content + createdAt timestamp if available, otherwise use current time const timestamp = createdAt || Date.now(); const logKey = `${logMessage.trim()}_${timestamp}`; // Skip if this log was already processed if (processedLogsRef.current.has(logKey)) { console.log('[useChatbot] Skipping duplicate log:', logMessage.substring(0, 50) + '...'); return; } // Mark as processed processedLogsRef.current.add(logKey); // Add log to queue logQueueRef.current.push(logMessage); // Start processing if not already processing if (!isProcessingLogsRef.current) { processLogQueue(); } }, [processLogQueue]); // Process SSE event and update messages const processChatDataItem = useCallback((item: ChatDataItem) => { // Log the actual streamed response for debugging console.log('[useChatbot] Streamed item:', { type: item.type, createdAt: item.createdAt, item: item.item }); if (item.type === 'log' && item.item) { // Process log item - add to thinking message one at a time const logData = item.item as any; const logMessage = logData.message || logData.text || ''; console.log('[useChatbot] Processing log:', { message: logMessage.substring(0, 100) + (logMessage.length > 100 ? '...' : ''), createdAt: item.createdAt, fullItem: item }); if (logMessage) { // Add log immediately (progressive display) with createdAt for deduplication addLogToThinkingMessage(logMessage, item.createdAt); } } else if (item.type === 'message' && item.item) { const messageData = item.item as any; // Ensure message has required fields if (!messageData.id) { console.warn('⚠️ Invalid message item (missing id):', messageData); return; } // Check if this is an assistant message that should clear thinking message const messageRole = messageData.role?.toLowerCase(); const isAssistantMessage = messageRole === 'assistant' || messageRole === 'ai' || messageRole === 'system'; // ALWAYS clear thinking message when ANY message arrives (not just assistant) // The thinking message should disappear when the real message comes const thinkingId = thinkingMessageIdRef.current; // Check if we've already processed this message const messageId = messageData.id; // Always clear thinking messages when a real message arrives clearThinkingMessage(); if (processedMessageIdsRef.current.has(messageId)) { // Update existing message setMessages(prevMessages => { const existingIndex = prevMessages.findIndex(m => m.id === messageId); if (existingIndex >= 0) { const updated = [...prevMessages]; updated[existingIndex] = messageData as Message; return updated.sort(sortMessages); } return prevMessages; }); } else { // Add new message processedMessageIdsRef.current.add(messageId); setMessages(prevMessages => { // Add new message (thinking messages already cleared by clearThinkingMessage) const updated = [...prevMessages, messageData as Message]; return updated.sort(sortMessages); }); } } }, [addLogToThinkingMessage]); // Load all threads (with loading state) const loadThreads = useCallback(async () => { try { setThreadsLoading(true); setThreadsError(null); const result = await getChatbotThreadsApi(request); // Sort threads by lastActivity (newest first) const sortedThreads = [...result.items].sort((a, b) => { const aTime = a.lastActivity || a.startedAt || 0; const bTime = b.lastActivity || b.startedAt || 0; return bTime - aTime; // Descending order (newest first) }); setThreads(sortedThreads); } catch (err: any) { console.error('Error loading threads:', err); setThreadsError(err.message || 'Failed to load threads'); } finally { setThreadsLoading(false); } }, [request]); // Load threads silently (without loading state) - keeps existing threads visible const loadThreadsSilently = useCallback(async () => { try { setThreadsError(null); const result = await getChatbotThreadsApi(request); // Sort threads by lastActivity (newest first) const sortedThreads = [...result.items].sort((a, b) => { const aTime = a.lastActivity || a.startedAt || 0; const bTime = b.lastActivity || b.startedAt || 0; return bTime - aTime; // Descending order (newest first) }); setThreads(sortedThreads); } catch (err: any) { console.error('Error loading threads silently:', err); setThreadsError(err.message || 'Failed to load threads'); } }, [request]); // Load a specific workflow and its messages const loadWorkflow = useCallback(async (workflowIdToLoad: string) => { try { setError(null); const result = await getChatbotThreadApi(request, workflowIdToLoad); console.log('[loadWorkflow] Full result:', JSON.stringify(result, null, 2)); console.log('[loadWorkflow] chatData structure:', { isArray: Array.isArray(result.chatData), isObject: result.chatData && typeof result.chatData === 'object', chatDataKeys: result.chatData && typeof result.chatData === 'object' ? Object.keys(result.chatData) : [], hasItems: result.chatData?.items !== undefined, itemsLength: Array.isArray(result.chatData?.items) ? result.chatData.items.length : 'not an array' }); // Convert chatData to Message[] const loadedMessages: Message[] = []; const processedIds = new Set(); // Backend returns chatData as { items: ChatDataItem[] } structure const chatDataArray: any[] = result.chatData?.items || []; console.log('[loadWorkflow] Processing chatDataArray:', { length: chatDataArray.length, types: chatDataArray.map((item, idx) => ({ index: idx, type: item?.type, hasItem: !!item?.item, keys: item ? Object.keys(item) : [] })) }); for (const item of chatDataArray) { // Handle ChatDataItem structure: { type: 'message', createdAt: number, item: Message } if (item.type === 'message' && item.item) { const messageData = item.item as any; if (messageData.id && !processedIds.has(messageData.id)) { processedIds.add(messageData.id); loadedMessages.push(messageData as Message); } } // Handle direct Message structure (if item is already a message) else if (item.role && item.message !== undefined) { // This looks like a Message object directly if (item.id && !processedIds.has(item.id)) { processedIds.add(item.id); loadedMessages.push(item as Message); } } } console.log('[loadWorkflow] Loaded messages:', { count: loadedMessages.length, messageIds: loadedMessages.map(m => m.id), sampleMessage: loadedMessages.length > 0 ? loadedMessages[0] : null }); // Sort messages chronologically const sortedMessages = loadedMessages.sort(sortMessages); // Update state setMessages(sortedMessages); setWorkflowId(workflowIdToLoad); processedMessageIdsRef.current = processedIds; console.log('[loadWorkflow] State updated:', { messagesCount: sortedMessages.length, workflowId: workflowIdToLoad }); // Don't refresh threads list here - it causes unwanted loading state // Threads will be refreshed after new messages are sent/completed } catch (err: any) { console.error('Error loading workflow:', err); setError(err.message || 'Failed to load workflow'); } }, [request]); // Select a thread (loads its messages) const selectThread = useCallback(async (workflowIdToSelect: string) => { setSelectedThreadId(workflowIdToSelect); await loadWorkflow(workflowIdToSelect); }, [loadWorkflow]); // Auto-load threads on initialization useEffect(() => { loadThreads(); }, [loadThreads]); // Handle input change const onInputChange = useCallback((value: string) => { setInputValue(value); }, []); // Handle file upload const handleFileUpload = useCallback(async (file: File): Promise<{ success: boolean; data?: any }> => { setUploadError(null); setUploadingFile(true); try { // Validate file before upload if (!file || !file.name || file.name.trim() === '') { throw new Error('Invalid file: File must have a valid name'); } if (file.size === 0) { throw new Error('Invalid file: File cannot be empty'); } const formData = new FormData(); formData.append('file', file); const response = await api.post('/api/files/upload', formData, { headers: { 'Content-Type': 'multipart/form-data', } }); const fileData = response.data; // Extract fileId from response // Backend returns: { message: "...", file: { id: "...", ... }, duplicateType: "..." } const fileId = fileData?.file?.id || fileData?.id || fileData?.fileId; if (!fileId) { console.error('Upload response structure:', fileData); throw new Error('Upload failed: No file ID returned from server'); } // Extract file name from response (use storedFileName if available, otherwise original fileName) const fileName = fileData?.file?.fileName || fileData?.storedFileName || file.name; // Add to pending file IDs and uploaded files list setPendingFileIds(prev => { const updated = [...prev, fileId]; pendingFileIdsRef.current = updated; // Keep ref in sync return updated; }); setUploadedFiles(prev => [...prev, { fileId, fileName }]); return { success: true, data: fileData }; } catch (err: any) { console.error('File upload failed:', err); const errorMessage = err.message || 'Failed to upload file'; setUploadError(errorMessage); return { success: false, error: errorMessage }; } finally { setUploadingFile(false); } }, []); // Handle file remove (remove from pending list) const handleFileRemove = useCallback((fileId: string) => { setPendingFileIds(prev => { const updated = prev.filter(id => id !== fileId); pendingFileIdsRef.current = updated; // Keep ref in sync return updated; }); setUploadedFiles(prev => prev.filter(f => f.fileId !== fileId)); }, []); // Stop chatbot workflow const stopChatbot = useCallback(async () => { if (!workflowId || !isRunning) { return; } try { setIsSubmitting(true); // Abort any ongoing stream if (streamAbortControllerRef.current) { streamAbortControllerRef.current.abort(); streamAbortControllerRef.current = null; } // Clear thinking message when stopping clearThinkingMessage(); // Call stop API await stopChatbotApi(request, workflowId); setIsRunning(false); setError(null); } catch (err: any) { console.error('Error stopping chatbot:', err); setError(err.message || 'Failed to stop chatbot'); } finally { setIsSubmitting(false); } }, [workflowId, isRunning, request, clearThinkingMessage]); // Handle form submit const handleSubmit = useCallback(async () => { // If running, stop instead of starting if (isRunning && workflowId) { await stopChatbot(); return; } const trimmedInput = inputValue.trim(); if (!trimmedInput || isSubmitting) { return; } try { setIsSubmitting(true); setError(null); setIsRunning(true); // Abort any existing stream if (streamAbortControllerRef.current) { streamAbortControllerRef.current.abort(); } // Create new abort controller for this stream const abortController = new AbortController(); streamAbortControllerRef.current = abortController; // Use ref to get current file IDs (avoids closure issues) const fileIdsToSend = pendingFileIdsRef.current.length > 0 ? pendingFileIdsRef.current : pendingFileIds; // Fallback to state if ref is empty // Log for debugging console.log('[handleSubmit] pendingFileIds from state:', pendingFileIds); console.log('[handleSubmit] pendingFileIds from ref:', pendingFileIdsRef.current); console.log('[handleSubmit] fileIdsToSend:', fileIdsToSend); const requestBody: StartChatbotRequest = { prompt: trimmedInput, userLanguage: 'en', ...(workflowId && { workflowId }) }; // Always include listFileId if there are any files if (fileIdsToSend.length > 0) { requestBody.listFileId = fileIdsToSend; console.log('[handleSubmit] Added listFileId to requestBody:', fileIdsToSend); } else { console.warn('[handleSubmit] No file IDs to send! Check if files were uploaded correctly.'); } console.log('[handleSubmit] Final requestBody:', JSON.stringify(requestBody, null, 2)); // Track if workflow was created in this request let workflowCreated = false; // Clear thinking message when starting a new request clearThinkingMessage(); processedLogsRef.current.clear(); // Clear processed logs for new request // Track if this is the first event (to reset isSubmitting) let firstEventReceived = false; // Start SSE stream await startChatbotStreamApi( requestBody, (item: ChatDataItem) => { // Check if stream was aborted if (abortController.signal.aborted) { return; } // Reset isSubmitting after first event to enable stop button if (!firstEventReceived) { firstEventReceived = true; setIsSubmitting(false); } // Process the chat data item processChatDataItem(item); // Extract workflow ID from message if available if (item.type === 'message' && item.item) { const messageData = item.item as any; if (messageData.workflowId) { if (!workflowId) { // New workflow created - select it automatically setWorkflowId(messageData.workflowId); setSelectedThreadId(messageData.workflowId); workflowCreated = true; } else { // Existing workflow - ensure it's selected setSelectedThreadId(messageData.workflowId); } } } }, (err: Error) => { // Only handle error if stream wasn't aborted if (!abortController.signal.aborted) { console.error('SSE stream error:', err); setError(err.message || 'Stream error occurred'); setIsRunning(false); // Reset isSubmitting if stream fails before first event if (!firstEventReceived) { setIsSubmitting(false); } // Clear thinking messages on error clearThinkingMessage(); } else { // Stream was aborted (stopped) - clear thinking messages clearThinkingMessage(); } }, () => { // Stream completed if (!abortController.signal.aborted) { setIsRunning(false); setInputValue(''); // Clear input on completion // Clear pending file IDs after successful submission (files are now part of conversation) setPendingFileIds([]); pendingFileIdsRef.current = []; // Clear ref too setUploadedFiles([]); // Clear thinking message on completion (final message should have cleared it, but ensure cleanup) clearThinkingMessage(); // Refresh threads list after message completion (silently, without loading state) setTimeout(() => { loadThreadsSilently(); }, 100); } else { // Stream was aborted (stopped) - clear thinking messages clearThinkingMessage(); } } ); // Clear input after starting (optimistic) if (!workflowId) { setInputValue(''); } } catch (err: any) { console.error('Error starting chatbot:', err); setError(err.message || 'Failed to start chatbot'); setIsRunning(false); // Clear thinking messages on error clearThinkingMessage(); } finally { setIsSubmitting(false); streamAbortControllerRef.current = null; } }, [inputValue, workflowId, isRunning, isSubmitting, stopChatbot, processChatDataItem, clearThinkingMessage, loadThreads, pendingFileIds]); // Delete a chatbot workflow const handleDeleteThread = useCallback(async (workflowIdToDelete: string): Promise => { try { // Add to deleting set setDeletingThreads(prev => new Set(prev).add(workflowIdToDelete)); // If deleting the selected thread, clear selection and messages if (selectedThreadId === workflowIdToDelete) { setMessages([]); setWorkflowId(null); setSelectedThreadId(null); } // Call delete API await deleteChatbotWorkflowApi(request, workflowIdToDelete); // Remove from threads list optimistically setThreads(prev => prev.filter(t => t.id !== workflowIdToDelete)); return true; } catch (err: any) { console.error('Error deleting thread:', err); throw err; } finally { // Remove from deleting set setDeletingThreads(prev => { const next = new Set(prev); next.delete(workflowIdToDelete); return next; }); } }, [request, selectedThreadId]); // Optimistically remove thread from list const removeThreadOptimistically = useCallback((workflowId: string) => { setThreads(prev => prev.filter(t => t.id !== workflowId)); // If deleting the selected thread, clear selection if (selectedThreadId === workflowId) { setSelectedThreadId(null); setMessages([]); setWorkflowId(null); } }, [selectedThreadId]); // Start a new chat (clears selection and messages) const startNewChat = useCallback(() => { // Abort any ongoing stream if (streamAbortControllerRef.current) { streamAbortControllerRef.current.abort(); streamAbortControllerRef.current = null; } // Clear messages and selection setMessages([]); setWorkflowId(null); setSelectedThreadId(null); setError(null); setInputValue(''); setPendingFileIds([]); pendingFileIdsRef.current = []; setUploadedFiles([]); thinkingLogsRef.current = []; thinkingMessageIdRef.current = null; processedLogsRef.current.clear(); clearProcessedMessages(); }, [clearProcessedMessages]); // Reset chatbot state const resetChatbot = useCallback(() => { // Abort any ongoing stream if (streamAbortControllerRef.current) { streamAbortControllerRef.current.abort(); streamAbortControllerRef.current = null; } setMessages([]); setWorkflowId(null); setSelectedThreadId(null); setIsRunning(false); setIsSubmitting(false); setError(null); setInputValue(''); setPendingFileIds([]); pendingFileIdsRef.current = []; setUploadedFiles([]); thinkingLogsRef.current = []; thinkingMessageIdRef.current = null; processedLogsRef.current.clear(); clearProcessedMessages(); }, [clearProcessedMessages]); // Cleanup on unmount const cleanup = useCallback(() => { if (streamAbortControllerRef.current) { streamAbortControllerRef.current.abort(); streamAbortControllerRef.current = null; } logQueueRef.current = []; isProcessingLogsRef.current = false; processedLogsRef.current.clear(); }, []); // Memoized display messages const displayMessages = useMemo(() => { return messages.sort(sortMessages); }, [messages]); return { // GenericDataHook interface data: [], loading: false, error, // Input form interface inputValue, onInputChange, handleSubmit, isSubmitting, // Workflow state workflowId: workflowId || undefined, isRunning, // Messages messages: displayMessages, // Chat history state threads, selectedThreadId, threadsLoading, threadsError, // Chat history methods selectThread, loadThreads, // Delete methods handleDelete: handleDeleteThread, removeOptimistically: removeThreadOptimistically, deletingItems: deletingThreads, refetch: loadThreads, // Additional methods stopChatbot, resetChatbot, startNewChat, cleanup, // File upload interface handleFileUpload, handleUpload: handleFileUpload, // Alias for compatibility with DragDropOverlay handleFileRemove, pendingFileIds, uploadedFiles, uploadingFile, uploadError }; } export function createChatbotHook() { return () => useChatbot(); }