ui-nyla/src/hooks/useChatbot.ts

455 lines
15 KiB
TypeScript

/**
* 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<void>;
createNewThread: () => void;
sendMessage: (input: string, files?: Array<{ id: string; name: string }>) => Promise<void>;
stopStreaming: () => Promise<void>;
deleteThread: (workflowId: string) => Promise<void>;
refreshThreads: () => Promise<void>;
// 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<ChatbotWorkflow[]>([]);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [loadingThreads, setLoadingThreads] = useState(false);
// Messages state
const [messages, setMessages] = useState<Message[]>([]);
const [loadingMessages, setLoadingMessages] = useState(false);
// Current workflow state
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
const [isStreaming, setIsStreaming] = useState(false);
const [streamingStatus, setStreamingStatus] = useState<string | null>(null);
// Error state
const [error, setError] = useState<string | null>(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
};
};
}