CCCPaste

AITutorPage

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;