S01Nour
feat(chat): implement detect stance and extract topic tools with enhanced UI and message formatting
5a8c8b7
| 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<ChatState>(initialState); | |
| const addMessage = useCallback((message: Omit<ChatMessage, 'id' | 'timestamp'>) => { | |
| 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<ChatMessage, 'id' | 'timestamp'> = { | |
| 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<ChatMessage, 'id' | 'timestamp'> = { | |
| 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<ChatMessage, 'id' | 'timestamp'> = { | |
| 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<ChatMessage, 'id' | 'timestamp'> = { | |
| 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<ChatMessage, 'id' | 'timestamp'> = { | |
| 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<ChatMessage, 'id' | 'timestamp'> = { | |
| 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<ChatMessage, 'id' | 'timestamp'> = { | |
| 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, | |
| }; | |
| }; | |