import React, { useState, useRef, useEffect, useCallback } from "react"; import { useParams, useNavigate } from "react-router-dom"; import { cn } from "@/lib/utils"; import { useTheme } from "@/hooks/useTheme"; import { Header } from "@/components/Header"; import { Button } from "@/components/ui/button"; import { Mic, MicOff, Send, StopCircle, PlusCircle, Newspaper } from "lucide-react"; import { ScrollArea } from "@/components/ui/scroll-area"; // Keep if ChatMessages doesn't use it internally import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; // Keep if ChatMessages doesn't handle avatars import { Input } from "@/components/ui/input"; // Keep for the text input area import { shiksakLogo } from '../assets/index.js'; // Assuming path is correct import { BlockMath, InlineMath } from "react-katex"; import "katex/dist/katex.min.css"; // Import child components from Version 2 structure import { ChatHistorySidebar } from "./tutor/ChatHistorySidebar.js"; import { ChatMessages } from "./tutor/ChatMessages.js"; // We'll use this structure // We might not need ChatInput if we handle input directly like V1, or adapt it heavily. Let's handle input here for now. import { ThinkingIndicator } from "./tutor/ThinkingIndicator.js"; // Keep this component import { string } from "zod"; import { useAuth } from "@/contexts/AuthContext"; // --- Speech Recognition Interfaces (from V1) --- interface SpeechRecognitionEvent { results: SpeechRecognitionResultList; } interface SpeechRecognitionResultList { length: number; [index: number]: SpeechRecognitionResult; } interface SpeechRecognitionResult { isFinal: boolean; length: number; [index: number]: SpeechRecognitionAlternative; } interface SpeechRecognitionAlternative { confidence: number; transcript: string; } interface SpeechRecognitionErrorEvent extends Event { error: string; message?: string; } interface SpeechRecognition extends EventTarget { continuous: boolean; interimResults: boolean; lang: string; start(): void; stop(): void; abort(): void; onresult: (event: SpeechRecognitionEvent) => void; onend: () => void; onerror: (event: SpeechRecognitionErrorEvent) => void; // Use specific error event type onnomatch: (event: Event) => void; onstart: () => void; // Add this missing property } declare global { interface Window { SpeechRecognition: new () => SpeechRecognition; webkitSpeechRecognition: new () => SpeechRecognition; } } // --- Data Structures --- const formatTimestamp = (date: Date): string => { if (!date || !(date instanceof Date) || isNaN(date.getTime())) { console.warn("Invalid date passed to formatTimestamp:", date); return new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); // Fallback } return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", // second: "2-digit", // Optionally remove seconds for cleaner look }); }; interface Message { role: 'user' | 'assistant'; content: string; timestamp: Date; isTyping?: boolean; // For the word-by-word effect isInterruption?: boolean; } interface ChatSession { id: string; title: string; lastMessageDate: Date; messages: Message[]; } // Helper to generate a simple title from first user message const generateChatTitle = (messages: Message[], defaultTitle: string): string => { const firstUserMessage = messages.find(m => m.role === 'user'); if (firstUserMessage && firstUserMessage.content) { const title = firstUserMessage.content.split(' ').slice(0, 5).join(' '); return title.length > 30 ? title.substring(0, 27) + '...' : title; } return defaultTitle; }; // --- API Endpoint --- const API_ENDPOINT = "/api/tutor/query"; function AITutorPage() { const { subject } = useParams<{ subject: string }>(); const navigate = useNavigate(); const { isDarkMode } = useTheme(); const { isAuthenticated } = useAuth(); // --- State --- const [chatSessions, setChatSessions] = useState([]); const [currentSessionId, setCurrentSessionId] = useState(null); const [conversation, setConversation] = useState([]); // Messages for the *current* session const [userInput, setUserInput] = useState(""); // Text input state const [isThinking, setIsThinking] = useState(false); // AI thinking state const [isListening, setIsListening] = useState(false); // Mic active state const [recognizedSpeech, setRecognizedSpeech] = useState(""); // Live speech recognition result const [isDisplayingWords, setIsDisplayingWords] = useState(false); // TTS word-by-word effect active const [autoScroll, setAutoScroll] = useState(true); // Auto-scroll state // --- Refs --- const synthesisRef = useRef(null); const recognitionRef = useRef(null); const speechVoiceRef = useRef(null); const endOfMessagesRef = useRef(null); const transcriptRef = useRef(''); // Stores the final transcript from speech recognition const wordTimerRef = useRef(null); // Timer for word-by-word display const noSpeechTimerRef = useRef(null); // Timer for speech inactivity const lastSpeechTimeRef = useRef(Date.now()); // Timestamp of last detected speech const currentResponseRef = useRef<{ // Stores state for ongoing TTS/word display fullText: string; currentIndex: number; words: string[]; }>({ fullText: '', currentIndex: 0, words: [] }); const scrollAreaRef = useRef(null); // Ref for the scroll area const interruptionSpeechRef = useRef(null); const [relatedTopics, setRelatedTopics] = useState([]); const formattedSubject = subject?.replace(/_/g, " ") || "general topics"; const app: { aiResponseContext: Record; } = { aiResponseContext: {} }; // --- Effects --- // Effect #1: Initialize component, load data from localStorage, set up default session if needed. useEffect(() => { console.log("Effect: Initializing component and loading data."); // Function to load session data from the database const loadUserSessions = async () => { try { // Make API call to fetch sessions from the database const response = await fetch(`/api/history/get_sessions?user_id=${1}`); if (!response.ok) { throw new Error(`Failed to load sessions: ${response.status} ${response.statusText}`); } const data = await response.json(); let loadedSessions = data.sessions || []; // Ensure dates are correctly parsed loadedSessions = loadedSessions.map(session => ({ ...session, lastMessageDate: new Date(session.lastMessageDate), messages: session.messages.map(msg => ({ ...msg, timestamp: new Date(msg.timestamp) })) })).sort((a, b) => new Date(b.lastMessageDate).getTime() - new Date(a.lastMessageDate).getTime() ); console.log(`Loaded ${loadedSessions.length} sessions from database`); let activeSessionId = null; // Try to get active session ID from API response const activeSessionResponse = await fetch(`/api/history/get_active_session?user_id=${1}`); if (activeSessionResponse.ok) { const activeData = await activeSessionResponse.json(); activeSessionId = activeData.active_session_id; } const foundSession = loadedSessions.find(s => s.id === activeSessionId); if (foundSession) { // Use the active session setConversation(foundSession.messages); console.log("Restored active session:", activeSessionId); } else if (loadedSessions.length > 0) { // Fallback to the most recent session activeSessionId = loadedSessions[0].id; setConversation(loadedSessions[0].messages); console.log("Fell back to most recent session:", activeSessionId); } else { // Create a default session if none exist console.log("No sessions found, creating default session."); const newId = `session_${Date.now()}`; const initialMessage: Message = { role: 'assistant', content: `Hi there! I'm your ${formattedSubject} AI tutor. How can I help you today? Feel free to ask any question about ${formattedSubject}.`, timestamp: new Date() }; const defaultSession: ChatSession = { id: newId, title: `New ${formattedSubject} Chat`, lastMessageDate: new Date(), messages: [initialMessage] }; loadedSessions = [defaultSession]; activeSessionId = newId; setConversation([initialMessage]); // Save the new session to the database try { await fetch('/api/history/create_session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: newId, user_id: 1, title: `New ${formattedSubject} Chat`, subject: formattedSubject || 'general', message: { role: 'assistant', content: initialMessage.content, timestamp: initialMessage.timestamp.toISOString() } }), }); // Set as active session await fetch('/api/history/set_active_session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: 1, session_id: newId }), }); } catch (error) { console.error("Failed to create default session in database:", error); } } setChatSessions(loadedSessions); setCurrentSessionId(activeSessionId); // Update the active session in the database if (activeSessionId) { try { await fetch('/api/history/set_active_session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: 1, session_id: activeSessionId }), }); } catch (error) { console.error("Failed to update active session in database:", error); } } } catch (error) { console.error("Error loading sessions from database:", error); // Fallback to create a default session if database load fails const newId = `session_${Date.now()}`; const initialMessage: Message = { role: 'assistant', content: `Hi there! I'm your ${formattedSubject} AI tutor. How can I help you today? Feel free to ask any question about ${formattedSubject}.`, timestamp: new Date() }; const defaultSession: ChatSession = { id: newId, title: `New ${formattedSubject} Chat`, lastMessageDate: new Date(), messages: [initialMessage] }; setChatSessions([defaultSession]); setCurrentSessionId(newId); setConversation([initialMessage]); } }; // Load user sessions when component mounts loadUserSessions(); }, [1, formattedSubject]); // Rerun if userId or subject changes // Effect #2: Initialize Speech Synthesis API. useEffect(() => { console.log("Effect: Initializing Speech Synthesis."); if (typeof window !== 'undefined' && 'speechSynthesis' in window) { synthesisRef.current = window.speechSynthesis; // Pre-warm the synthesis engine (optional, can help with first utterance delay) const testUtterance = new SpeechSynthesisUtterance(''); testUtterance.volume = 0; // Speak silently try { synthesisRef.current.speak(testUtterance); synthesisRef.current.cancel(); // Cancel immediately } catch (e) { console.warn("Speech synthesis pre-warm attempt might have been interrupted or failed:", e) } } else { console.error("Speech synthesis not supported in this browser."); } // Cleanup function to cancel any ongoing speech when component unmounts return () => { if (synthesisRef.current) { synthesisRef.current.cancel(); } // Clear word display timer on unmount if (wordTimerRef.current) { clearInterval(wordTimerRef.current); } }; }, []); // Runs only once on mount useEffect(() => { const loadVoices = () => { const availableVoices = window.speechSynthesis.getVoices(); const preferredVoice = availableVoices.find(voice => voice.name.includes("Google") || voice.name.includes("Microsoft") || voice.name.includes("Natural") ); if (preferredVoice) { console.log("✅ Using voice:", preferredVoice.name); speechVoiceRef.current = preferredVoice; } else if (availableVoices.length > 0) { console.warn("⚠️ No natural voice found. Using first available voice:", availableVoices[0].name); speechVoiceRef.current = availableVoices[0]; } else { console.error("❌ No voices available."); } }; // Chrome might not have voices immediately, so listen for the event if (typeof window !== 'undefined' && window.speechSynthesis) { window.speechSynthesis.onvoiceschanged = loadVoices; loadVoices(); // Initial attempt } }, []); // Function to interrupt AI speech const interruptSpeaking = () => { console.log("Interrupting AI speech - IMMEDIATE ACTION"); // Cancel speech synthesis immediately if (synthesisRef.current) { synthesisRef.current.cancel(); } // Force stop word display immediately if (wordTimerRef.current) { clearInterval(wordTimerRef.current); wordTimerRef.current = null; } // Force immediate display finalization setIsDisplayingWords(false); // Finalize message display but keep partial text finalizeMessageDisplay(false); // Keep listening active if (!isListening) { startListening(); } }; // Updated speech recognition initialization with better error handling useEffect(() => { console.log("Effect: Initializing Speech Recognition."); if (typeof window !== 'undefined') { const SpeechRecognitionAPI = window.SpeechRecognition || window.webkitSpeechRecognition; if (SpeechRecognitionAPI) { try { recognitionRef.current = new SpeechRecognitionAPI(); recognitionRef.current.continuous = true; recognitionRef.current.interimResults = true; recognitionRef.current.lang = 'en-US'; // Enhanced onresult handler recognitionRef.current.onresult = (event: any) => { let interimTranscript = ''; let finalTranscript = ''; for (let i = event.resultIndex; i < event.results.length; ++i) { if (event.results[i].isFinal) { finalTranscript += event.results[i][0].transcript; } else { interimTranscript += event.results[i][0].transcript; } } // Update recognized speech immediately for display setRecognizedSpeech(transcriptRef.current + interimTranscript); // Detect interruption early (even on interim results) if (isDisplayingWords && interimTranscript.trim().length > 0) { console.log("User started speaking. Interrupting AI immediately."); interruptSpeaking(); const interruption = interimTranscript.trim(); transcriptRef.current = interruption; setRecognizedSpeech(interruption); const immediateUserMessage: Message = { role: 'user', content: interruption, timestamp: new Date(), isInterruption: true }; setConversation(prev => [...prev, immediateUserMessage]); interruptSpeaking(); interruptionSpeechRef.current = null; handleUserInterruption(interruption); setIsListening(false); stopListening(); } if (finalTranscript) { transcriptRef.current += finalTranscript + ' '; setRecognizedSpeech(transcriptRef.current); } lastSpeechTimeRef.current = Date.now(); resetNoSpeechTimer(); }; // Enhanced error handler recognitionRef.current.onerror = (event: SpeechRecognitionErrorEvent) => { console.error("Speech recognition error:", event.error, event.message); // Reset state on any error to prevent stuck states setIsListening(false); clearNoSpeechTimer(); // Handle specific errors if (event.error === 'aborted') { console.log("Recognition was aborted - this is normal during cleanup"); return; } if (event.error === 'not-allowed') { console.error("Microphone permission denied"); alert("Microphone permission is required for voice input. Please allow microphone access and try again."); return; } if (event.error === 'network') { console.error("Network error during speech recognition"); return; } // For other errors, just log and continue console.warn(`Speech recognition error: ${event.error} - Recognition stopped`); }; // Enhanced onend handler recognitionRef.current.onend = () => { console.log("Speech recognition ended."); // Always ensure state is consistent when recognition ends if (isListening) { console.log("Recognition ended but isListening was true - syncing state"); setIsListening(false); } clearNoSpeechTimer(); }; // Add onstart handler for better state tracking recognitionRef.current.onstart = () => { console.log("Speech recognition started successfully"); // Ensure state is consistent if (!isListening) { console.log("Recognition started but isListening was false - syncing state"); setIsListening(true); } }; } catch (error) { console.error("Error initializing speech recognition instance:", error); recognitionRef.current = null; } } else { console.error("Speech recognition not supported in this browser."); } } // Enhanced cleanup function return () => { if (recognitionRef.current) { try { recognitionRef.current.abort(); // Force stop setIsListening(false); // Ensure state is reset } catch (e) { console.error("Error stopping recognition on unmount:", e); } } clearNoSpeechTimer(); }; }, []); // Effect #4: Handle automatic scrolling to the bottom of the messages. useEffect(() => { // console.log("Effect: Checking scroll state - autoScroll:", autoScroll, "isDisplayingWords:", isDisplayingWords); if (autoScroll && !isDisplayingWords && endOfMessagesRef.current) { // console.log("Scrolling into view"); // Use requestAnimationFrame to ensure scrolling happens after DOM update requestAnimationFrame(() => { endOfMessagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); }); } }, [conversation.length, autoScroll, isDisplayingWords, recognizedSpeech]); // Trigger on new messages, scroll state change, or recognized speech update const handleUserSpeech = async (speech) => { if (!speech || !speech.trim()) { console.warn("handleUserSpeech called with empty input."); return; } if (isThinking) { console.warn("Already processing a request, ignoring new speech input."); return; } console.log("Processing user input (speech/text):", speech); // Pre-processing Steps setIsListening(false); clearNoSpeechTimer(); setRecognizedSpeech(""); transcriptRef.current = ""; // Interrupt any ongoing AI speech/display if (synthesisRef.current) { synthesisRef.current.cancel(); } if (isDisplayingWords) { finalizeMessageDisplay(false); } // Prepare User Message const userMessage: Message = { role: 'user', content: speech, timestamp: new Date() }; // Update UI Immediately setConversation(prev => [...prev, userMessage]); setUserInput(""); // Add this helper function to detect test generation requests const isTestGenerationRequest = (text) => { const testKeywords = [ "create a test", "generate a test", "make a test", "sample paper", "question paper", "practice test", "mock exam", "mock test", "create an exam", "practice questions", "quiz me", "generate questions" ]; const lowercaseText = text.toLowerCase(); return testKeywords.some(keyword => lowercaseText.includes(keyword)); }; // Check if this is a test generation request const isTestRequest = isTestGenerationRequest(speech); // Update State & Database let assistantMessage = null; const currentSession = chatSessions.find(s => s.id === currentSessionId); let newTitle = currentSession?.title ?? `New ${formattedSubject} Chat`; if (currentSession && currentSession.messages.filter(m => m.role === 'user').length === 0) { newTitle = generateChatTitle([userMessage], `New ${formattedSubject} Chat`); } // First update local state for immediate feedback setChatSessions(prevSessions => { const sessionIndex = prevSessions.findIndex(s => s.id === currentSessionId); if (sessionIndex === -1) { console.error("Current session ID not found in chatSessions!"); return prevSessions; } const updatedSessions = [...prevSessions]; const targetSession = { ...updatedSessions[sessionIndex] }; targetSession.messages = [...targetSession.messages, userMessage]; targetSession.lastMessageDate = new Date(); targetSession.title = newTitle; updatedSessions[sessionIndex] = targetSession; return updatedSessions; }); // Save user message to database try { await fetch('/api/history/add_message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: currentSessionId, user_id: 1, // Ensure userId is available in your component state message: { role: 'user', content: speech, timestamp: new Date().toISOString() }, session_title: newTitle, subject: subject || 'general' }), }); } catch (error) { console.error("Failed to save user message to database:", error); } // API Call for response setIsThinking(true); try { // Use different endpoint based on request type const endpoint = isTestRequest ? "/api/generate" : API_ENDPOINT; console.log(`Sending fetch to ${endpoint} for session ${currentSessionId}`); const payload = isTestRequest ? { subject: subject || 'general' } : { query: speech, subject: subject || 'general', session_id: currentSessionId, title: newTitle }; const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) { const errorText = await response.text(); console.error("API Error Response:", errorText); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); console.log("API Success Response:", data); const formatResponse = (text: string): JSX.Element[] | null => { if (!text) return null; // Function to handle bold text formatting inside a string const formatBoldText = (str: string): (string | JSX.Element)[] => { if (!str.includes('**')) return [str]; const parts: (string | JSX.Element)[] = []; let lastIndex = 0; let count = 0; let startIndex = -1; for (let i = 0; i < str.length - 1; i++) { if (str[i] === '*' && str[i + 1] === '*') { if (startIndex === -1) { // Found opening ** if (i > lastIndex) { parts.push(str.substring(lastIndex, i)); } startIndex = i; i++; // Skip the second * } else { // Found closing ** parts.push( {str.substring(startIndex + 2, i)} ); lastIndex = i + 2; startIndex = -1; i++; // Skip the second * } } } if (lastIndex < str.length) { parts.push(str.substring(lastIndex)); } return parts; }; const formattedElements: JSX.Element[] = []; let currentOptions: JSX.Element[] = []; let questionCount = 0; let insideSection = false; text.split("\n").forEach((line, index) => { if (line.startsWith("###")) { questionCount = 0; insideSection = true; const headingText = line.replace("###", "").trim(); const boldFormatted = formatBoldText(headingText); formattedElements.push(

{boldFormatted.map((part, i) => {part})}

); } else if (line.match(/^\d+\./)) { if (insideSection) { questionCount++; const questionText = line.replace(/^\d+\.\s*/, "").trim(); const containsMath = questionText.includes("$") || questionText.includes("\\("); if (containsMath) { formattedElements.push(

{`${questionCount}. `} {questionText}

); } else { const boldFormatted = formatBoldText(questionText); formattedElements.push(

{`${questionCount}. `} {boldFormatted.map((part, i) => {part})}

); } } else { const boldFormatted = formatBoldText(line); formattedElements.push(

{boldFormatted.map((part, i) => {part})}

); } } else if (line.match(/^[a-d]\)/i)) { const optionText = line.replace(/^[a-d]\)\s*/, "").trim(); const containsMath = optionText.includes("$") || optionText.includes("\\("); if (containsMath) { currentOptions.push( {optionText} ); } else { const boldFormatted = formatBoldText(optionText); currentOptions.push( {boldFormatted.map((part, i) => {part})} ); } } else { const containsMath = line.includes("$") || line.includes("\\("); if (containsMath) { formattedElements.push(

{line}

); } else { const boldFormatted = formatBoldText(line); formattedElements.push(

{boldFormatted.map((part, i) => {part})}

); } } }); if (currentOptions.length > 0) { formattedElements.push(
{currentOptions}
); } return formattedElements; }; // Process successful response based on which endpoint was used let answerText; if (isTestRequest) { setRelatedTopics([]); answerText = data.mock_test || "Sorry, I couldn't generate a test right now."; answerText = formatResponse(answerText); } else { answerText = data.answer || "Sorry, I couldn't generate a response right now."; setRelatedTopics(data.related_topics || []); } assistantMessage = { role: 'assistant', content: answerText, timestamp: new Date(), isTestPaper: isTestRequest // Flag to identify test papers }; // Update State with AI Response setChatSessions(prevSessions => { const sessionIndex = prevSessions.findIndex(s => s.id === currentSessionId); if (sessionIndex === -1) return prevSessions; const updatedSessions = [...prevSessions]; const targetSession = { ...updatedSessions[sessionIndex] }; targetSession.messages = [...targetSession.messages, assistantMessage]; targetSession.lastMessageDate = new Date(); updatedSessions[sessionIndex] = targetSession; return updatedSessions; }); // Save assistant message to database try { await fetch('/api/history/add_message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: currentSessionId, user_id: 1, message: { role: 'assistant', content: answerText, timestamp: new Date().toISOString(), isTestPaper: isTestRequest }, subject: subject || 'general' }), }); } catch (error) { console.error("Failed to save assistant message to database:", error); } // Handle different display methods based on request type if (isTestRequest) { // For test papers, just update the conversation without speech setConversation(prev => [...prev, assistantMessage]); } else { // For normal responses, display with speech displayTextWithSpeech(answerText); } } catch (error) { console.error("Error during API call or processing:", error); const errorContent = `Sorry, an error occurred: ${error.message}. Please try again.`; assistantMessage = { role: 'assistant', content: errorContent, timestamp: new Date() }; setChatSessions(prevSessions => { const sessionIndex = prevSessions.findIndex(s => s.id === currentSessionId); if (sessionIndex === -1) return prevSessions; const updatedSessions = [...prevSessions]; const targetSession = { ...updatedSessions[sessionIndex] }; targetSession.messages = [...targetSession.messages, assistantMessage]; targetSession.lastMessageDate = new Date(); updatedSessions[sessionIndex] = targetSession; return updatedSessions; }); // Save error message to database try { await fetch('/api/history/add_message', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: currentSessionId, user_id: 1, message: { role: 'assistant', content: errorContent, timestamp: new Date().toISOString() }, subject: subject || 'general' }), }); } catch (dbError) { console.error("Failed to save error message to database:", dbError); } setConversation(prev => [...prev, assistantMessage]); } finally { setIsThinking(false); } }; const resetNoSpeechTimer = useCallback(() => { clearNoSpeechTimer(); noSpeechTimerRef.current = setTimeout(() => { const currentTime = Date.now(); const timeSinceLastSpeech = currentTime - lastSpeechTimeRef.current; const finalSpeech = transcriptRef.current.trim(); const interruptedSpeech = interruptionSpeechRef.current; if (timeSinceLastSpeech >= 2000) { stopListening(); if (interruptedSpeech) { console.log("10s silence after interruption. Submitting:", interruptedSpeech); interruptionSpeechRef.current = null; // Clear pending handleUserInterruption(interruptedSpeech); // Send to API now } else if (finalSpeech.length > 0) { console.log("10s silence after speech. Submitting:", finalSpeech); handleUserSpeech(finalSpeech); } else { console.log("10s passed with no meaningful speech. Just stopping."); } } }, 2000); }, []); const clearNoSpeechTimer = () => { if (noSpeechTimerRef.current) { // console.log("Clearing no-speech timer."); clearTimeout(noSpeechTimerRef.current); noSpeechTimerRef.current = null; } }; // Function to handle user interruption during AI response const handleUserInterruption = async (speech) => { if (!speech || !speech.trim()) return; console.log("FAST PROCESSING user interruption:", speech); // Preserve interrupted message context const currentMessages = [...conversation]; const lastAssistantMessage = currentMessages.find( (m) => m.role === 'assistant' && m.isTyping === true ); // Store interruption context if (lastAssistantMessage && currentSessionId) { app.aiResponseContext[currentSessionId] = { lastPartialResponse: lastAssistantMessage.content, fullIntendedResponse: currentResponseRef.current.fullText, interruptedAt: new Date(), }; } // Clear transcript transcriptRef.current = ''; setRecognizedSpeech(''); // Note: We've already added the user's message in the onresult handler // This function now just needs to handle the API call // Update State & Persistence let assistantMessage = null; // API Call setIsThinking(true); try { console.log(`Sending fetch to ${API_ENDPOINT} for session ${currentSessionId} (interruption)`); const payload = { query: speech, subject: subject || 'general', session_id: currentSessionId, is_interruption: true, interrupted_context: currentSessionId && app.aiResponseContext[currentSessionId] ? { last_partial_response: app.aiResponseContext[currentSessionId].lastPartialResponse, full_intended_response: app.aiResponseContext[currentSessionId].fullIntendedResponse } : null }; const response = await fetch(API_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!response.ok) { const errorText = await response.text(); console.error("API Error Response:", errorText); throw new Error(`API request failed: ${response.status} ${response.statusText} - ${errorText}`); } const data = await response.json(); console.log("API Success Response for interruption:", data); const answerText = data.answer || "Sorry, I couldn't generate a response to your interruption."; // IMPORTANT: Handle related topics from the API response setRelatedTopics(data.related_topics || []); assistantMessage = { role: 'assistant', content: answerText, timestamp: new Date() }; // Update State & Persistence with AI Response setChatSessions(prevSessions => { const sessionIndex = prevSessions.findIndex(s => s.id === currentSessionId); if (sessionIndex === -1) return prevSessions; const updatedSessions = [...prevSessions]; const targetSession = { ...updatedSessions[sessionIndex] }; targetSession.messages = [...targetSession.messages, assistantMessage]; targetSession.lastMessageDate = new Date(); updatedSessions[sessionIndex] = targetSession; localStorage.setItem('ai_tutor_chat_sessions', JSON.stringify(updatedSessions)); return updatedSessions; }); // Display with speech displayTextWithSpeech(answerText); } catch (error) { console.error("Error during interruption API call or processing:", error); const errorContent = `Sorry, an error occurred: ${error.message}. Please try again.`; assistantMessage = { role: 'assistant', content: errorContent, timestamp: new Date() }; setChatSessions(prevSessions => { const sessionIndex = prevSessions.findIndex(s => s.id === currentSessionId); if (sessionIndex === -1) return prevSessions; const updatedSessions = [...prevSessions]; const targetSession = { ...updatedSessions[sessionIndex] }; targetSession.messages = [...targetSession.messages, assistantMessage]; targetSession.lastMessageDate = new Date(); updatedSessions[sessionIndex] = targetSession; localStorage.setItem('ai_tutor_chat_sessions', JSON.stringify(updatedSessions)); return updatedSessions; }); setConversation(prev => [...prev, assistantMessage]); } finally { setIsThinking(false); } }; const RelatedTopics = ({ topics, onTopicClick }) => { if (!topics || topics.length === 0) return null; return (

