455 lines
15 KiB
TypeScript
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
|
|
};
|
|
};
|
|
}
|