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,
};
};