import { useState, useCallback } from 'react'; import { sendChatMessageStream } from '../services/groq.service.ts'; import { generateEnhancedArgument } from '../services/argument.service.ts'; import { getUserId } from '../utils/index.ts'; import type { ChatMessage, ChatState } from '../types/chat.types.ts'; const generateId = () => Math.random().toString(36).substring(2, 15); const initialState: ChatState = { messages: [], isLoading: false, error: null, }; export const useChat = () => { const [state, setState] = useState(initialState); const addMessage = useCallback((message: Omit) => { setState(prev => ({ ...prev, messages: [...prev.messages, { ...message, id: generateId(), timestamp: new Date(), }], })); }, []); const extractTopicAndPosition = useCallback(async (userInput: string): Promise<{ topic: string; position: string }> => { // Default fallback values const fallback = { topic: userInput.trim().slice(0, 100) || 'General Discussion', position: 'positive' }; if (!userInput || !userInput.trim()) { return fallback; } const extractionPrompt = `Extract the debate topic and position from the following text. Return ONLY a JSON object with "topic" and "position" keys. The position should be either "positive" (in favor) or "negative" (against). Text: "${userInput}" Example output: {"topic": "Assisted suicide should be a criminal offence", "position": "positive"}`; try { const response = await sendChatMessageStream([ { id: generateId(), role: 'user', content: extractionPrompt, timestamp: new Date() } ], () => { }); if (!response) { console.warn('Empty response from extraction API'); return fallback; } // Try to extract JSON from the response (handles markdown blocks and extra text) const jsonMatch = response.match(/\{[\s\S]*?\}/); if (jsonMatch) { try { const parsed = JSON.parse(jsonMatch[0]); return { topic: parsed.topic || fallback.topic, position: (parsed.position?.toLowerCase().includes('neg')) ? 'negative' : 'positive' }; } catch (parseError) { console.error('Failed to parse extracted JSON:', parseError, 'Raw match:', jsonMatch[0]); } } else { console.warn('No JSON structure found in extraction response:', response); } return fallback; } catch (error) { console.error('Error in extractTopicAndPosition process:', error); return fallback; } }, []); const sendAudioMessage = useCallback(async (audioBlob: Blob, selectedTool?: string | null) => { console.log('sendAudioMessage called with:', { size: audioBlob.size, type: audioBlob.type }); // Add user audio message const userMessage: Omit = { role: 'user', content: 'Audio message sent', audioUrl: URL.createObjectURL(audioBlob), tool: selectedTool ?? null, }; addMessage(userMessage); // Set loading state setState(prev => ({ ...prev, isLoading: true, error: null })); try { // Get user ID const userId = getUserId(); if (!userId) { throw new Error('User ID not found. Please register or log in.'); } // Get webhook URL const webhookUrl = process.env.REACT_APP_N8N_WEBHOOK_URL; if (!webhookUrl) { throw new Error('REACT_APP_N8N_WEBHOOK_URL environment variable is not set'); } console.log('Sending audio to webhook:', { userId, webhookUrl }); // Create form data for audio file upload const formData = new FormData(); const file = new File([audioBlob], 'recording.webm', { type: audioBlob.type }); formData.append('file', file); formData.append('user_id', userId); console.log('FormData created with', formData.get('file') ? 'file' : 'no file'); // Call the debate-assistant endpoint with audio file const response = await fetch(`${webhookUrl}/debate-assistant`, { method: 'POST', body: formData, }); console.log('Response received:', response.status); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); console.log('Response data:', data); // Extract tts_text from response (based on your curl example) const ttsText = data?.tts_text || data?.final_argument || data?.result?.result?.[0]?.text || 'No response received'; // Extract audio_base64 from response if available const audioBase64 = data?.audio_base64; // Add assistant response with both text and audio if available const assistantMessage: Omit = { role: 'assistant', content: ttsText, }; // Add audio URL to message if audio_base64 is present if (audioBase64) { // Convert base64 to audio blob and create object URL const binaryString = atob(audioBase64); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } const audioBlob = new Blob([bytes], { type: 'audio/wav' }); assistantMessage.audioUrl = URL.createObjectURL(audioBlob); } addMessage(assistantMessage); setState(prev => ({ ...prev, isLoading: false })); } catch (error) { console.error('Error in sendAudioMessage:', error); const errorMessage = error instanceof Error ? error.message : 'Failed to send audio message'; setState(prev => ({ ...prev, isLoading: false, error: errorMessage })); } }, [addMessage]); const sendMessage = useCallback(async (content: string, selectedTool?: string | null, stance?: 'positive' | 'negative') => { // Check if detect stance tool is selected (to format user message differently) const isDetectStanceTool = selectedTool && ( selectedTool.toLowerCase().includes('detect') && selectedTool.toLowerCase().includes('stance') ); // Format user message content based on tool let userMessageContent = content; if (isDetectStanceTool) { try { const parsed = JSON.parse(content); // Format as readable text instead of JSON userMessageContent = `**Topic:** ${parsed.topic}\n**Argument:** ${parsed.argument}`; } catch (error) { // If parsing fails, use original content userMessageContent = content; } } // Add user message const userMessage: Omit = { role: 'user', content: userMessageContent, tool: selectedTool ?? null, }; addMessage(userMessage); // Set loading state setState(prev => ({ ...prev, isLoading: true, error: null })); try { // Check if detect stance tool is selected const isDetectStanceTool = selectedTool && ( selectedTool.toLowerCase().includes('detect') && selectedTool.toLowerCase().includes('stance') ); // Check if extract topic tool is selected const isExtractTopicTool = selectedTool && ( selectedTool.toLowerCase().includes('extract') && selectedTool.toLowerCase().includes('topic') ); // Check if generate argument tool is selected (check for multiple possible names) const isGenerateArgumentTool = selectedTool && ( selectedTool.toLowerCase().includes('generate') && selectedTool.toLowerCase().includes('argument') ); if (isDetectStanceTool) { console.log('Detect stance tool detected:', selectedTool); // Parse the JSON input containing topic and argument let topic: string; let argument: string; try { const parsed = JSON.parse(content); topic = parsed.topic; argument = parsed.argument; } catch (error) { throw new Error('Invalid input format. Expected JSON with "topic" and "argument" fields.'); } // Get user ID const userId = getUserId(); if (!userId) { throw new Error('User ID not found. Please register or log in.'); } // Get detect stance webhook URL const detectStanceWebhookUrl = process.env.REACT_APP_DETECT_STANCE_N8N_WEBHOOK_URL; const baseWebhookUrl = process.env.REACT_APP_N8N_WEBHOOK_URL; const webhookUrl = detectStanceWebhookUrl || baseWebhookUrl; if (!webhookUrl) { throw new Error('REACT_APP_DETECT_STANCE_N8N_WEBHOOK_URL or REACT_APP_N8N_WEBHOOK_URL environment variable is not set'); } // Determine the endpoint (use full URL if provided, otherwise append endpoint) const endpoint = detectStanceWebhookUrl ? '' // Full URL provided, no endpoint needed : '/detect-stance'; // Append endpoint to base URL // Call the detect stance endpoint const response = await fetch(`${webhookUrl}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ topic: topic, argument: argument, }), }); if (!response.ok) { const errorText = await response.text(); console.error('Detect stance API error:', response.status, errorText); throw new Error(`HTTP error! status: ${response.status}: ${errorText}`); } const data = await response.json(); console.log('Detect stance API response:', data); // Handle array response (n8n webhook format) const result = Array.isArray(data) && data.length > 0 ? data[0] : data; if (!result.success) { throw new Error('Stance detection failed'); } // Format the response for display const stanceDisplay = result.stance === 'PRO' ? 'Positive (PRO)' : result.stance === 'CON' ? 'Negative (CON)' : result.stance; const confidenceDisplay = result.confidence_percentage || `${(result.confidence * 100).toFixed(1)}%`; // Create formatted content const formattedContent = `**Stance:** ${stanceDisplay}\n**Confidence:** ${confidenceDisplay}\n\n**Explanation:**\n${result.explanation || 'No explanation provided'}`; // Add assistant response with the stance detection results const assistantMessage: Omit = { role: 'assistant', content: formattedContent, tool: selectedTool ?? 'detect_stance', stance: result.stance === 'PRO' ? 'positive' : result.stance === 'CON' ? 'negative' : null, }; addMessage(assistantMessage); } else if (isExtractTopicTool) { console.log('Extract topic tool detected:', selectedTool); // Get user ID const userId = getUserId(); if (!userId) { throw new Error('User ID not found. Please register or log in.'); } // Get extract topic webhook URL (specific or fallback to base URL) const extractTopicWebhookUrl = process.env.REACT_APP_EXTRACT_TOPIC_N8N_WEBHOOK_URL; const baseWebhookUrl = process.env.REACT_APP_N8N_WEBHOOK_URL; const webhookUrl = extractTopicWebhookUrl || baseWebhookUrl; if (!webhookUrl) { throw new Error('REACT_APP_EXTRACT_TOPIC_N8N_WEBHOOK_URL or REACT_APP_N8N_WEBHOOK_URL environment variable is not set'); } // Determine the endpoint (use full URL if provided, otherwise append endpoint) const endpoint = extractTopicWebhookUrl ? '' // Full URL provided, no endpoint needed : '/extract-topic'; // Append endpoint to base URL // Call the extract topic endpoint const response = await fetch(`${webhookUrl}${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ text: content, user_id: userId, }), }); if (!response.ok) { const errorText = await response.text(); console.error('Extract topic API error:', response.status, errorText); throw new Error(`HTTP error! status: ${response.status}: ${errorText}`); } const data = await response.json(); console.log('Extract topic API response:', data); // Extract llm_improved_topic from response let extractedTopic: string; if (data?.llm_improved_topic) { extractedTopic = String(data.llm_improved_topic); // Clean up the topic: remove surrounding quotes and escape characters extractedTopic = extractedTopic .replace(/^["']|["']$/g, '') // Remove surrounding quotes .replace(/\\"/g, '"') // Unescape quotes .replace(/\\n/g, ' ') // Replace newlines with spaces .trim(); } else if (data?.topic) { extractedTopic = String(data.topic); } else if (data?.extracted_topic) { extractedTopic = String(data.extracted_topic); } else { console.warn('Could not find llm_improved_topic in response. Full response:', JSON.stringify(data, null, 2)); extractedTopic = 'Unable to extract topic. Please check the n8n webhook response format.'; } // Ensure we have a valid topic string if (!extractedTopic || extractedTopic.trim() === '') { extractedTopic = 'Unable to extract topic. Please check the n8n webhook response format.'; } // Extract process pipeline information const processPipeline = data?.process ? String(data.process) : undefined; // Add assistant response with the extracted topic and process const assistantMessage: Omit = { role: 'assistant', content: extractedTopic, tool: selectedTool ?? 'extract_topic', process: processPipeline, }; addMessage(assistantMessage); } else if (isGenerateArgumentTool) { console.log('Generate argument tool detected:', selectedTool); // Extract topic and position from content let topic: string; let position: string; try { const extracted = await extractTopicAndPosition(content); topic = extracted.topic; // Use provided stance if available, otherwise use extracted position position = stance || extracted.position; } catch (error) { console.error('Unexpected error in topic extraction:', error); topic = content.slice(0, 100) || 'General Discussion'; position = stance || 'positive'; } // Call the external API const argumentResponse = await generateEnhancedArgument({ topic, position }); // Add assistant response with the enhanced argument const assistantMessage: Omit = { role: 'assistant', content: argumentResponse.enhanced_argument, tool: selectedTool ?? 'generate_argument', stance: stance || null, // Store the selected stance }; addMessage(assistantMessage); } else { // Regular chat - use n8n webhook /debate-assistant endpoint // Extract topic and position from user input const { position } = await extractTopicAndPosition(content); // Get user ID const userId = getUserId(); if (!userId) { throw new Error('User ID not found. Please register or log in.'); } // Get webhook URL const webhookUrl = process.env.REACT_APP_N8N_WEBHOOK_URL; if (!webhookUrl) { throw new Error('REACT_APP_N8N_WEBHOOK_URL environment variable is not set'); } // Call the debate-assistant endpoint const response = await fetch(`${webhookUrl}/debate-assistant`, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ text: content, position: position, user_id: userId, }), }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const data = await response.json(); // Extract final_argument from response const finalArgument = data?.final_argument || data?.result?.result?.[0]?.text || 'No response received'; const assistantTool = data?.tool_name ?? selectedTool ?? 'debate-assistant'; // Extract audio_base64 from response if available const audioBase64 = data?.audio_base64; // Parse if final_argument is a JSON string let finalContent = finalArgument; try { const parsed = JSON.parse(finalArgument); if (parsed.argument) { finalContent = parsed.argument; } } catch { // Not JSON, use as is } // Add assistant response with both text and audio if available const assistantMessage: Omit = { role: 'assistant', content: finalContent, tool: assistantTool, }; // Add audio URL to message if audio_base64 is present if (audioBase64) { // Convert base64 to audio blob and create object URL const binaryString = atob(audioBase64); const bytes = new Uint8Array(binaryString.length); for (let i = 0; i < binaryString.length; i++) { bytes[i] = binaryString.charCodeAt(i); } const audioBlob = new Blob([bytes], { type: 'audio/wav' }); assistantMessage.audioUrl = URL.createObjectURL(audioBlob); } addMessage(assistantMessage); } setState(prev => ({ ...prev, isLoading: false })); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to send message'; setState(prev => ({ ...prev, isLoading: false, error: errorMessage })); } }, [addMessage, extractTopicAndPosition]); const clearMessages = useCallback(() => { setState(initialState); }, []); const retryLastMessage = useCallback(() => { if (state.messages.length >= 2) { const lastUserMessage = [...state.messages] .reverse() .find(msg => msg.role === 'user'); if (lastUserMessage) { // Remove the last failed assistant message if exists setState(prev => { const lastMessage = prev.messages[prev.messages.length - 1]; if (lastMessage?.role === 'assistant') { return { ...prev, messages: prev.messages.slice(0, -1), error: null, }; } return { ...prev, error: null }; }); // Resend the user message sendMessage(lastUserMessage.content); } } }, [state.messages, sendMessage]); return { messages: state.messages, isLoading: state.isLoading, error: state.error, sendMessage, sendAudioMessage, clearMessages, retryLastMessage, }; };