fix:fixed and finished chatbot integration
This commit is contained in:
parent
23508eaa74
commit
836b8032ae
3 changed files with 90 additions and 71 deletions
|
|
@ -181,11 +181,7 @@ export function FormGeneratorControls({
|
||||||
size="sm"
|
size="sm"
|
||||||
icon={FaTrash}
|
icon={FaTrash}
|
||||||
>
|
>
|
||||||
<<<<<<< HEAD
|
|
||||||
{allItemsSelected
|
{allItemsSelected
|
||||||
=======
|
|
||||||
{selectedCount === displayData.length && displayData.length > 0
|
|
||||||
>>>>>>> c76e7efd28210f45737b5afbdddff2712b2c0cc7
|
|
||||||
? t('formgen.delete.all', `Delete all ${selectedCount} items`).replace('{count}', selectedCount.toString())
|
? t('formgen.delete.all', `Delete all ${selectedCount} items`).replace('{count}', selectedCount.toString())
|
||||||
: t('formgen.delete.multiple', `Delete ${selectedCount} selected items`).replace('{count}', selectedCount.toString())}
|
: t('formgen.delete.multiple', `Delete ${selectedCount} selected items`).replace('{count}', selectedCount.toString())}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -1006,7 +1006,12 @@ const PageRenderer: React.FC<PageRendererProps> = ({
|
||||||
const buttonVariant = isRunning
|
const buttonVariant = isRunning
|
||||||
? (config.stopButtonVariant || config.buttonVariant || 'primary')
|
? (config.stopButtonVariant || config.buttonVariant || 'primary')
|
||||||
: (config.buttonVariant || 'primary');
|
: (config.buttonVariant || 'primary');
|
||||||
const buttonDisabled = hookData.isSubmitting || (!isRunning && !hookData.inputValue?.trim());
|
// Button disabled logic:
|
||||||
|
// - Always enabled when running (to allow stopping), unless submitting
|
||||||
|
// - When not running, disabled if submitting or input is empty
|
||||||
|
const buttonDisabled = isRunning
|
||||||
|
? hookData.isSubmitting // When running, only disable if submitting
|
||||||
|
: (hookData.isSubmitting || !hookData.inputValue?.trim()); // When not running, disable if submitting or input empty
|
||||||
|
|
||||||
// Handle Enter key press
|
// Handle Enter key press
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ export function useChatbot() {
|
||||||
const thinkingLogsRef = useRef<string[]>([]); // Use ref instead of state to avoid batching
|
const thinkingLogsRef = useRef<string[]>([]); // Use ref instead of state to avoid batching
|
||||||
const logQueueRef = useRef<string[]>([]); // Queue for logs to process one by one
|
const logQueueRef = useRef<string[]>([]); // Queue for logs to process one by one
|
||||||
const isProcessingLogsRef = useRef<boolean>(false); // Flag to prevent concurrent processing
|
const isProcessingLogsRef = useRef<boolean>(false); // Flag to prevent concurrent processing
|
||||||
|
const processedLogsRef = useRef<Set<string>>(new Set()); // Track processed logs to prevent duplicates
|
||||||
|
|
||||||
// Clear processed message IDs when workflow changes
|
// Clear processed message IDs when workflow changes
|
||||||
const clearProcessedMessages = useCallback(() => {
|
const clearProcessedMessages = useCallback(() => {
|
||||||
|
|
@ -65,16 +66,18 @@ export function useChatbot() {
|
||||||
// Clear log queue and stop processing
|
// Clear log queue and stop processing
|
||||||
logQueueRef.current = [];
|
logQueueRef.current = [];
|
||||||
isProcessingLogsRef.current = false;
|
isProcessingLogsRef.current = false;
|
||||||
|
processedLogsRef.current.clear(); // Clear processed logs tracking
|
||||||
|
|
||||||
if (thinkingMessageIdRef.current) {
|
// Reset thinking message refs
|
||||||
const thinkingId = thinkingMessageIdRef.current;
|
const thinkingId = thinkingMessageIdRef.current;
|
||||||
thinkingMessageIdRef.current = null;
|
thinkingMessageIdRef.current = null;
|
||||||
thinkingLogsRef.current = [];
|
thinkingLogsRef.current = [];
|
||||||
|
|
||||||
setMessages(prevMessages => {
|
// Remove ALL thinking messages (not just the one with current ID)
|
||||||
return prevMessages.filter(m => m.id !== thinkingId);
|
// 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)
|
// Process logs from queue one by one (progressive display)
|
||||||
|
|
@ -110,8 +113,8 @@ export function useChatbot() {
|
||||||
|
|
||||||
// Update messages immediately
|
// Update messages immediately
|
||||||
setMessages(prevMessages => {
|
setMessages(prevMessages => {
|
||||||
// Remove old thinking message if it exists
|
// Remove ALL thinking messages first (to prevent duplicates from previous workflows)
|
||||||
const filtered = prevMessages.filter(m => m.id !== thinkingId);
|
const filtered = prevMessages.filter(m => m.status !== 'thinking');
|
||||||
// Add updated thinking message
|
// Add updated thinking message
|
||||||
const updated = [...filtered, thinkingMessage];
|
const updated = [...filtered, thinkingMessage];
|
||||||
return updated.sort(sortMessages);
|
return updated.sort(sortMessages);
|
||||||
|
|
@ -129,7 +132,21 @@ export function useChatbot() {
|
||||||
}, [workflowId]);
|
}, [workflowId]);
|
||||||
|
|
||||||
// Add a single log to thinking message (progressive display)
|
// Add a single log to thinking message (progressive display)
|
||||||
const addLogToThinkingMessage = useCallback((logMessage: string) => {
|
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
|
// Add log to queue
|
||||||
logQueueRef.current.push(logMessage);
|
logQueueRef.current.push(logMessage);
|
||||||
|
|
||||||
|
|
@ -141,14 +158,27 @@ export function useChatbot() {
|
||||||
|
|
||||||
// Process SSE event and update messages
|
// Process SSE event and update messages
|
||||||
const processChatDataItem = useCallback((item: ChatDataItem) => {
|
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) {
|
if (item.type === 'log' && item.item) {
|
||||||
// Process log item - add to thinking message one at a time
|
// Process log item - add to thinking message one at a time
|
||||||
const logData = item.item as any;
|
const logData = item.item as any;
|
||||||
const logMessage = logData.message || logData.text || '';
|
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) {
|
if (logMessage) {
|
||||||
// Add log immediately (progressive display)
|
// Add log immediately (progressive display) with createdAt for deduplication
|
||||||
addLogToThinkingMessage(logMessage);
|
addLogToThinkingMessage(logMessage, item.createdAt);
|
||||||
}
|
}
|
||||||
} else if (item.type === 'message' && item.item) {
|
} else if (item.type === 'message' && item.item) {
|
||||||
const messageData = item.item as any;
|
const messageData = item.item as any;
|
||||||
|
|
@ -170,38 +200,26 @@ export function useChatbot() {
|
||||||
// Check if we've already processed this message
|
// Check if we've already processed this message
|
||||||
const messageId = messageData.id;
|
const messageId = messageData.id;
|
||||||
|
|
||||||
if (processedMessageIdsRef.current.has(messageId)) {
|
// Always clear thinking messages when a real message arrives
|
||||||
// Update existing message - clear thinking message first
|
clearThinkingMessage();
|
||||||
setMessages(prevMessages => {
|
|
||||||
let filtered = prevMessages;
|
|
||||||
if (thinkingId) {
|
|
||||||
filtered = prevMessages.filter(m => m.id !== thinkingId);
|
|
||||||
thinkingMessageIdRef.current = null;
|
|
||||||
thinkingLogsRef.current = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingIndex = filtered.findIndex(m => m.id === messageId);
|
if (processedMessageIdsRef.current.has(messageId)) {
|
||||||
|
// Update existing message
|
||||||
|
setMessages(prevMessages => {
|
||||||
|
const existingIndex = prevMessages.findIndex(m => m.id === messageId);
|
||||||
if (existingIndex >= 0) {
|
if (existingIndex >= 0) {
|
||||||
const updated = [...filtered];
|
const updated = [...prevMessages];
|
||||||
updated[existingIndex] = messageData as Message;
|
updated[existingIndex] = messageData as Message;
|
||||||
return updated.sort(sortMessages);
|
return updated.sort(sortMessages);
|
||||||
}
|
}
|
||||||
return filtered;
|
return prevMessages;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Add new message - clear thinking message first
|
// Add new message
|
||||||
processedMessageIdsRef.current.add(messageId);
|
processedMessageIdsRef.current.add(messageId);
|
||||||
setMessages(prevMessages => {
|
setMessages(prevMessages => {
|
||||||
// Remove thinking message BEFORE adding new message (same state update)
|
// Add new message (thinking messages already cleared by clearThinkingMessage)
|
||||||
let filtered = prevMessages;
|
const updated = [...prevMessages, messageData as Message];
|
||||||
if (thinkingId) {
|
|
||||||
filtered = prevMessages.filter(m => m.id !== thinkingId);
|
|
||||||
thinkingMessageIdRef.current = null;
|
|
||||||
thinkingLogsRef.current = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add new message
|
|
||||||
const updated = [...filtered, messageData as Message];
|
|
||||||
return updated.sort(sortMessages);
|
return updated.sort(sortMessages);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -348,7 +366,6 @@ export function useChatbot() {
|
||||||
setInputValue(value);
|
setInputValue(value);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// Handle file upload
|
// Handle file upload
|
||||||
const handleFileUpload = useCallback(async (file: File): Promise<{ success: boolean; data?: any }> => {
|
const handleFileUpload = useCallback(async (file: File): Promise<{ success: boolean; data?: any }> => {
|
||||||
setUploadError(null);
|
setUploadError(null);
|
||||||
|
|
@ -416,8 +433,6 @@ export function useChatbot() {
|
||||||
setUploadedFiles(prev => prev.filter(f => f.fileId !== fileId));
|
setUploadedFiles(prev => prev.filter(f => f.fileId !== fileId));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
=======
|
|
||||||
>>>>>>> c76e7efd28210f45737b5afbdddff2712b2c0cc7
|
|
||||||
// Stop chatbot workflow
|
// Stop chatbot workflow
|
||||||
const stopChatbot = useCallback(async () => {
|
const stopChatbot = useCallback(async () => {
|
||||||
if (!workflowId || !isRunning) {
|
if (!workflowId || !isRunning) {
|
||||||
|
|
@ -476,7 +491,6 @@ export function useChatbot() {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
streamAbortControllerRef.current = abortController;
|
streamAbortControllerRef.current = abortController;
|
||||||
|
|
||||||
<<<<<<< HEAD
|
|
||||||
// Use ref to get current file IDs (avoids closure issues)
|
// Use ref to get current file IDs (avoids closure issues)
|
||||||
const fileIdsToSend = pendingFileIdsRef.current.length > 0
|
const fileIdsToSend = pendingFileIdsRef.current.length > 0
|
||||||
? pendingFileIdsRef.current
|
? pendingFileIdsRef.current
|
||||||
|
|
@ -487,15 +501,11 @@ export function useChatbot() {
|
||||||
console.log('[handleSubmit] pendingFileIds from ref:', pendingFileIdsRef.current);
|
console.log('[handleSubmit] pendingFileIds from ref:', pendingFileIdsRef.current);
|
||||||
console.log('[handleSubmit] fileIdsToSend:', fileIdsToSend);
|
console.log('[handleSubmit] fileIdsToSend:', fileIdsToSend);
|
||||||
|
|
||||||
=======
|
|
||||||
// Prepare request body
|
|
||||||
>>>>>>> c76e7efd28210f45737b5afbdddff2712b2c0cc7
|
|
||||||
const requestBody: StartChatbotRequest = {
|
const requestBody: StartChatbotRequest = {
|
||||||
prompt: trimmedInput,
|
prompt: trimmedInput,
|
||||||
userLanguage: 'en',
|
userLanguage: 'en',
|
||||||
...(workflowId && { workflowId })
|
...(workflowId && { workflowId })
|
||||||
};
|
};
|
||||||
<<<<<<< HEAD
|
|
||||||
|
|
||||||
// Always include listFileId if there are any files
|
// Always include listFileId if there are any files
|
||||||
if (fileIdsToSend.length > 0) {
|
if (fileIdsToSend.length > 0) {
|
||||||
|
|
@ -506,14 +516,16 @@ export function useChatbot() {
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[handleSubmit] Final requestBody:', JSON.stringify(requestBody, null, 2));
|
console.log('[handleSubmit] Final requestBody:', JSON.stringify(requestBody, null, 2));
|
||||||
=======
|
|
||||||
>>>>>>> c76e7efd28210f45737b5afbdddff2712b2c0cc7
|
|
||||||
|
|
||||||
// Track if workflow was created in this request
|
// Track if workflow was created in this request
|
||||||
let workflowCreated = false;
|
let workflowCreated = false;
|
||||||
|
|
||||||
// Clear thinking message when starting a new request
|
// Clear thinking message when starting a new request
|
||||||
clearThinkingMessage();
|
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
|
// Start SSE stream
|
||||||
await startChatbotStreamApi(
|
await startChatbotStreamApi(
|
||||||
|
|
@ -524,6 +536,12 @@ export function useChatbot() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset isSubmitting after first event to enable stop button
|
||||||
|
if (!firstEventReceived) {
|
||||||
|
firstEventReceived = true;
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Process the chat data item
|
// Process the chat data item
|
||||||
processChatDataItem(item);
|
processChatDataItem(item);
|
||||||
|
|
||||||
|
|
@ -549,6 +567,15 @@ export function useChatbot() {
|
||||||
console.error('SSE stream error:', err);
|
console.error('SSE stream error:', err);
|
||||||
setError(err.message || 'Stream error occurred');
|
setError(err.message || 'Stream error occurred');
|
||||||
setIsRunning(false);
|
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();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
|
|
@ -556,19 +583,19 @@ export function useChatbot() {
|
||||||
if (!abortController.signal.aborted) {
|
if (!abortController.signal.aborted) {
|
||||||
setIsRunning(false);
|
setIsRunning(false);
|
||||||
setInputValue(''); // Clear input on completion
|
setInputValue(''); // Clear input on completion
|
||||||
<<<<<<< HEAD
|
|
||||||
// Clear pending file IDs after successful submission (files are now part of conversation)
|
// Clear pending file IDs after successful submission (files are now part of conversation)
|
||||||
setPendingFileIds([]);
|
setPendingFileIds([]);
|
||||||
pendingFileIdsRef.current = []; // Clear ref too
|
pendingFileIdsRef.current = []; // Clear ref too
|
||||||
setUploadedFiles([]);
|
setUploadedFiles([]);
|
||||||
=======
|
// Clear thinking message on completion (final message should have cleared it, but ensure cleanup)
|
||||||
>>>>>>> c76e7efd28210f45737b5afbdddff2712b2c0cc7
|
clearThinkingMessage();
|
||||||
// Clear thinking message on completion if no final message was received
|
// Refresh threads list after message completion (silently, without loading state)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
clearThinkingMessage();
|
|
||||||
// Refresh threads list after message completion (silently, without loading state)
|
|
||||||
loadThreadsSilently();
|
loadThreadsSilently();
|
||||||
}, 100);
|
}, 100);
|
||||||
|
} else {
|
||||||
|
// Stream was aborted (stopped) - clear thinking messages
|
||||||
|
clearThinkingMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
@ -581,15 +608,13 @@ export function useChatbot() {
|
||||||
console.error('Error starting chatbot:', err);
|
console.error('Error starting chatbot:', err);
|
||||||
setError(err.message || 'Failed to start chatbot');
|
setError(err.message || 'Failed to start chatbot');
|
||||||
setIsRunning(false);
|
setIsRunning(false);
|
||||||
|
// Clear thinking messages on error
|
||||||
|
clearThinkingMessage();
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
streamAbortControllerRef.current = null;
|
streamAbortControllerRef.current = null;
|
||||||
}
|
}
|
||||||
<<<<<<< HEAD
|
|
||||||
}, [inputValue, workflowId, isRunning, isSubmitting, stopChatbot, processChatDataItem, clearThinkingMessage, loadThreads, pendingFileIds]);
|
}, [inputValue, workflowId, isRunning, isSubmitting, stopChatbot, processChatDataItem, clearThinkingMessage, loadThreads, pendingFileIds]);
|
||||||
=======
|
|
||||||
}, [inputValue, workflowId, isRunning, isSubmitting, stopChatbot, processChatDataItem, clearThinkingMessage, loadThreads]);
|
|
||||||
>>>>>>> c76e7efd28210f45737b5afbdddff2712b2c0cc7
|
|
||||||
|
|
||||||
// Delete a chatbot workflow
|
// Delete a chatbot workflow
|
||||||
const handleDeleteThread = useCallback(async (workflowIdToDelete: string): Promise<boolean> => {
|
const handleDeleteThread = useCallback(async (workflowIdToDelete: string): Promise<boolean> => {
|
||||||
|
|
@ -649,14 +674,12 @@ export function useChatbot() {
|
||||||
setSelectedThreadId(null);
|
setSelectedThreadId(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
<<<<<<< HEAD
|
|
||||||
setPendingFileIds([]);
|
setPendingFileIds([]);
|
||||||
pendingFileIdsRef.current = [];
|
pendingFileIdsRef.current = [];
|
||||||
setUploadedFiles([]);
|
setUploadedFiles([]);
|
||||||
=======
|
|
||||||
>>>>>>> c76e7efd28210f45737b5afbdddff2712b2c0cc7
|
|
||||||
thinkingLogsRef.current = [];
|
thinkingLogsRef.current = [];
|
||||||
thinkingMessageIdRef.current = null;
|
thinkingMessageIdRef.current = null;
|
||||||
|
processedLogsRef.current.clear();
|
||||||
clearProcessedMessages();
|
clearProcessedMessages();
|
||||||
}, [clearProcessedMessages]);
|
}, [clearProcessedMessages]);
|
||||||
|
|
||||||
|
|
@ -675,14 +698,12 @@ export function useChatbot() {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
setInputValue('');
|
setInputValue('');
|
||||||
<<<<<<< HEAD
|
|
||||||
setPendingFileIds([]);
|
setPendingFileIds([]);
|
||||||
pendingFileIdsRef.current = [];
|
pendingFileIdsRef.current = [];
|
||||||
setUploadedFiles([]);
|
setUploadedFiles([]);
|
||||||
=======
|
|
||||||
>>>>>>> c76e7efd28210f45737b5afbdddff2712b2c0cc7
|
|
||||||
thinkingLogsRef.current = [];
|
thinkingLogsRef.current = [];
|
||||||
thinkingMessageIdRef.current = null;
|
thinkingMessageIdRef.current = null;
|
||||||
|
processedLogsRef.current.clear();
|
||||||
clearProcessedMessages();
|
clearProcessedMessages();
|
||||||
}, [clearProcessedMessages]);
|
}, [clearProcessedMessages]);
|
||||||
|
|
||||||
|
|
@ -694,6 +715,7 @@ export function useChatbot() {
|
||||||
}
|
}
|
||||||
logQueueRef.current = [];
|
logQueueRef.current = [];
|
||||||
isProcessingLogsRef.current = false;
|
isProcessingLogsRef.current = false;
|
||||||
|
processedLogsRef.current.clear();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Memoized display messages
|
// Memoized display messages
|
||||||
|
|
@ -740,7 +762,6 @@ export function useChatbot() {
|
||||||
stopChatbot,
|
stopChatbot,
|
||||||
resetChatbot,
|
resetChatbot,
|
||||||
startNewChat,
|
startNewChat,
|
||||||
<<<<<<< HEAD
|
|
||||||
cleanup,
|
cleanup,
|
||||||
|
|
||||||
// File upload interface
|
// File upload interface
|
||||||
|
|
@ -751,9 +772,6 @@ export function useChatbot() {
|
||||||
uploadedFiles,
|
uploadedFiles,
|
||||||
uploadingFile,
|
uploadingFile,
|
||||||
uploadError
|
uploadError
|
||||||
=======
|
|
||||||
cleanup
|
|
||||||
>>>>>>> c76e7efd28210f45737b5afbdddff2712b2c0cc7
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue