/** * useChatbot Hook * * Hook for managing chatbot conversations, messages, and chat functionality. */ import { useState, useEffect, useCallback, useRef } from 'react'; import { useApiRequest } from './useApi'; import { getChatbotThreadsApi, getChatbotThreadApi, startChatbotStreamApi, stopChatbotApi, deleteChatbotWorkflowApi, type ChatbotWorkflow, type ChatDataItem, type StartChatbotRequest } from '../api/chatbotApi'; import { Message, getConversationId } from '../components/UiComponents/Messages/MessagesTypes'; import { useInstanceId } from './useCurrentInstance'; export interface ChatbotHookReturn { // Threads/Conversations threads: ChatbotWorkflow[]; selectedThreadId: string | null; loadingThreads: boolean; error: string | null; // Messages messages: Message[]; loadingMessages: boolean; // Current workflow state currentWorkflowId: string | null; isStreaming: boolean; streamingStatus: string | null; // Current streaming status message // Actions selectThread: (workflowId: string) => Promise; createNewThread: () => void; sendMessage: (input: string, files?: Array<{ id: string; name: string }>) => Promise; stopStreaming: () => Promise; deleteThread: (workflowId: string) => Promise; refreshThreads: () => Promise; // Input form state inputValue: string; setInputValue: (value: string) => void; } /** * Main chatbot hook */ export function useChatbot(): ChatbotHookReturn { const { request } = useApiRequest(); const instanceId = useInstanceId(); // Threads state const [threads, setThreads] = useState([]); const [selectedThreadId, setSelectedThreadId] = useState(null); const [loadingThreads, setLoadingThreads] = useState(false); // Messages state const [messages, setMessages] = useState([]); const [loadingMessages, setLoadingMessages] = useState(false); // Current workflow state const [currentWorkflowId, setCurrentWorkflowId] = useState(null); const [isStreaming, setIsStreaming] = useState(false); const [streamingStatus, setStreamingStatus] = useState(null); // Error state const [error, setError] = useState(null); // Input state const [inputValue, setInputValue] = useState(''); // Ref to track if component is mounted const isMountedRef = useRef(true); useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); // Load threads const refreshThreads = useCallback(async () => { if (!instanceId) return; setLoadingThreads(true); setError(null); try { const result = await getChatbotThreadsApi(request, instanceId); if (isMountedRef.current) { setThreads(result.items || []); } } catch (err: any) { console.error('Error loading threads:', err); if (isMountedRef.current) { setError(err.message || 'Fehler beim Laden der Konversationen'); } } finally { if (isMountedRef.current) { setLoadingThreads(false); } } }, [request, instanceId]); // Load messages for a thread const loadThreadMessages = useCallback(async (workflowId: string) => { if (!instanceId) return; setLoadingMessages(true); setError(null); try { const result = await getChatbotThreadApi(request, instanceId, workflowId); if (isMountedRef.current) { // Extract messages from chatData items const messageItems = (result.chatData?.items || []) .filter((item: ChatDataItem) => item.type === 'message') .map((item: ChatDataItem) => item.item as Message); setMessages(messageItems); setCurrentWorkflowId(workflowId); } } catch (err: any) { console.error('Error loading thread messages:', err); if (isMountedRef.current) { setError(err.message || 'Fehler beim Laden der Nachrichten'); setMessages([]); } } finally { if (isMountedRef.current) { setLoadingMessages(false); } } }, [request, instanceId]); // Select a thread const selectThread = useCallback(async (workflowId: string) => { setSelectedThreadId(workflowId); await loadThreadMessages(workflowId); }, [loadThreadMessages]); // Create new thread const createNewThread = useCallback(() => { setSelectedThreadId(null); setMessages([]); setCurrentWorkflowId(null); setInputValue(''); }, []); // Send message const sendMessage = useCallback(async ( input: string, files?: Array<{ id: string; name: string }> ) => { if (!input.trim() || isStreaming || !instanceId) return; setError(null); setIsStreaming(true); setStreamingStatus(null); // Reset status // Store the input message content to track duplicates const inputMessageContent = input.trim(); // Add user message immediately for better UX const tempUserMessageId = `temp-user-${Date.now()}`; const userMessage: Message = { id: tempUserMessageId, workflowId: currentWorkflowId || undefined, conversationId: currentWorkflowId || undefined, role: 'user', message: inputMessageContent, publishedAt: Date.now() }; setMessages(prev => [...prev, userMessage]); setInputValue(''); try { const requestBody: StartChatbotRequest = { prompt: input, workflowId: currentWorkflowId || undefined, listFileId: files?.map(f => f.id), userLanguage: navigator.language || 'de' }; let newWorkflowId: string | null = null; await startChatbotStreamApi( instanceId, requestBody, (item: ChatDataItem) => { if (!isMountedRef.current) return; // Handle stopped event if (item.type === 'stopped') { console.log('Received stopped event from backend'); setIsStreaming(false); setStreamingStatus(null); return; } // Handle status event (streaming progress updates) if (item.type === 'status') { const statusLabel = item.label || (item.item as any)?.label || ''; console.log('Received status update:', statusLabel); setStreamingStatus(statusLabel); return; } // Handle workflow update (includes name updates from background task) if (item.type === 'stat' && item.item?.id) { newWorkflowId = item.item.id; setCurrentWorkflowId(item.item.id); if (!selectedThreadId) { setSelectedThreadId(item.item.id); } // Check if workflow status is stopped if (item.item.status === 'stopped') { console.log('Workflow status is stopped'); setIsStreaming(false); } // Refresh threads when workflow data arrives (e.g. name update from background) if (item.item?.name) { refreshThreads(); } } // Handle messages if (item.type === 'message' && item.item) { const message = item.item as Message; // Extract conversation/workflow ID from message (supports workflowId and conversationId) const extractedWorkflowId = getConversationId(message); if (extractedWorkflowId) { // Update local variable and state if not already set if (!newWorkflowId) { newWorkflowId = extractedWorkflowId; console.log('Extracting workflowId from message:', extractedWorkflowId); } // Always update state to ensure we have the latest workflowId setCurrentWorkflowId(prev => { if (!prev) { console.log('Setting currentWorkflowId from message:', extractedWorkflowId); return extractedWorkflowId; } return prev; }); if (!selectedThreadId) { setSelectedThreadId(extractedWorkflowId); } } setMessages(prev => { // Check if message already exists by ID if (prev.some(m => m.id === message.id)) { return prev; } // Backend sends the "first" message with the transformed/normalized user prompt // Replace the temporary optimistic message with it if (message.status === 'first') { return prev.map(m => m.id === tempUserMessageId ? message : m ); } // For other messages, check for duplicates by role and content (more lenient check) const isDuplicate = prev.some(m => { // Exact ID match if (m.id === message.id) return true; // For same role and content, check if it's a duplicate if (m.role === message.role && m.message === message.message) { // If it's a user message, it's definitely a duplicate if (message.role === 'user') return true; // For assistant messages, check if timestamps are very close (within 1 second) if (m.publishedAt && message.publishedAt) { return Math.abs(m.publishedAt - message.publishedAt) < 1000; } } return false; }); if (isDuplicate) return prev; return [...prev, message]; }); } }, (err: Error) => { console.error('Stream error:', err); if (isMountedRef.current) { setError(err.message || 'Fehler beim Senden der Nachricht'); setIsStreaming(false); } }, () => { if (isMountedRef.current) { setIsStreaming(false); setStreamingStatus(null); // Clear status on completion // Refresh threads to get updated list refreshThreads(); } } ); // Refresh threads after completion if (newWorkflowId) { await refreshThreads(); } } catch (err: any) { console.error('Error sending message:', err); if (isMountedRef.current) { setError(err.message || 'Fehler beim Senden der Nachricht'); setIsStreaming(false); } } }, [currentWorkflowId, selectedThreadId, isStreaming, instanceId, refreshThreads]); // Stop streaming const stopStreaming = useCallback(async () => { if (!instanceId) { console.warn('Cannot stop: missing instanceId', { instanceId }); return; } if (!isStreaming) { console.warn('Cannot stop: not currently streaming'); return; } // Immediately reset UI state for instant feedback setIsStreaming(false); console.log('UI reset immediately after stop button click'); // Try to get workflowId from currentWorkflowId, or from the latest message let workflowIdToStop = currentWorkflowId; if (!workflowIdToStop && messages.length > 0) { // Try to extract workflowId from the latest message (supports workflowId and conversationId) const latestMessage = messages[messages.length - 1]; workflowIdToStop = getConversationId(latestMessage) || undefined; if (workflowIdToStop) { console.log('Extracted workflowId from latest message:', workflowIdToStop); } } if (!workflowIdToStop) { console.warn('Cannot stop: missing workflowId, but UI already reset', { currentWorkflowId, messagesCount: messages.length, latestMessage: messages.length > 0 ? messages[messages.length - 1] : null }); // UI already reset above, just return return; } // Send stop request to backend (fire and forget - UI already reset) try { console.log('Sending stop request to backend for workflow:', workflowIdToStop); // Don't await - let it run in background, UI is already reset stopChatbotApi(request, instanceId, workflowIdToStop).catch((err: any) => { console.error('Error stopping stream on backend (non-blocking):', err); // Optionally show a non-intrusive error notification if (isMountedRef.current) { // Don't reset isStreaming again as it's already false // Just log the error console.warn('Backend stop request failed, but UI was already reset'); } }); } catch (err: any) { console.error('Error initiating stop request:', err); // UI already reset, so just log the error } }, [currentWorkflowId, isStreaming, instanceId, request, messages]); // Delete thread const deleteThread = useCallback(async (workflowId: string) => { if (!instanceId) return; // Optimistic UI update - remove thread immediately const previousThreads = threads; setThreads(prev => prev.filter(t => t.id !== workflowId)); // If deleted thread was selected, clear selection immediately if (selectedThreadId === workflowId) { createNewThread(); } try { await deleteChatbotWorkflowApi(request, instanceId, workflowId); // Refresh threads list to sync with server await refreshThreads(); } catch (err: any) { console.error('Error deleting thread:', err); // Restore threads on error setThreads(previousThreads); setError(err.message || 'Fehler beim Löschen der Konversation'); } }, [request, instanceId, selectedThreadId, threads, createNewThread, refreshThreads]); // Initial load useEffect(() => { if (instanceId) { refreshThreads(); } }, [instanceId, refreshThreads]); return { threads, selectedThreadId, loadingThreads, error, messages, loadingMessages, currentWorkflowId, isStreaming, streamingStatus, selectThread, createNewThread, sendMessage, stopStreaming, deleteThread, refreshThreads, inputValue, setInputValue }; } /** * Hook factory for use in GenericPageData inputFormConfig */ export function createChatbotHook() { return () => { const chatbot = useChatbot(); return { messages: chatbot.messages, loading: chatbot.loadingMessages || chatbot.isStreaming, error: chatbot.error, data: [], inputValue: chatbot.inputValue, onInputChange: chatbot.setInputValue, handleSubmit: async () => { await chatbot.sendMessage(chatbot.inputValue); }, isSubmitting: chatbot.isStreaming, stopAction: chatbot.stopStreaming, canStop: chatbot.isStreaming }; }; }