Related Topics:

{topics.map((topic, index) => ( ))}
); }; const handleRelatedTopicClick = (topic) => { console.log(`Related topic clicked: ${topic}`); // Treat the topic as a user message handleUserSpeech(topic); }; // Updated startListening function with better state management const startListening = () => { if (!recognitionRef.current) { console.error("Cannot start listening: Speech recognition not initialized."); return; } // Check if already listening - this prevents the error if (isListening) { console.warn("Already listening - ignoring start request."); return; } // Interrupt any ongoing AI speech/display if (synthesisRef.current) { synthesisRef.current.cancel(); } if (isDisplayingWords) { finalizeMessageDisplay(false); // Finalize display, keeping partial text } // Clear any existing recognition state try { recognitionRef.current.abort(); // Force stop any existing recognition } catch (e) { // Ignore errors from aborting non-running recognition } // Small delay to ensure previous recognition is fully stopped setTimeout(() => { setIsListening(true); setRecognizedSpeech(""); // Clear visual feedback transcriptRef.current = ""; // Clear stored final transcript lastSpeechTimeRef.current = Date.now(); // Reset last speech time try { // Start recognition recognitionRef.current.start(); console.log("Speech recognition started via startListening()."); // Start the inactivity timer resetNoSpeechTimer(); } catch (error) { console.error("Error starting speech recognition:", error); setIsListening(false); // Revert state if start fails clearNoSpeechTimer(); } }, 100); // Small delay to ensure cleanup }; // Updated stopListening function with better cleanup const stopListening = () => { if (!recognitionRef.current) { console.error("Cannot stop listening: Speech recognition not initialized."); return; } // Always clear the timer first clearNoSpeechTimer(); // If not listening according to our state, just ensure cleanup if (!isListening) { console.warn("stopListening called but not currently listening according to state."); // Still try to stop in case states are out of sync try { recognitionRef.current.abort(); } catch (error) { console.error("Error calling abort during state mismatch:", error); } return; } console.log("Attempting to stop speech recognition via stopListening()."); try { recognitionRef.current.stop(); // Request graceful stop console.log("Speech recognition stop() called."); } catch (error) { console.error("Error calling recognitionRef.current.stop():", error); // Try abort as fallback try { recognitionRef.current.abort(); } catch (abortError) { console.error("Error calling abort as fallback:", abortError); } } finally { // *Always* set listening to false when explicitly stopping setIsListening(false); // Process any final recognized speech that might not have triggered the timer const finalSpeech = transcriptRef.current.trim(); if (finalSpeech.length > 0) { console.log("Processing speech captured before explicit stop:", finalSpeech); handleUserSpeech(finalSpeech); // Send the final transcript } else { setRecognizedSpeech(""); // Clear any lingering interim results visually } } }; // / Fix 1: Update the finalizeMessageDisplay function const finalizeMessageDisplay = (showFullText: boolean) => { if (!isDisplayingWords) return; // Already finalized or wasn't displaying console.log(`Finalizing message display. Show full text: ${showFullText}`); // Clear the word-by-word timer if (wordTimerRef.current) { clearInterval(wordTimerRef.current); wordTimerRef.current = null; } setIsDisplayingWords(false); // Mark as no longer displaying // Update the conversation state setConversation(prev => { const updatedConversation = [...prev]; const lastIndex = updatedConversation.length - 1; if (lastIndex >= 0 && updatedConversation[lastIndex].role === 'assistant' && updatedConversation[lastIndex].isTyping) { // Update the last message updatedConversation[lastIndex] = { ...updatedConversation[lastIndex], content: showFullText ? currentResponseRef.current.fullText : updatedConversation[lastIndex].content, isTyping: false // Mark as not typing anymore }; } return updatedConversation; }); // Update the session in chatSessions as well if (currentSessionId) { setChatSessions(prevSessions => { const sessionIndex = prevSessions.findIndex(s => s.id === currentSessionId); if (sessionIndex === -1) return prevSessions; // Should not happen const updatedSessions = [...prevSessions]; const targetSession = { ...updatedSessions[sessionIndex] }; const messageIndex = targetSession.messages.length - 1; if (messageIndex >= 0 && targetSession.messages[messageIndex].role === 'assistant' && targetSession.messages[messageIndex].isTyping) { targetSession.messages[messageIndex] = { ...targetSession.messages[messageIndex], content: showFullText ? currentResponseRef.current.fullText : targetSession.messages[messageIndex].content, isTyping: false, }; updatedSessions[sessionIndex] = targetSession; } return updatedSessions; }); } // Ensure scroll after final display if needed if (autoScroll && endOfMessagesRef.current) { requestAnimationFrame(() => { endOfMessagesRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' }); }); } }; // Fix 2: Update the displayTextWithSpeech function's utterance.onend handler // Fix 2: Update the displayTextWithSpeech function's utterance.onend handler const displayTextWithSpeech = (text: string) => { if (!text) return; console.log("Starting displayTextWithSpeech for:", text.substring(0, 50) + "..."); // --- Safety Checks and Cleanup --- // Clear any existing word timer if (wordTimerRef.current) { clearInterval(wordTimerRef.current); wordTimerRef.current = null; } // Cancel any ongoing speech if (synthesisRef.current) { synthesisRef.current.cancel(); } // If we were somehow already displaying words, finalize the previous one fully. if (isDisplayingWords) { finalizeMessageDisplay(true); } // --- Prepare for new message --- currentResponseRef.current = { fullText: text, currentIndex: 0, // Start from the beginning words: text.split(/(\s+)/).filter(w => w.trim().length > 0) // Split by space, keep spaces if needed, filter empty }; // Add a placeholder message to the conversation for the typing effect const typingMessage: Message = { role: 'assistant', content: '', // Start empty timestamp: new Date(), isTyping: true }; setConversation(prev => [...prev, typingMessage]); setIsDisplayingWords(true); // Set flag // --- Configure Speech --- if (!synthesisRef.current) { console.error("Speech synthesis not available. Displaying text only."); // Fallback: Just display the text word-by-word without speech let wordIndex = 0; wordTimerRef.current = setInterval(() => { if (wordIndex >= currentResponseRef.current.words.length) { finalizeMessageDisplay(true); // All words shown return; } const currentWords = currentResponseRef.current.words.slice(0, wordIndex + 1).join(' '); setConversation(prev => { const updated = [...prev]; const lastIndex = updated.length - 1; if (lastIndex >= 0 && updated[lastIndex].isTyping) { updated[lastIndex] = { ...updated[lastIndex], content: currentWords }; } return updated; }); wordIndex++; }, 150); // Adjust speed as needed return; // Exit function early } const utterance = new SpeechSynthesisUtterance(text); utterance.voice = speechVoiceRef.current; utterance.rate = 1.0; // Adjust rate as needed utterance.pitch = 1.0; // Adjust pitch as needed let spokenWordIndex = 0; let currentWords = ''; // --- Speech Event Handlers --- utterance.onboundary = (event) => { if (event.name === 'word') { // Find the word that's being spoken based on character index // This synchronization can be tricky. A simpler approach is often better: // Update display based on word *index* rather than charIndex. if (spokenWordIndex < currentResponseRef.current.words.length) { currentWords = currentResponseRef.current.words.slice(0, spokenWordIndex + 1).join(' '); // Update the conversation's last message content setConversation(prev => { const updated = [...prev]; const lastIndex = updated.length - 1; if (lastIndex >= 0 && updated[lastIndex].isTyping) { updated[lastIndex] = { ...updated[lastIndex], content: currentWords }; } return updated; }); spokenWordIndex++; } } }; // FIX: Updated onend handler to properly reset state utterance.onend = () => { console.log("Speech synthesis ended - resetting states"); finalizeMessageDisplay(true); // Ensure full text is displayed // Reset all relevant states to allow mic button to work again setIsDisplayingWords(false); // Clear any timers if (wordTimerRef.current) { clearInterval(wordTimerRef.current); wordTimerRef.current = null; } console.log("Speech ended - mic button should be clickable now"); }; utterance.onerror = (event) => { console.error("Speech synthesis error:", event); finalizeMessageDisplay(true); // Show full text on error // Reset states on error as well setIsDisplayingWords(false); if (wordTimerRef.current) { clearInterval(wordTimerRef.current); wordTimerRef.current = null; } }; // --- Start Speaking --- try { synthesisRef.current.speak(utterance); } catch (error) { console.error("Error calling speechSynthesis.speak:", error); finalizeMessageDisplay(true); // Show full text if speak fails setIsDisplayingWords(false); // Reset state on error } }; const createNewSession = async () => { const newId = `session_${Date.now()}`; const initialMessage: Message = { role: 'assistant', content: `Hi there! I'm your ${formattedSubject} AI tutor. How can I help you today? Feel free to ask any question about ${formattedSubject}.`, timestamp: new Date() }; const newSession: ChatSession = { id: newId, title: `New ${formattedSubject} Chat`, lastMessageDate: new Date(), messages: [initialMessage] }; // Update local state setChatSessions(prev => [newSession, ...prev]); setCurrentSessionId(newId); setConversation([initialMessage]); // Create in database try { await fetch('/api/history/create_session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: newId, user_id: 1, title: `New ${formattedSubject} Chat`, subject: formattedSubject || 'general', message: { role: 'assistant', content: initialMessage.content, timestamp: initialMessage.timestamp.toISOString() } }), }); // Set as active session await fetch('/api/history/set_active_session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: 1, session_id: newId }), }); } catch (error) { console.error("Failed to create new session in database:", error); } }; const switchSession = async (sessionId) => { const session = chatSessions.find(s => s.id === sessionId); if (!session) { console.error(`Session with ID ${sessionId} not found`); return; } setCurrentSessionId(sessionId); setConversation(session.messages); // Update active session in database try { await fetch('/api/history/set_active_session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id: 1, session_id: sessionId }), }); } catch (error) { console.error("Failed to update active session in database:", error); } }; // Handle text input submission const handleTextSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!userInput.trim() || isThinking || isListening) return; handleUserSpeech(userInput.trim()); }; // Handle scroll events in the message area to toggle auto-scroll const handleScroll = (event: React.UIEvent) => { const { scrollTop, scrollHeight, clientHeight } = event.currentTarget; const isNearBottom = scrollHeight - scrollTop - clientHeight < 50; // Threshold for being "at the bottom" if (isNearBottom) { if (!autoScroll) { // console.log("Scroll: Reached bottom, enabling autoScroll"); setAutoScroll(true); } } else { if (autoScroll) { // console.log("Scroll: Scrolled up, disabling autoScroll"); setAutoScroll(false); } } }; // --- Chat Session Management Functions --- const createNewChat = useCallback(() => { console.log("Creating new chat session."); // Interrupt any ongoing actions if (synthesisRef.current) synthesisRef.current.cancel(); if (recognitionRef.current && isListening) stopListening(); finalizeMessageDisplay(true); // Finalize any ongoing display fully setIsThinking(false); // Ensure thinking state is off const newId = `session_${Date.now()}`; const initialMessage: Message = { role: 'assistant', content: `Starting a new chat on ${formattedSubject}. How can I help?`, timestamp: new Date() }; const newSession: ChatSession = { id: newId, title: `New ${formattedSubject} Chat`, // Placeholder title lastMessageDate: new Date(), messages: [initialMessage] }; // Update state setChatSessions(prev => [newSession, ...prev]); // Add to the beginning setCurrentSessionId(newId); setConversation([initialMessage]); // Update localStorage localStorage.setItem('ai_tutor_chat_sessions', JSON.stringify([newSession, ...chatSessions])); localStorage.setItem('ai_tutor_current_session_id', newId); }, [formattedSubject, chatSessions, isListening]); // Include dependencies const switchChat = (sessionId: string) => { if (sessionId === currentSessionId) return; // No change needed console.log(`Switching chat to session: ${sessionId}`); // Interrupt any ongoing actions if (synthesisRef.current) synthesisRef.current.cancel(); if (recognitionRef.current && isListening) stopListening(); finalizeMessageDisplay(true); // Finalize any ongoing display fully setIsThinking(false); // Ensure thinking state is off const sessionToLoad = chatSessions.find(s => s.id === sessionId); if (sessionToLoad) { setCurrentSessionId(sessionId); setConversation(sessionToLoad.messages); localStorage.setItem('ai_tutor_current_session_id', sessionId); // Reset scroll state when switching chats setAutoScroll(true); // Scroll to bottom after a short delay to allow rendering setTimeout(() => { endOfMessagesRef.current?.scrollIntoView({ behavior: "auto", block: "end" }); // Use auto for instant jump }, 50); } else { console.error(`Session ID ${sessionId} not found during switch.`); } }; const deleteChat = (sessionIdToDelete: string, e: React.MouseEvent) => { e.stopPropagation(); // Prevent triggering switch chat if delete is on the item console.log(`Deleting chat session: ${sessionIdToDelete}`); // Interrupt ongoing actions if deleting the *current* chat if (sessionIdToDelete === currentSessionId) { if (synthesisRef.current) synthesisRef.current.cancel(); if (recognitionRef.current && isListening) stopListening(); finalizeMessageDisplay(true); setIsThinking(false); } const updatedSessions = chatSessions.filter(s => s.id !== sessionIdToDelete); setChatSessions(updatedSessions); localStorage.setItem('ai_tutor_chat_sessions', JSON.stringify(updatedSessions)); // If the deleted session was the active one, switch to another or create new if (sessionIdToDelete === currentSessionId) { if (updatedSessions.length > 0) { // Switch to the new first session (most recent) switchChat(updatedSessions[0].id); } else { // No sessions left, create a new default one createNewChat(); } } // If a different session was deleted, the current one remains active, no switch needed. }; // --- Render --- return (
{/* pt-16 for Header height */} {isAuthenticated && ( )} {/* Main chat area */}
{/* Message display area */} {/* Use ChatMessages component */} {conversation.map((msg, index) => (
{/* Message rendering */}
{typeof msg.content === 'string' ? msg.content : msg.content}
{/* Related Topics (shown only after the latest assistant message) */} {msg.role === 'assistant' && index === conversation.length - 1 && relatedTopics.length > 0 && (

Related Topics:

{relatedTopics.map((topic, i) => ( ))}
)}
))} {/* Div to scroll to */}
{/* Thinking Indicator (conditionally rendered) */} {isThinking && } {/* Combined Input and Controls Area */}
{/* Text Input Form */}
setUserInput(e.target.value)} className={cn( "flex-1 p-3 rounded-lg border focus:outline-none focus:ring-2 focus:ring-primary", isDarkMode ? "bg-gray-700 border-gray-600 text-white placeholder-gray-400" : "bg-white border-gray-300 text-black placeholder-gray-500" )} disabled={isThinking || isListening || isDisplayingWords} // Disable when thinking, listening, or AI speaking aria-label="Chat input" />
{/* Voice Controls */}
{/* Mic Button */}
{/* End Input and Controls Area */}
{/* End Main chat area */}
{/*
); } export default AITutorPage;