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 */}
{/* Voice Controls */}
{/* Mic Button */}
{/* End Input and Controls Area */}
{/* End Main chat area */}
{/* */}
);
}
export default AITutorPage;