const express = require('express'); const multer = require('multer'); const axios = require('axios'); const bodyParser = require('body-parser'); const cors = require('cors'); const session = require('express-session'); const { v4: uuidv4 } = require('uuid'); const fs = require('fs-extra'); const path = require('path'); const bcrypt = require('bcrypt'); const flash = require('connect-flash'); require('dotenv').config(); // Document processing utilities const crypto = require('crypto'); // Document chunking configuration const CHUNK_SIZE = 20000; // Larger chunks for FormData uploads (20KB) const CHUNK_OVERLAP = 200; // Overlap between chunks const MAX_DOCUMENT_SIZE = 1000000; // 1MB limit for document content // Flowise document store configuration const FLOWISE_BASE_URL = 'https://flowise.suika.cc'; const FLOWISE_DOCUMENT_STORE_ID = 'e293dc23-7cb2-4522-a057-4f0c4b0a444c'; const FLOWISE_API_KEY = process.env.FLOWISE_API_KEY || null; const app = express(); const PORT = process.env.PORT || 3000; // Middleware app.use(cors()); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: true })); app.use(express.static('public')); app.use(session({ secret: process.env.SESSION_SECRET || 'educat-secret-key', resave: false, saveUninitialized: false, cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 } // 24 hours })); app.use(flash()); // Set EJS as view engine app.set('view engine', 'ejs'); app.set('views', './views'); // Ensure uploads directory exists const uploadsDir = path.join(__dirname, 'uploads'); fs.ensureDirSync(uploadsDir); // Simple user storage (in production, use a proper database) const users = [ { id: 1, username: 'admin', email: 'admin@educat.com', password: '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password name: 'Admin User' }, { id: 2, username: 'student', email: 'student@educat.com', password: '$2b$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password name: 'Student User' } ]; // Initialize demo users with proper password hashing async function initializeDemoUsers() { try { const hashedPassword = await bcrypt.hash('password', 10); users[0].password = hashedPassword; users[1].password = hashedPassword; } catch (error) { console.error('Error initializing demo users:', error); } } // Initialize demo users on startup initializeDemoUsers(); // Authentication middleware const requireAuth = (req, res, next) => { if (req.session.userId) { next(); } else { req.flash('error', 'Please log in to access this page'); res.redirect('/login'); } }; // Add user info to all templates and load persistent files app.use(async (req, res, next) => { res.locals.user = req.session.userId ? users.find(u => u.id === req.session.userId) : null; res.locals.messages = req.flash(); // Load user files from persistent storage if user is logged in and session doesn't have files if (req.session.userId && (!req.session.uploadedFiles || req.session.uploadedFiles.length === 0)) { try { const persistentFiles = await loadUserFiles(req.session.userId); if (persistentFiles.length > 0) { req.session.uploadedFiles = persistentFiles; } } catch (error) { console.error('Error loading user files in middleware:', error); } } next(); }); // Configure multer for file uploads const storage = multer.diskStorage({ destination: (req, file, cb) => { cb(null, 'uploads/'); }, filename: (req, file, cb) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname)); } }); const upload = multer({ storage: storage, limits: { fileSize: 10 * 1024 * 1024 // 10MB limit }, fileFilter: (req, file, cb) => { // Allow text files, PDFs, and images const allowedTypes = /jpeg|jpg|png|gif|pdf|txt|doc|docx/; const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); const mimetype = allowedTypes.test(file.mimetype); if (mimetype && extname) { return cb(null, true); } else { cb(new Error('Only text files, PDFs, and images are allowed!')); } } }); // Flowise API configuration const FLOWISE_API_URL = process.env.FLOWISE_API_URL || 'https://flowise.suika.cc/api/v1/prediction'; const FLOWISE_CHATFLOW_ID = process.env.FLOWISE_CHATFLOW_ID || 'your-chatflow-id'; // Helper function to chunk text into smaller pieces function chunkText(text, chunkSize = CHUNK_SIZE, overlap = CHUNK_OVERLAP) { const chunks = []; let start = 0; while (start < text.length) { const end = Math.min(start + chunkSize, text.length); const chunk = text.substring(start, end); // Only add non-empty chunks if (chunk.trim().length > 0) { chunks.push({ content: chunk.trim(), metadata: { chunkIndex: chunks.length, startIndex: start, endIndex: end, totalLength: text.length } }); } // Move start position, accounting for overlap start = end - overlap; // Break if we're at the end if (end >= text.length) break; } return chunks; } // Helper function to clean and validate document content function validateAndCleanDocument(content, originalName) { // Remove excessive whitespace and normalize line endings let cleanContent = content .replace(/\r\n/g, '\n') .replace(/\r/g, '\n') .replace(/\n{3,}/g, '\n\n') .replace(/[ \t]{2,}/g, ' ') .trim(); // Check document size if (cleanContent.length > MAX_DOCUMENT_SIZE) { throw new Error(`Document is too large (${cleanContent.length} characters). Maximum size is ${MAX_DOCUMENT_SIZE} characters.`); } // Check if document has meaningful content if (cleanContent.length < 50) { throw new Error('Document content is too short. Please upload a document with at least 50 characters.'); } // Remove or replace problematic characters that might cause issues cleanContent = cleanContent .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control characters .replace(/[^\x20-\x7E\n\t]/g, (char) => { // Replace non-printable characters const code = char.charCodeAt(0); return code > 127 ? char : ''; // Keep Unicode characters, remove other non-printable }); return cleanContent; } // Helper function to generate document hash for deduplication function generateDocumentHash(content, originalName) { return crypto.createHash('sha256') .update(content + originalName) .digest('hex') .substring(0, 16); } // Helper function to get document loaders from document store async function getDocumentStoreLoaders(documentStoreId) { try { const headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' }; if (FLOWISE_API_KEY) { headers['Authorization'] = `Bearer ${FLOWISE_API_KEY}`; } // Get document store details which includes loaders const storeUrl = `${FLOWISE_BASE_URL}/api/v1/document-store/store/${documentStoreId}`; const response = await axios.get(storeUrl, { headers, timeout: 10000 }); // Parse loaders - they may come as array directly or as JSON string let loaders = []; if (response.data.loaders) { if (Array.isArray(response.data.loaders)) { // Loaders are already an array loaders = response.data.loaders; } else if (typeof response.data.loaders === 'string') { // Loaders are a JSON string that needs parsing try { loaders = JSON.parse(response.data.loaders); } catch (parseError) { loaders = []; } } else { loaders = []; } } return { store: response.data, loaders: loaders }; } catch (error) { console.error('Error getting document store loaders:', { error: error.message, response: error.response ? { status: error.response.status, data: error.response.data } : 'No response' }); return null; } } // Helper function to upsert document to Flowise using FormData (direct file upload) async function upsertDocumentToFlowiseFormData(fileInfo, documentMetadata) { try { // Create FormData for file upload const FormData = require('form-data'); const formData = new FormData(); // Read the file and append to FormData const filePath = path.isAbsolute(fileInfo.path) ? fileInfo.path : path.join(__dirname, fileInfo.path); const fileStream = fs.createReadStream(filePath); // Try to get existing document store info to find existing loaders const storeInfo = await getDocumentStoreLoaders(FLOWISE_DOCUMENT_STORE_ID); let docId = null; let useExistingLoader = false; if (storeInfo && storeInfo.loaders && storeInfo.loaders.length > 0) { // Use the first existing loader ID, but only if replaceExisting is true docId = storeInfo.loaders[0].id || storeInfo.loaders[0].loaderId; useExistingLoader = true; } else { // Create a new loader by letting Flowise auto-generate or create new store } // Append form fields following the Flowise API structure formData.append('files', fileStream, { filename: fileInfo.originalName, contentType: fileInfo.mimetype }); // Only append docId if we have an existing loader, otherwise let Flowise create new if (useExistingLoader && docId) { formData.append('docId', docId); formData.append('replaceExisting', 'true'); } else { // For new documents, don't specify docId and create new store entry formData.append('replaceExisting', 'false'); formData.append('createNewDocStore', 'true'); } formData.append('splitter', JSON.stringify({ "config": { "chunkSize": CHUNK_SIZE, "chunkOverlap": CHUNK_OVERLAP } })); // Add metadata const metadata = { documentId: documentMetadata.documentId, originalName: documentMetadata.originalName, uploadDate: documentMetadata.uploadDate, userId: documentMetadata.userId, source: 'EduCat', fileSize: fileInfo.size, mimetype: fileInfo.mimetype }; formData.append('metadata', JSON.stringify(metadata)); // Don't duplicate the replaceExisting and createNewDocStore - they're set above // Prepare the upsert URL const upsertUrl = `${FLOWISE_BASE_URL}/api/v1/document-store/upsert/${FLOWISE_DOCUMENT_STORE_ID}`; // Prepare headers const headers = { ...formData.getHeaders() }; if (FLOWISE_API_KEY) { headers['Authorization'] = `Bearer ${FLOWISE_API_KEY}`; } // Make the request using axios with FormData const response = await axios.post(upsertUrl, formData, { headers, timeout: 120000, // 2 minute timeout for large files maxContentLength: Infinity, maxBodyLength: Infinity }); return { success: true, totalChunks: 1, // FormData uploads are treated as single operations successfulChunks: 1, failedChunks: 0, results: [{ chunkIndex: 0, success: true, response: response.data }], errors: [] }; } catch (error) { console.error('Flowise FormData upsert failed:', { error: error.message, response: error.response ? { status: error.response.status, statusText: error.response.statusText, data: error.response.data } : 'No response', config: error.config ? { url: error.config.url, method: error.config.method } : 'No config' }); // Fall back to local storage if Flowise fails try { const documentData = { id: documentMetadata.documentId, fileInfo: fileInfo, metadata: documentMetadata, storedAt: new Date().toISOString(), fallbackReason: 'flowise_formdata_upsert_failed' }; const documentsDir = path.join(__dirname, 'data', 'documents'); await fs.ensureDir(documentsDir); const documentPath = path.join(documentsDir, `${documentMetadata.documentId}.json`); await fs.writeJSON(documentPath, documentData, { spaces: 2 }); return { success: true, totalChunks: 1, successfulChunks: 1, failedChunks: 0, results: [{ chunkIndex: 0, success: true, response: { status: 'stored_locally', message: 'Fallback to local storage' } }], errors: [] }; } catch (fallbackError) { console.error('Local storage fallback also failed:', fallbackError); return { success: false, totalChunks: 1, successfulChunks: 0, failedChunks: 1, results: [], errors: [{ chunkIndex: 0, error: `Flowise FormData upsert failed: ${error.message}. Local fallback failed: ${fallbackError.message}`, chunk: 'Complete document' }] }; } } } // Routes app.get('/', (req, res) => { res.render('index', { title: 'EduCat - AI-Powered Note Revision', messages: req.flash() }); }); // Authentication routes app.get('/login', (req, res) => { if (req.session.userId) { return res.redirect('/dashboard'); } res.render('login', { title: 'Login - EduCat', messages: req.flash() }); }); app.post('/login', async (req, res) => { try { const { username, password } = req.body; if (!username || !password) { req.flash('error', 'Please provide both username and password'); return res.redirect('/login'); } const user = users.find(u => u.username === username || u.email === username); if (!user) { req.flash('error', 'Invalid username or password'); return res.redirect('/login'); } const isValidPassword = await bcrypt.compare(password, user.password); if (!isValidPassword) { req.flash('error', 'Invalid username or password'); return res.redirect('/login'); } req.session.userId = user.id; req.flash('success', `Welcome back, ${user.name}!`); res.redirect('/dashboard'); } catch (error) { console.error('Login error:', error); req.flash('error', 'An error occurred during login'); res.redirect('/login'); } }); app.get('/register', (req, res) => { if (req.session.userId) { return res.redirect('/dashboard'); } res.render('register', { title: 'Register - EduCat', messages: req.flash() }); }); app.post('/register', async (req, res) => { try { const { username, email, password, confirmPassword, name } = req.body; if (!username || !email || !password || !confirmPassword || !name) { req.flash('error', 'Please fill in all fields'); return res.redirect('/register'); } if (password !== confirmPassword) { req.flash('error', 'Passwords do not match'); return res.redirect('/register'); } if (password.length < 6) { req.flash('error', 'Password must be at least 6 characters long'); return res.redirect('/register'); } // Check if user already exists const existingUser = users.find(u => u.username === username || u.email === email); if (existingUser) { req.flash('error', 'Username or email already exists'); return res.redirect('/register'); } // Hash password const hashedPassword = await bcrypt.hash(password, 10); // Create new user const newUser = { id: users.length + 1, username, email, password: hashedPassword, name }; users.push(newUser); req.session.userId = newUser.id; req.flash('success', `Welcome to EduCat, ${newUser.name}!`); res.redirect('/dashboard'); } catch (error) { console.error('Registration error:', error); req.flash('error', 'An error occurred during registration'); res.redirect('/register'); } }); app.get('/logout', (req, res) => { req.session.destroy((err) => { if (err) { console.error('Logout error:', err); } res.redirect('/'); }); }); app.get('/upload', requireAuth, (req, res) => { res.render('upload', { title: 'Upload Your Notes - EduCat' }); }); app.post('/upload', requireAuth, upload.single('noteFile'), async (req, res) => { try { if (!req.file) { return res.status(400).json({ success: false, error: 'No file uploaded' }); } const fileInfo = { id: uuidv4(), originalName: req.file.originalname, filename: req.file.filename, path: req.file.path, size: req.file.size, mimetype: req.file.mimetype, uploadDate: new Date().toISOString(), userId: req.session.userId, status: 'processing' }; // Store initial file info in session for immediate access if (!req.session.uploadedFiles) { req.session.uploadedFiles = []; } req.session.uploadedFiles.push(fileInfo); // Also store in persistent storage await addUserFile(req.session.userId, fileInfo); // Send immediate response to prevent timeout res.json({ success: true, message: 'File uploaded successfully! Processing document...', fileInfo: fileInfo, processing: true }); // Process document asynchronously processDocumentAsync(fileInfo, req.session.userId, req.session); } catch (error) { console.error('Upload error:', error); res.status(500).json({ success: false, error: 'Upload failed', details: error.message }); } }); // Async function to process document and upsert to Flowise using FormData async function processDocumentAsync(fileInfo, userId, session) { try { // Find the file in the session to update it by reference const sessionFile = session.uploadedFiles.find(f => f.id === fileInfo.id); if (!sessionFile) { return; } // Create document metadata const documentMetadata = { documentId: fileInfo.id, originalName: fileInfo.originalName, uploadDate: fileInfo.uploadDate, userId: userId, fileSize: fileInfo.size, fileInfo: fileInfo // Pass file info for progress tracking }; // Initialize processing result sessionFile.processingResult = { totalChunks: 1, // FormData uploads are single operations successfulChunks: 0, failedChunks: 0, startedAt: new Date().toISOString(), status: 'uploading_to_flowise' }; // Upsert document to Flowise using FormData const upsertResult = await upsertDocumentToFlowiseFormData(fileInfo, documentMetadata); // Update session file with processing results sessionFile.status = upsertResult.success ? 'processed' : 'failed'; sessionFile.processingProgress = null; // Clear progress when done sessionFile.processingResult = { totalChunks: upsertResult.totalChunks, successfulChunks: upsertResult.successfulChunks, failedChunks: upsertResult.failedChunks, processedAt: new Date().toISOString(), startedAt: sessionFile.processingResult.startedAt, duration: Date.now() - new Date(sessionFile.processingResult.startedAt).getTime(), method: 'formdata' }; if (upsertResult.errors.length > 0) { sessionFile.processingErrors = upsertResult.errors; } // Update persistent storage await updateUserFile(userId, fileInfo.id, { status: sessionFile.status, processingResult: sessionFile.processingResult, processingErrors: sessionFile.processingErrors }); // Verify the session file was updated // Force session save since we modified it in an async context session.save((err) => { if (err) { console.error('Error saving session after document processing:', err); } else { } }); // Log final upsert verification } catch (error) { console.error(`Error processing document ${fileInfo.originalName}:`, error); // Find the file in the session to update it with error const sessionFile = session.uploadedFiles.find(f => f.id === fileInfo.id); if (sessionFile) { sessionFile.status = 'failed'; sessionFile.processingError = error.message; sessionFile.processingResult = sessionFile.processingResult || {}; sessionFile.processingResult.failedAt = new Date().toISOString(); // Update persistent storage await updateUserFile(userId, fileInfo.id, { status: 'failed', processingError: error.message, processingResult: sessionFile.processingResult }); // Force session save since we modified it in an async context session.save((err) => { if (err) { console.error('Error saving session after processing error:', err); } else { } }); } } } app.get('/revise/:fileId', requireAuth, async (req, res) => { try { const fileId = req.params.fileId; const files = req.session.uploadedFiles || []; const file = files.find(f => f.id === fileId); if (!file) { return res.status(404).render('error', { title: 'File Not Found', error: 'File not found' }); } // Read file content const fileContent = await fs.readFile(file.path, 'utf-8'); res.render('revise', { title: 'Revise Notes - EduCat', file: file, content: fileContent }); } catch (error) { console.error('Error loading file:', error); res.status(500).render('error', { title: 'Error', error: 'Failed to load file' }); } }); app.post('/api/revise', requireAuth, async (req, res) => { try { const { content, revisionType, fileId } = req.body; let prompt = ''; let contextContent = content; // If fileId is provided, try to get additional context from stored document if (fileId) { try { const documentPath = path.join(__dirname, 'data', 'documents', `${fileId}.json`); if (await fs.pathExists(documentPath)) { const documentData = await fs.readJSON(documentPath); contextContent = documentData.content; } } catch (error) { } } switch (revisionType) { case 'summarize': prompt = `Please summarize the following notes in a clear and concise manner:\n\n${contextContent}`; break; case 'improve': prompt = `Please improve and enhance the following notes, making them more comprehensive and well-structured:\n\n${contextContent}`; break; case 'questions': prompt = `Based on the following notes, generate study questions that would help test understanding:\n\n${contextContent}`; break; default: prompt = `Please help improve these notes:\n\n${contextContent}`; } // Call Flowise API const response = await axios.post(`${FLOWISE_API_URL}/${FLOWISE_CHATFLOW_ID}`, { question: prompt, history: [] }); res.json({ success: true, revisedContent: response.data.text || response.data.answer || 'No response received' }); } catch (error) { console.error('Revision error:', error); res.status(500).json({ error: 'Failed to revise notes', details: error.message }); } }); app.get('/chat', requireAuth, (req, res) => { // Initialize chat history if it doesn't exist if (!req.session.chatHistory) { req.session.chatHistory = []; } // Initialize chat session ID if it doesn't exist if (!req.session.chatSessionId) { req.session.chatSessionId = `educat-${req.session.userId}-${Date.now()}`; } res.render('chat', { title: 'Chat with EduCat AI', chatHistory: req.session.chatHistory }); }); app.post('/api/chat', requireAuth, async (req, res) => { try { const { message } = req.body; // Initialize chat history in session if it doesn't exist if (!req.session.chatHistory) { req.session.chatHistory = []; } // Initialize or get persistent chat session ID for this user if (!req.session.chatSessionId) { req.session.chatSessionId = `${req.session.userId}-${Date.now()}`; } // Prepare the request payload for Flowise with sessionId and chatId const flowisePayload = { question: message, sessionId: req.session.chatSessionId }; // Add chatId if we have one from previous conversations if (req.session.chatId) { flowisePayload.chatId = req.session.chatId; } // Call Flowise API for chat with session history and sessionId const response = await axios.post(`${FLOWISE_API_URL}/${FLOWISE_CHATFLOW_ID}`, flowisePayload); const aiResponse = response.data.text || response.data.answer || 'No response received'; // Save the chatId from Flowise response for future requests if (response.data.chatId) { req.session.chatId = response.data.chatId; } // Add the conversation to session history req.session.chatHistory.push({ human: message, ai: aiResponse }); // Save session explicitly since we modified it req.session.save((err) => { if (err) { console.error('Error saving chat session:', err); } }); res.json({ success: true, response: aiResponse }); } catch (error) { console.error('Chat error:', error); res.status(500).json({ success: false, error: 'Failed to get chat response', details: error.message }); } }); // Get chat history from session app.get('/api/chat/history', requireAuth, (req, res) => { try { const chatHistory = req.session.chatHistory || []; res.json({ success: true, history: chatHistory }); } catch (error) { console.error('Error getting chat history:', error); res.status(500).json({ success: false, error: 'Failed to get chat history', details: error.message }); } }); // Clear chat history app.delete('/api/chat/history', requireAuth, (req, res) => { try { req.session.chatHistory = []; // Reset the session ID to start a fresh conversation req.session.chatSessionId = `${req.session.userId}-${Date.now()}`; // Clear the Flowise chatId delete req.session.chatId; req.session.save((err) => { if (err) { console.error('Error clearing chat session:', err); return res.status(500).json({ success: false, error: 'Failed to clear chat history' }); } res.json({ success: true, message: 'Chat history cleared' }); }); } catch (error) { console.error('Error clearing chat history:', error); res.status(500).json({ success: false, error: 'Failed to clear chat history', details: error.message }); } }); app.get('/dashboard', requireAuth, async (req, res) => { try { // Load persistent files for this user const persistentFiles = await loadUserFiles(req.session.userId); // Merge with session files (in case there are newly uploaded files not yet saved) const sessionFiles = req.session.uploadedFiles || []; const allFiles = [...persistentFiles]; // Add any session files that aren't already in persistent storage sessionFiles.forEach(sessionFile => { if (!persistentFiles.find(f => f.id === sessionFile.id)) { allFiles.push(sessionFile); } }); // Update session with merged files for current session use req.session.uploadedFiles = allFiles; res.render('dashboard', { title: 'Dashboard - EduCat', files: allFiles }); } catch (error) { console.error('Error loading dashboard:', error); res.render('dashboard', { title: 'Dashboard - EduCat', files: req.session.uploadedFiles || [] }); } }); // File management endpoints app.get('/api/files/:fileId/preview', requireAuth, async (req, res) => { try { const fileId = req.params.fileId; const files = req.session.uploadedFiles || []; const file = files.find(f => f.id === fileId); if (!file) { return res.status(404).json({ success: false, error: 'File not found' }); } // Read file content const filePath = path.join(__dirname, file.path); const fileContent = await fs.readFile(filePath, 'utf-8'); res.json({ success: true, file: { id: file.id, originalName: file.originalName, size: file.size, uploadDate: file.uploadDate, content: fileContent } }); } catch (error) { console.error('Error previewing file:', error); res.status(500).json({ success: false, error: 'Failed to preview file', details: error.message }); } }); app.delete('/api/files/:fileId', requireAuth, async (req, res) => { try { const fileId = req.params.fileId; const files = req.session.uploadedFiles || []; const fileIndex = files.findIndex(f => f.id === fileId); if (fileIndex === -1) { return res.status(404).json({ success: false, error: 'File not found' }); } const file = files[fileIndex]; // Delete the physical file const filePath = path.join(__dirname, file.path); if (await fs.pathExists(filePath)) { await fs.unlink(filePath); } // Remove from session req.session.uploadedFiles.splice(fileIndex, 1); // Remove from persistent storage await removeUserFile(req.session.userId, fileId); res.json({ success: true, message: 'File deleted successfully' }); } catch (error) { console.error('Error deleting file:', error); res.status(500).json({ success: false, error: 'Failed to delete file', details: error.message }); } }); // File processing status endpoint app.get('/api/files/:fileId/status', requireAuth, async (req, res) => { try { const fileId = req.params.fileId; let files = req.session.uploadedFiles || []; let file = files.find(f => f.id === fileId); // If not in session, try to load from persistent storage if (!file) { const persistentFiles = await loadUserFiles(req.session.userId); file = persistentFiles.find(f => f.id === fileId); // If found in persistent storage, add to session for future requests if (file) { if (!req.session.uploadedFiles) { req.session.uploadedFiles = []; } req.session.uploadedFiles.push(file); } } if (!file) { return res.status(404).json({ success: false, error: 'File not found' }); } res.json({ success: true, file: { id: file.id, originalName: file.originalName, status: file.status, uploadDate: file.uploadDate, processingResult: file.processingResult, processingError: file.processingError, processingErrors: file.processingErrors } }); } catch (error) { console.error('Error getting file status:', error); res.status(500).json({ success: false, error: 'Failed to get file status', details: error.message }); } }); // Retry processing endpoint app.post('/api/files/:fileId/retry', requireAuth, async (req, res) => { try { const fileId = req.params.fileId; const files = req.session.uploadedFiles || []; const file = files.find(f => f.id === fileId); if (!file) { return res.status(404).json({ success: false, error: 'File not found' }); } if (file.status === 'processing') { return res.status(400).json({ success: false, error: 'File is already being processed' }); } // Reset file status for retry file.status = 'processing'; file.processingResult = null; file.processingError = null; file.processingErrors = null; // Start processing asynchronously processDocumentAsync(file, req.session.userId, req.session); res.json({ success: true, message: 'Processing retry initiated' }); } catch (error) { console.error('Error retrying processing:', error); res.status(500).json({ success: false, error: 'Failed to retry processing', details: error.message }); } }); // Bulk processing status endpoint app.get('/api/files/status/all', requireAuth, async (req, res) => { try { // Load persistent files and merge with session const persistentFiles = await loadUserFiles(req.session.userId); const sessionFiles = req.session.uploadedFiles || []; // Merge files, preferring session data for current uploads const allFiles = [...persistentFiles]; sessionFiles.forEach(sessionFile => { const existingIndex = allFiles.findIndex(f => f.id === sessionFile.id); if (existingIndex !== -1) { // Update existing with session data (more current) allFiles[existingIndex] = sessionFile; } else { // Add new session file allFiles.push(sessionFile); } }); const statusSummary = allFiles.map(file => ({ id: file.id, originalName: file.originalName, status: file.status, uploadDate: file.uploadDate, processingResult: file.processingResult ? { totalChunks: file.processingResult.totalChunks, successfulChunks: file.processingResult.successfulChunks, failedChunks: file.processingResult.failedChunks, processedAt: file.processingResult.processedAt } : null })); const summary = { totalFiles: allFiles.length, processing: allFiles.filter(f => f.status === 'processing').length, processed: allFiles.filter(f => f.status === 'processed').length, failed: allFiles.filter(f => f.status === 'failed').length, files: statusSummary }; res.json({ success: true, summary: summary }); } catch (error) { console.error('Error getting files status:', error); res.status(500).json({ success: false, error: 'Failed to get files status', details: error.message }); } }); app.get('/quiz', requireAuth, (req, res) => { res.render('quiz', { title: 'AI Quiz Generator - EduCat' }); }); app.post('/api/generate-quiz', requireAuth, async (req, res) => { try { const { topic, difficulty, questionCount, quizType } = req.body; let prompt = ''; switch (quizType) { case 'multiple-choice': prompt = `Generate exactly ${questionCount} multiple choice questions about "${topic}" at ${difficulty} difficulty level. Each question should have 4 options (A, B, C, D). Format the response as a JSON array where each question follows this exact structure: [ { "question": "What is the capital of France?", "options": ["A) London", "B) Paris", "C) Berlin", "D) Madrid"], "correct": "B", "explanation": "Paris is the capital and largest city of France." } ] Return ONLY the JSON array, no additional text.`; break; case 'true-false': prompt = `Generate exactly ${questionCount} true/false questions about "${topic}" at ${difficulty} difficulty level. Format the response as a JSON array where each question follows this exact structure: [ { "question": "The Earth is flat.", "correct": "False", "explanation": "The Earth is spherical, not flat." } ] Return ONLY the JSON array, no additional text.`; break; case 'short-answer': prompt = `Generate exactly ${questionCount} short answer questions about "${topic}" at ${difficulty} difficulty level. Format the response as a JSON array where each question follows this exact structure: [ { "question": "What is the process by which plants make their own food?", "answer": "Photosynthesis", "keywords": ["photosynthesis", "sunlight", "chlorophyll"] } ] Return ONLY the JSON array, no additional text.`; break; default: prompt = `Generate exactly ${questionCount} multiple choice questions about "${topic}" at ${difficulty} difficulty level.`; } // Call Flowise API const response = await axios.post(`${FLOWISE_API_URL}/${FLOWISE_CHATFLOW_ID}`, { question: prompt, history: [] }); let quizData; try { const responseText = response.data.text || response.data.answer || response.data; // Try to extract JSON from the response let jsonString = null; // First, try to find JSON wrapped in code blocks const codeBlockMatch = responseText.match(/```(?:json)?\s*(\[[\s\S]*?\])\s*```/); if (codeBlockMatch) { jsonString = codeBlockMatch[1]; } else { // Try to find JSON array by counting brackets const startIndex = responseText.indexOf('['); if (startIndex !== -1) { let bracketCount = 0; let endIndex = startIndex; for (let i = startIndex; i < responseText.length; i++) { if (responseText[i] === '[') bracketCount++; if (responseText[i] === ']') bracketCount--; if (bracketCount === 0) { endIndex = i; break; } } if (bracketCount === 0) { jsonString = responseText.substring(startIndex, endIndex + 1); } } } if (jsonString) { quizData = JSON.parse(jsonString); } else { quizData = generateFallbackQuiz(topic, questionCount, quizType); } } catch (parseError) { console.error('Quiz parsing error:', parseError); quizData = generateFallbackQuiz(topic, questionCount, quizType); } // Ensure quizData is always defined and is an array if (!quizData || !Array.isArray(quizData) || quizData.length === 0) { quizData = generateFallbackQuiz(topic, questionCount, quizType); } res.json({ success: true, quiz: quizData, topic: topic, difficulty: difficulty, questionCount: questionCount, quizType: quizType }); } catch (error) { console.error('Quiz generation error:', error); console.error('Error stack:', error.stack); // Return fallback quiz on error const fallbackQuiz = generateFallbackQuiz(req.body.topic || 'General Knowledge', req.body.questionCount || 5, req.body.quizType || 'multiple-choice'); res.json({ success: true, quiz: fallbackQuiz, topic: req.body.topic || 'General Knowledge', difficulty: req.body.difficulty || 'beginner', questionCount: req.body.questionCount || 5, quizType: req.body.quizType || 'multiple-choice' }); } }); function generateFallbackQuiz(topic, questionCount, quizType) { const questions = []; // Generate actual questions based on topic const topicQuestions = { 'javascript': [ { question: 'What is the correct way to declare a variable in JavaScript?', options: ['A) var myVar = 5;', 'B) variable myVar = 5;', 'C) declare myVar = 5;', 'D) int myVar = 5;'], correct: 'A', explanation: 'The var keyword is used to declare variables in JavaScript.' }, { question: 'Which method is used to add an element to the end of an array?', options: ['A) push()', 'B) add()', 'C) append()', 'D) insert()'], correct: 'A', explanation: 'The push() method adds one or more elements to the end of an array.' }, { question: 'What does === operator do in JavaScript?', options: ['A) Assigns a value', 'B) Compares values only', 'C) Compares values and types', 'D) Declares a constant'], correct: 'C', explanation: 'The === operator compares both value and type without type conversion.' }, { question: 'How do you write a comment in JavaScript?', options: ['A) ', 'B) // comment', 'C) # comment', 'D) /* comment */'], correct: 'B', explanation: 'Single-line comments in JavaScript start with //.' }, { question: 'What is the result of typeof null in JavaScript?', options: ['A) "null"', 'B) "undefined"', 'C) "object"', 'D) "boolean"'], correct: 'C', explanation: 'typeof null returns "object" due to a legacy bug in JavaScript.' } ], 'python': [ { question: 'Which keyword is used to create a function in Python?', options: ['A) function', 'B) def', 'C) create', 'D) func'], correct: 'B', explanation: 'The def keyword is used to define functions in Python.' }, { question: 'What is the correct way to create a list in Python?', options: ['A) list = (1, 2, 3)', 'B) list = {1, 2, 3}', 'C) list = [1, 2, 3]', 'D) list = <1, 2, 3>'], correct: 'C', explanation: 'Square brackets [] are used to create lists in Python.' }, { question: 'How do you start a comment in Python?', options: ['A) //', 'B) #', 'C) /*', 'D) --'], correct: 'B', explanation: 'Comments in Python start with the # symbol.' }, { question: 'What does len() function do in Python?', options: ['A) Returns the length of an object', 'B) Converts to lowercase', 'C) Rounds a number', 'D) Prints output'], correct: 'A', explanation: 'The len() function returns the number of items in an object.' }, { question: 'Which of the following is a mutable data type in Python?', options: ['A) tuple', 'B) string', 'C) list', 'D) integer'], correct: 'C', explanation: 'Lists are mutable, meaning they can be changed after creation.' } ], 'math': [ { question: 'What is the value of π (pi) approximately?', options: ['A) 3.14159', 'B) 2.71828', 'C) 1.61803', 'D) 4.66920'], correct: 'A', explanation: 'π (pi) is approximately 3.14159, the ratio of circumference to diameter.' }, { question: 'What is the derivative of x²?', options: ['A) x', 'B) 2x', 'C) x³', 'D) 2x²'], correct: 'B', explanation: 'Using the power rule, the derivative of x² is 2x.' }, { question: 'What is the Pythagorean theorem?', options: ['A) a + b = c', 'B) a² + b² = c²', 'C) a × b = c', 'D) a² - b² = c²'], correct: 'B', explanation: 'The Pythagorean theorem states that a² + b² = c² for right triangles.' }, { question: 'What is the factorial of 5?', options: ['A) 25', 'B) 120', 'C) 60', 'D) 100'], correct: 'B', explanation: '5! = 5 × 4 × 3 × 2 × 1 = 120' }, { question: 'What is the square root of 64?', options: ['A) 6', 'B) 7', 'C) 8', 'D) 9'], correct: 'C', explanation: 'The square root of 64 is 8 because 8² = 64.' } ] }; // Get appropriate questions for the topic const availableQuestions = topicQuestions[topic.toLowerCase()] || topicQuestions['math']; for (let i = 0; i < questionCount; i++) { const questionIndex = i % availableQuestions.length; const baseQuestion = availableQuestions[questionIndex]; if (quizType === 'multiple-choice') { questions.push({ question: baseQuestion.question, options: baseQuestion.options, correct: baseQuestion.correct, explanation: baseQuestion.explanation }); } else if (quizType === 'true-false') { questions.push({ question: baseQuestion.question.replace(/Which|What|How/, 'Is it true that'), correct: Math.random() > 0.5 ? 'True' : 'False', explanation: baseQuestion.explanation }); } else { questions.push({ question: baseQuestion.question, answer: baseQuestion.correct, keywords: [baseQuestion.correct.toLowerCase(), topic.toLowerCase()] }); } } return questions; } app.post('/api/submit-quiz', requireAuth, async (req, res) => { try { const { answers, quiz, topic, difficulty, quizType } = req.body; let score = 0; const results = []; quiz.forEach((question, index) => { const userAnswer = answers[index]; const isCorrect = userAnswer === question.correct; if (isCorrect) score++; results.push({ question: question.question, userAnswer: userAnswer, correctAnswer: question.correct, isCorrect: isCorrect, explanation: question.explanation || '', options: question.options || null // Include options for multiple choice }); }); const percentage = Math.round((score / quiz.length) * 100); // Store quiz result in session if (!req.session.quizResults) { req.session.quizResults = []; } const quizResult = { id: uuidv4(), topic: topic, score: score, total: quiz.length, percentage: percentage, date: new Date().toISOString(), results: results, userId: req.session.userId, difficulty: difficulty || 'beginner', quizType: quizType || 'multiple-choice' }; // Store quiz result in session (for immediate use) req.session.quizResults.push(quizResult); // Store quiz result persistently await addQuizResult(req.session.userId, quizResult); res.json({ success: true, score: score, total: quiz.length, percentage: percentage, results: results, quizId: quizResult.id }); } catch (error) { console.error('Quiz submission error:', error); res.status(500).json({ error: 'Failed to submit quiz', details: error.message }); } }); // User file storage persistence const USER_FILES_DIR = path.join(__dirname, 'data', 'user-files'); // Ensure user files directory exists async function ensureUserFilesDirectory() { await fs.ensureDir(USER_FILES_DIR); } // Save user files to persistent storage async function saveUserFiles(userId, files) { try { await ensureUserFilesDirectory(); const userFilePath = path.join(USER_FILES_DIR, `user-${userId}-files.json`); await fs.writeJSON(userFilePath, files, { spaces: 2 }); } catch (error) { console.error('Error saving user files:', error); } } // Load user files from persistent storage async function loadUserFiles(userId) { try { await ensureUserFilesDirectory(); const userFilePath = path.join(USER_FILES_DIR, `user-${userId}-files.json`); if (await fs.pathExists(userFilePath)) { const files = await fs.readJSON(userFilePath); return files || []; } return []; } catch (error) { console.error('Error loading user files:', error); return []; } } // Add a file to user's persistent storage async function addUserFile(userId, fileInfo) { const userFiles = await loadUserFiles(userId); userFiles.push(fileInfo); await saveUserFiles(userId, userFiles); } // Update a file in user's persistent storage async function updateUserFile(userId, fileId, updates) { const userFiles = await loadUserFiles(userId); const fileIndex = userFiles.findIndex(f => f.id === fileId); if (fileIndex !== -1) { userFiles[fileIndex] = { ...userFiles[fileIndex], ...updates }; await saveUserFiles(userId, userFiles); } } // Remove a file from user's persistent storage async function removeUserFile(userId, fileId) { const userFiles = await loadUserFiles(userId); const filteredFiles = userFiles.filter(f => f.id !== fileId); await saveUserFiles(userId, filteredFiles); } // Quiz results persistence const QUIZ_RESULTS_FILE = path.join(__dirname, 'data', 'quiz-results.json'); // Ensure data directory exists async function ensureDataDirectory() { await fs.ensureDir(path.join(__dirname, 'data')); } // Load quiz results from file async function loadQuizResults() { try { await ensureDataDirectory(); if (await fs.pathExists(QUIZ_RESULTS_FILE)) { const data = await fs.readJSON(QUIZ_RESULTS_FILE); return data || {}; } return {}; } catch (error) { console.error('Error loading quiz results:', error); return {}; } } // Save quiz results to file async function saveQuizResults(results) { try { await ensureDataDirectory(); await fs.writeJSON(QUIZ_RESULTS_FILE, results, { spaces: 2 }); } catch (error) { console.error('Error saving quiz results:', error); } } // Add quiz result for user async function addQuizResult(userId, quizResult) { const allResults = await loadQuizResults(); if (!allResults[userId]) { allResults[userId] = []; } allResults[userId].push(quizResult); await saveQuizResults(allResults); } // Get quiz results for user async function getUserQuizResults(userId) { const allResults = await loadQuizResults(); return allResults[userId] || []; } // Error handling middleware app.use((error, req, res, next) => { if (error instanceof multer.MulterError) { if (error.code === 'LIMIT_FILE_SIZE') { return res.status(400).json({ error: 'File too large. Maximum size is 10MB.' }); } } console.error('Error:', error); res.status(500).json({ error: 'Internal server error' }); }); // Quiz history route app.get('/quiz-history', requireAuth, async (req, res) => { try { const quizResults = await getUserQuizResults(req.session.userId); res.render('quiz-history', { title: 'Quiz History - EduCat', quizResults: quizResults.reverse() // Show newest first }); } catch (error) { console.error('Error loading quiz history:', error); res.render('quiz-history', { title: 'Quiz History - EduCat', quizResults: [] }); } }); // API route to clear quiz history app.delete('/api/quiz-history', requireAuth, async (req, res) => { try { const userId = req.session.userId; const allResults = await loadQuizResults(); // Clear quiz history for this user allResults[userId] = []; await saveQuizResults(allResults); res.json({ success: true, message: 'Quiz history cleared successfully' }); } catch (error) { console.error('Error clearing quiz history:', error); res.status(500).json({ success: false, error: 'Failed to clear quiz history' }); } }); // API route to get quiz statistics app.get('/api/quiz-stats', requireAuth, async (req, res) => { try { const quizResults = await getUserQuizResults(req.session.userId); if (quizResults.length === 0) { return res.json({ success: true, stats: { totalQuizzes: 0, averageScore: 0, bestScore: 0, recentQuizzes: [], topicStats: {}, progressChart: [] } }); } // Calculate statistics const totalQuizzes = quizResults.length; const averageScore = Math.round(quizResults.reduce((sum, quiz) => sum + quiz.percentage, 0) / totalQuizzes); const bestScore = Math.max(...quizResults.map(quiz => quiz.percentage)); const recentQuizzes = quizResults.slice(-5).reverse(); // Last 5 quizzes // Topic statistics const topicStats = {}; quizResults.forEach(quiz => { if (!topicStats[quiz.topic]) { topicStats[quiz.topic] = { count: 0, totalScore: 0, bestScore: 0 }; } topicStats[quiz.topic].count++; topicStats[quiz.topic].totalScore += quiz.percentage; topicStats[quiz.topic].bestScore = Math.max(topicStats[quiz.topic].bestScore, quiz.percentage); }); // Calculate average for each topic Object.keys(topicStats).forEach(topic => { topicStats[topic].averageScore = Math.round(topicStats[topic].totalScore / topicStats[topic].count); }); // Progress chart data (last 10 quizzes) const progressChart = quizResults.slice(-10).map((quiz, index) => ({ quiz: index + 1, score: quiz.percentage, topic: quiz.topic, date: quiz.date })); res.json({ success: true, stats: { totalQuizzes, averageScore, bestScore, recentQuizzes, topicStats, progressChart } }); } catch (error) { console.error('Error getting quiz stats:', error); res.status(500).json({ success: false, error: 'Failed to get quiz statistics' }); } }); // API route to get detailed quiz results by ID app.get('/api/quiz-details/:quizId', requireAuth, async (req, res) => { try { const quizId = req.params.quizId; const quizResults = await getUserQuizResults(req.session.userId); const quiz = quizResults.find(q => q.id === quizId); if (!quiz) { return res.status(404).json({ success: false, error: 'Quiz not found' }); } res.json({ success: true, quiz: quiz }); } catch (error) { console.error('Error getting quiz details:', error); res.status(500).json({ success: false, error: 'Failed to get quiz details' }); } }); // File progress tracking endpoint app.get('/api/files/:fileId/progress', requireAuth, async (req, res) => { try { const fileId = req.params.fileId; const files = req.session.uploadedFiles || []; const file = files.find(f => f.id === fileId); if (!file) { return res.status(404).json({ success: false, error: 'File not found' }); } res.json({ success: true, progress: { status: file.status, processingProgress: file.processingProgress || null, processingResult: file.processingResult || null, processingError: file.processingError || null } }); } catch (error) { console.error('Error getting file progress:', error); res.status(500).json({ success: false, error: 'Failed to get file progress', details: error.message }); } }); // 404 handler app.use((req, res) => { res.status(404).render('error', { title: 'Page Not Found', error: 'The page you are looking for does not exist.' }); }); app.listen(PORT, () => { console.log(`EduCat server running on http://localhost:${PORT}`); });