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'); const mammoth = require('mammoth'); // For .docx files const pdfParse = require('pdf-parse'); // For PDF files const ExcelJS = require('exceljs'); // For Excel files // Markdown and HTML processing const createDOMPurify = require('dompurify'); const { JSDOM } = require('jsdom'); // Initialize DOMPurify const window = new JSDOM('').window; const DOMPurify = createDOMPurify(window); // Dynamic import for marked (ES module) let marked = null; async function initializeMarked() { if (!marked) { const markedModule = await import('marked'); marked = markedModule.marked; } return marked; } // Helper function to extract text from various document formats async function extractTextFromDocument(filePath, fileExtension) { try { const extension = fileExtension.toLowerCase(); switch (extension) { case '.docx': // Extract text from Word documents const docxBuffer = await fs.readFile(filePath); const docxResult = await mammoth.extractRawText({ buffer: docxBuffer }); return { success: true, text: docxResult.value, extractedLength: docxResult.value.length, method: 'mammoth' }; case '.pdf': // Extract text from PDF documents const pdfBuffer = await fs.readFile(filePath); const pdfResult = await pdfParse(pdfBuffer); return { success: true, text: pdfResult.text, extractedLength: pdfResult.text.length, method: 'pdf-parse', pages: pdfResult.numpages }; case '.xlsx': case '.xls': // Extract text from Excel files using ExcelJS const workbook = new ExcelJS.Workbook(); await workbook.xlsx.readFile(filePath); let excelText = ''; let sheetCount = 0; workbook.eachSheet((worksheet, sheetId) => { sheetCount++; excelText += `Sheet: ${worksheet.name}\n`; worksheet.eachRow((row, rowNumber) => { const rowText = []; row.eachCell((cell, colNumber) => { // Extract cell value as text let cellValue = cell.value; if (cellValue !== null && cellValue !== undefined) { // Handle different cell types if (typeof cellValue === 'object' && cellValue.text) { // Rich text object cellValue = cellValue.text; } else if (typeof cellValue === 'object' && cellValue.result) { // Formula result cellValue = cellValue.result; } rowText.push(String(cellValue)); } }); if (rowText.length > 0) { excelText += rowText.join('\t') + '\n'; } }); excelText += '\n'; }); return { success: true, text: excelText, extractedLength: excelText.length, method: 'exceljs', sheets: sheetCount }; case '.txt': case '.md': case '.json': case '.js': case '.html': case '.css': case '.xml': case '.csv': // Read text files directly const textContent = await fs.readFile(filePath, 'utf-8'); return { success: true, text: textContent, extractedLength: textContent.length, method: 'direct' }; default: return { success: false, error: `Unsupported file type: ${extension}`, text: null }; } } catch (error) { console.error('Error extracting text from document:', error); return { success: false, error: error.message, text: null }; } } // 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-2025', resave: true, saveUninitialized: true, rolling: true, cookie: { secure: false, maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days instead of 1 day httpOnly: true }, name: 'educat.session.id' // Custom session name })); 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) => { // Define allowed file types for RAG processing - only text-extractable documents const allowedExtensions = ['.pdf', '.txt', '.doc', '.docx', '.xlsx', '.xls', '.md', '.json', '.csv', '.xml']; const allowedMimeTypes = [ 'application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'text/xml', 'application/xml', 'application/json', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ]; const fileExtension = path.extname(file.originalname).toLowerCase(); const fileMimeType = file.mimetype.toLowerCase(); // Check if file extension is allowed const isExtensionAllowed = allowedExtensions.includes(fileExtension); // Check if MIME type is allowed const isMimeTypeAllowed = allowedMimeTypes.includes(fileMimeType); // Additional validation for specific file types if (isExtensionAllowed && isMimeTypeAllowed) { return cb(null, true); } else { // Provide specific error messages for different rejection reasons if (!isExtensionAllowed) { return cb(new Error(`File type "${fileExtension}" is not supported. Only document files (PDF, Word, Excel, text files) are allowed for RAG processing.`)); } else if (!isMimeTypeAllowed) { return cb(new Error(`MIME type "${fileMimeType}" is not supported. Please upload valid document files only.`)); } else { return cb(new Error('Invalid file type. Only text-extractable documents are allowed to prevent RAG corruption.')); } } } }); // 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('/'); }); }); // Dashboard route app.get('/dashboard', requireAuth, async (req, res) => { try { // Load user files from both session and persistent storage let allFiles = []; let revisedFiles = []; // Load persistent files try { const persistentFiles = await loadUserFiles(req.session.userId); const persistentRevisedFiles = await loadRevisedFiles(req.session.userId); allFiles = persistentFiles; revisedFiles = persistentRevisedFiles; // Merge with session data (session data is more current) const sessionFiles = req.session.uploadedFiles || []; const sessionRevisedFiles = req.session.revisedFiles || []; // Update session with persistent data if session is empty if (sessionFiles.length === 0 && persistentFiles.length > 0) { req.session.uploadedFiles = persistentFiles; } if (sessionRevisedFiles.length === 0 && persistentRevisedFiles.length > 0) { req.session.revisedFiles = persistentRevisedFiles; } // Use session data as the source of truth for display allFiles = req.session.uploadedFiles || persistentFiles; revisedFiles = req.session.revisedFiles || persistentRevisedFiles; } catch (error) { console.error('Error loading user files for dashboard:', error); // Fall back to session data allFiles = req.session.uploadedFiles || []; revisedFiles = req.session.revisedFiles || []; } res.render('dashboard', { title: 'Dashboard - EduCat', files: allFiles, revisedFiles: revisedFiles }); } catch (error) { console.error('Dashboard error:', error); req.flash('error', 'Error loading dashboard'); res.redirect('/'); } }); // Chat route app.get('/chat', requireAuth, async (req, res) => { try { // Initialize chat session ID if it doesn't exist if (!req.session.chatSessionId) { req.session.chatSessionId = `educat-${req.session.userId}-${Date.now()}`; } // Initialize chat history in session if it doesn't exist if (!req.session.chatHistory) { req.session.chatHistory = []; } res.render('chat', { title: 'Chat with EduCat AI', chatHistory: req.session.chatHistory }); } catch (error) { console.error('Chat route error:', error); res.render('chat', { title: 'Chat with EduCat AI', chatHistory: [] }); } }); 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' }); } const filePath = path.join(__dirname, file.path); const fileExtension = path.extname(file.originalName).toLowerCase(); // Extract text from the document using the same method as file preview const extractionResult = await extractTextFromDocument(filePath, fileExtension); let fileContent = ''; let extractionInfo = null; let extractionError = null; if (extractionResult.success) { fileContent = extractionResult.text; extractionInfo = { method: extractionResult.method, totalLength: extractionResult.extractedLength, pages: extractionResult.pages || null, sheets: extractionResult.sheets || null }; } else { // Try to read as plain text if extraction failed try { fileContent = await fs.readFile(filePath, 'utf-8'); extractionInfo = { method: 'fallback-text', totalLength: fileContent.length }; } catch (readError) { extractionError = `Failed to extract or read file: ${extractionResult.error}`; fileContent = `Error: Unable to read file content. ${extractionResult.error}`; } } res.render('revise', { title: 'Revise Notes - EduCat', file: file, content: fileContent, extractionInfo: extractionInfo, extractionError: extractionError }); } 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) { console.log('Could not load stored document, using provided content'); } } 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.json({ success: false, error: 'Failed to revise notes. Please try again.', details: error.message }); } }); // Chat API endpoint app.post('/api/chat', requireAuth, async (req, res) => { try { const { message, history = [] } = req.body; if (!message || message.trim().length === 0) { return res.json({ success: false, error: 'Message is required' }); } // 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, history: req.session.chatHistory, 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, timestamp: new Date().toISOString() }); // Save session req.session.save((err) => { if (err) { console.error('Error saving session:', err); } }); res.json({ success: true, response: aiResponse }); } catch (error) { console.error('Chat error:', error); res.json({ success: false, error: 'Failed to get response from AI. Please try again.', details: error.message }); } }); // Get chat history endpoint app.get('/api/chat/history', requireAuth, (req, res) => { try { const chatHistory = req.session.chatHistory || []; res.json({ success: true, chatHistory: chatHistory }); } catch (error) { console.error('Error getting chat history:', error); res.json({ success: false, error: 'Failed to get chat history', details: error.message }); } }); // Delete chat history endpoint 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.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.json({ success: false, error: 'Failed to clear chat history', details: error.message }); } }); // Save revised notes endpoint app.post('/api/save-revised', requireAuth, async (req, res) => { try { const { fileId, revisedContent, revisionType } = req.body; if (!fileId || !revisedContent) { return res.json({ success: false, error: 'File ID and revised content are required' }); } // Find the original file const files = req.session.uploadedFiles || []; const originalFile = files.find(f => f.id === fileId); if (!originalFile) { return res.json({ success: false, error: 'Original file not found' }); } // Create revised notes directory if it doesn't exist const revisedNotesDir = path.join(__dirname, 'uploads', 'revised-notes'); await fs.ensureDir(revisedNotesDir); // Create revised file info const timestamp = Date.now(); const originalExt = path.extname(originalFile.originalName); const fileName = `${path.parse(originalFile.originalName).name}_${revisionType}_revised_${timestamp}.txt`; const filePath = path.join(revisedNotesDir, fileName); const revisedFileInfo = { id: uuidv4(), originalName: fileName, filename: fileName, path: filePath, size: Buffer.byteLength(revisedContent, 'utf8'), mimetype: 'text/plain', uploadDate: new Date().toISOString(), userId: req.session.userId, status: 'processed', isRevised: true, originalFileId: fileId, revisionType: revisionType, originalFileName: originalFile.originalName }; // Save revised content to file await fs.writeFile(revisedFileInfo.path, revisedContent, 'utf8'); // Don't add revised files to regular uploadedFiles - store separately if (!req.session.revisedFiles) { req.session.revisedFiles = []; } req.session.revisedFiles.push(revisedFileInfo); // Save to persistent storage in a separate category await saveRevisedFile(req.session.userId, revisedFileInfo); res.json({ success: true, message: 'Revised notes saved successfully!', fileInfo: revisedFileInfo }); } catch (error) { console.error('Save revised notes error:', error); res.json({ success: false, error: 'Failed to save revised notes', details: error.message }); } }); // Download revised notes endpoint app.get('/api/download-revised/:fileId', requireAuth, async (req, res) => { try { const fileId = req.params.fileId; const { content } = req.query; if (!content) { return res.status(400).json({ success: false, error: 'No content provided for download' }); } // Find the original file to get naming info const files = req.session.uploadedFiles || []; const originalFile = files.find(f => f.id === fileId); // Get the revision type from the query if available const revisionType = req.query.revisionType || 'revised'; const fileName = originalFile ? `${path.parse(originalFile.originalName).name}_${revisionType}.txt` : `revised_notes_${Date.now()}.txt`; // Set headers for file download res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); res.setHeader('Content-Length', Buffer.byteLength(content, 'utf8')); // Send the content res.send(content); } catch (error) { console.error('Download revised notes error:', error); res.status(500).json({ success: false, error: 'Failed to download revised notes', details: error.message }); } }); // Serve revised files for direct download app.get('/uploads/revised-notes/:filename', requireAuth, (req, res) => { try { const filename = req.params.filename; const filePath = path.join(__dirname, 'uploads', 'revised-notes', filename); // Check if file exists if (!fs.existsSync(filePath)) { return res.status(404).json({ success: false, error: 'File not found' }); } // Serve the file res.download(filePath, filename); } catch (error) { console.error('Error serving revised file:', error); res.status(500).json({ success: false, error: 'Failed to serve file' }); } }); // Delete revised file endpoint app.delete('/api/revised-files/:fileId', requireAuth, async (req, res) => { try { const fileId = req.params.fileId; const revisedFiles = req.session.revisedFiles || []; const fileIndex = revisedFiles.findIndex(f => f.id === fileId); if (fileIndex === -1) { return res.status(404).json({ success: false, error: 'File not found' }); } const file = revisedFiles[fileIndex]; // Delete the physical file if (await fs.pathExists(file.path)) { await fs.unlink(file.path); } // Remove from session req.session.revisedFiles.splice(fileIndex, 1); // Remove from persistent storage await removeRevisedFile(req.session.userId, fileId); res.json({ success: true, message: 'Revised file deleted successfully' }); } catch (error) { console.error('Error deleting revised file:', error); res.status(500).json({ success: false, error: 'Failed to delete file', details: error.message }); } }); // Get revised file content with rendering options app.get('/api/revised-files/:fileId/content', requireAuth, async (req, res) => { try { const fileId = req.params.fileId; const { displayMode = 'markdown' } = req.query; const revisedFiles = req.session.revisedFiles || []; const file = revisedFiles.find(f => f.id === fileId); if (!file) { // Try to load from persistent storage const persistentRevisedFiles = await loadRevisedFiles(req.session.userId); const persistentFile = persistentRevisedFiles.find(f => f.id === fileId); if (!persistentFile) { return res.status(404).json({ success: false, error: 'File not found' }); } // Read file content const content = await fs.readFile(persistentFile.path, 'utf8'); return res.json({ success: true, file: persistentFile, content: content, displayMode: displayMode }); } // Read file content const content = await fs.readFile(file.path, 'utf8'); res.json({ success: true, file: file, content: content, displayMode: displayMode }); } catch (error) { console.error('Error getting revised file content:', error); res.status(500).json({ success: false, error: 'Failed to get file content', details: error.message }); } }); // Get revised file info endpoint app.get('/api/revised-files/:fileId/info', requireAuth, async (req, res) => { try { const fileId = req.params.fileId; const revisedFiles = req.session.revisedFiles || []; const file = revisedFiles.find(f => f.id === fileId); if (!file) { // Try to load from persistent storage const persistentRevisedFiles = await loadRevisedFiles(req.session.userId); const persistentFile = persistentRevisedFiles.find(f => f.id === fileId); if (!persistentFile) { return res.status(404).json({ success: false, error: 'File not found' }); } return res.json({ success: true, file: persistentFile }); } res.json({ success: true, file: file }); } catch (error) { console.error('Error getting revised file info:', error); res.status(500).json({ success: false, error: 'Failed to get file info', details: error.message }); } }); // Render revised notes content endpoint app.post('/api/render-revised-content', requireAuth, async (req, res) => { try { const { content, displayMode = 'markdown', autoDetect = true } = req.body; if (!content) { return res.json({ success: false, error: 'No content provided' }); } let renderedContent = ''; let detectedFormat = 'text'; let isMarkdownContent = false; // Auto-detect if content is markdown (if autoDetect is enabled) if (autoDetect) { isMarkdownContent = isLikelyMarkdown(content); detectedFormat = isMarkdownContent ? 'markdown' : 'text'; } // Process content based on display mode switch (displayMode) { case 'html': if (isMarkdownContent || autoDetect === false) { // Convert markdown to safe HTML renderedContent = await markdownToSafeHtml(content); } else { // Just escape HTML and preserve line breaks for plain text renderedContent = escapeHtml(content).replace(/\n/g, '
'); } break; case 'markdown': case 'raw': default: // Return raw content (for markdown view or plain text) renderedContent = content; break; } res.json({ success: true, renderedContent: renderedContent, displayMode: displayMode, detectedFormat: detectedFormat, isMarkdownContent: isMarkdownContent }); } catch (error) { console.error('Error rendering content:', error); res.status(500).json({ success: false, error: 'Failed to render content', details: error.message }); } }); // Get file preview endpoint (simplified for dashboard) 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) { // Try to load from persistent storage try { const persistentFiles = await loadUserFiles(req.session.userId); const persistentFile = persistentFiles.find(f => f.id === fileId); if (!persistentFile) { return res.status(404).json({ success: false, error: 'File not found' }); } // Process the persistent file const filePath = path.join(__dirname, persistentFile.path); const fileExtension = path.extname(persistentFile.originalName).toLowerCase(); // Check if file exists if (!await fs.pathExists(filePath)) { return res.json({ success: true, file: { id: persistentFile.id, originalName: persistentFile.originalName, size: persistentFile.size, uploadDate: persistentFile.uploadDate, content: 'File not found on disk', previewType: 'error', message: `File "${persistentFile.originalName}" was uploaded but the physical file is no longer available on disk. Please re-upload the file.` } }); } // Try to extract text from the document const extractionResult = await extractTextFromDocument(filePath, fileExtension); if (extractionResult.success) { const extractedText = extractionResult.text; const previewContent = extractedText.length > 5000 ? extractedText.substring(0, 5000) + '\n\n... (content truncated for preview)' : extractedText; return res.json({ success: true, file: { id: persistentFile.id, originalName: persistentFile.originalName, size: persistentFile.size, uploadDate: persistentFile.uploadDate, content: previewContent, previewType: 'extracted-text', extractionInfo: { method: extractionResult.method, totalLength: extractionResult.extractedLength, pages: extractionResult.pages || null, sheets: extractionResult.sheets || null, truncated: extractedText.length > 5000 }, message: `Text successfully extracted from ${fileExtension.toUpperCase()} file. ${extractedText.length > 5000 ? 'Preview truncated to first 5000 characters.' : ''}` } }); } else { return res.json({ success: true, file: { id: persistentFile.id, originalName: persistentFile.originalName, size: persistentFile.size, uploadDate: persistentFile.uploadDate, content: 'Preview not available for this file type. File content is available for AI processing.', previewType: 'extraction-failed', message: `Failed to extract text from ${fileExtension.toUpperCase()} file: ${extractionResult.error}. The file has been uploaded and may still be usable for AI processing.` } }); } } catch (error) { console.error('Error loading from persistent storage:', error); return res.status(404).json({ success: false, error: 'File not found' }); } } const filePath = path.join(__dirname, file.path); const fileExtension = path.extname(file.originalName).toLowerCase(); // Check if file exists if (!await fs.pathExists(filePath)) { return res.json({ success: true, file: { id: file.id, originalName: file.originalName, size: file.size, uploadDate: file.uploadDate, content: 'File not found on disk', previewType: 'error', message: `File "${file.originalName}" was uploaded but the physical file is no longer available on disk. Please re-upload the file.` } }); } // Try to extract text from the document const extractionResult = await extractTextFromDocument(filePath, fileExtension); if (extractionResult.success) { // Successfully extracted text const extractedText = extractionResult.text; // Limit preview to first 5000 characters to avoid huge responses const previewContent = extractedText.length > 5000 ? extractedText.substring(0, 5000) + '\n\n... (content truncated for preview)' : extractedText; return res.json({ success: true, file: { id: file.id, originalName: file.originalName, size: file.size, uploadDate: file.uploadDate, content: previewContent, previewType: 'extracted-text', extractionInfo: { method: extractionResult.method, totalLength: extractionResult.extractedLength, pages: extractionResult.pages || null, sheets: extractionResult.sheets || null, truncated: extractedText.length > 5000 }, message: `Text successfully extracted from ${fileExtension.toUpperCase()} file. ${extractedText.length > 5000 ? 'Preview truncated to first 5000 characters.' : ''}` } }); } else { // Failed to extract text, fall back to file type detection const textFormats = ['.txt', '.md', '.json', '.js', '.html', '.css', '.xml', '.csv']; if (textFormats.includes(fileExtension)) { // Try reading as plain text (should have been handled by extraction, but fallback) try { const fileContent = await fs.readFile(filePath, 'utf-8'); const previewContent = fileContent.length > 5000 ? fileContent.substring(0, 5000) + '\n\n... (truncated)' : fileContent; return res.json({ success: true, file: { id: file.id, originalName: file.originalName, size: file.size, uploadDate: file.uploadDate, content: previewContent, previewType: 'text' } }); } catch (readError) { return res.json({ success: true, file: { id: file.id, originalName: file.originalName, size: file.size, uploadDate: file.uploadDate, content: 'File preview not available', previewType: 'error', message: `Error reading ${fileExtension.toUpperCase()} file: ${readError.message}` } }); } } else { // Binary format that couldn't be processed return res.json({ success: true, file: { id: file.id, originalName: file.originalName, size: file.size, uploadDate: file.uploadDate, content: 'Preview not available for this file type. File content is available for AI processing.', previewType: 'extraction-failed', message: `Failed to extract text from ${fileExtension.toUpperCase()} file: ${extractionResult.error}. The file has been uploaded and may still be usable for AI processing.` } }); } } } catch (error) { console.error('Error getting file preview:', error); res.status(500).json({ success: false, error: 'Failed to get file preview', details: error.message }); } }); // Get file preview content endpoint (for notes and revisions) app.get('/api/files/:fileId/preview-content', requireAuth, async (req, res) => { try { const fileId = req.params.fileId; const { displayMode = 'markdown' } = req.query; const files = req.session.uploadedFiles || []; const file = files.find(f => f.id === fileId); if (!file) { // Try to load from persistent storage try { const persistentFiles = await loadUserFiles(req.session.userId); const persistentFile = persistentFiles.find(f => f.id === fileId); if (!persistentFile) { return res.status(404).json({ success: false, error: 'File not found' }); } // Process the persistent file const filePath = path.join(__dirname, persistentFile.path); const fileExtension = path.extname(persistentFile.originalName).toLowerCase(); // Check if file exists if (!await fs.pathExists(filePath)) { return res.json({ success: true, file: { id: persistentFile.id, originalName: persistentFile.originalName, size: persistentFile.size, uploadDate: persistentFile.uploadDate, content: 'File not found on disk', previewType: 'error', message: `File "${persistentFile.originalName}" was uploaded but the physical file is no longer available on disk. Please re-upload the file.` } }); } // Try to extract text from the document const extractionResult = await extractTextFromDocument(filePath, fileExtension); if (extractionResult.success) { const extractedText = extractionResult.text; const previewContent = extractedText.length > 5000 ? extractedText.substring(0, 5000) + '\n\n... (content truncated for preview)' : extractedText; return res.json({ success: true, file: { id: persistentFile.id, originalName: persistentFile.originalName, size: persistentFile.size, uploadDate: persistentFile.uploadDate, content: previewContent, previewType: 'extracted-text', extractionInfo: { method: extractionResult.method, totalLength: extractionResult.extractedLength, pages: extractionResult.pages || null, sheets: extractionResult.sheets || null, truncated: extractedText.length > 5000 }, message: `Text successfully extracted from ${fileExtension.toUpperCase()} file. ${extractedText.length > 5000 ? 'Preview truncated to first 5000 characters.' : ''}` } }); } else { return res.json({ success: true, file: { id: persistentFile.id, originalName: persistentFile.originalName, size: persistentFile.size, uploadDate: persistentFile.uploadDate, content: 'Preview not available for this file type. File content is available for AI processing.', previewType: 'extraction-failed', message: `Failed to extract text from ${fileExtension.toUpperCase()} file: ${extractionResult.error}. The file has been uploaded and may still be usable for AI processing.` } }); } } catch (error) { console.error('Error loading from persistent storage:', error); return res.status(404).json({ success: false, error: 'File not found' }); } } const filePath = path.join(__dirname, file.path); const fileExtension = path.extname(file.originalName).toLowerCase(); // Check if file exists if (!await fs.pathExists(filePath)) { return res.json({ success: true, file: { id: file.id, originalName: file.originalName, size: file.size, uploadDate: file.uploadDate, content: 'File not found on disk', previewType: 'error', message: `File "${file.originalName}" was uploaded but the physical file is no longer available on disk. Please re-upload the file.` } }); } // Try to extract text from the document const extractionResult = await extractTextFromDocument(filePath, fileExtension); if (extractionResult.success) { // Successfully extracted text const extractedText = extractionResult.text; // Limit preview to first 5000 characters to avoid huge responses const previewContent = extractedText.length > 5000 ? extractedText.substring(0, 5000) + '\n\n... (content truncated for preview)' : extractedText; return res.json({ success: true, file: { id: file.id, originalName: file.originalName, size: file.size, uploadDate: file.uploadDate, content: previewContent, previewType: 'extracted-text', extractionInfo: { method: extractionResult.method, totalLength: extractionResult.extractedLength, pages: extractionResult.pages || null, sheets: extractionResult.sheets || null, truncated: extractedText.length > 5000 }, message: `Text successfully extracted from ${fileExtension.toUpperCase()} file. ${extractedText.length > 5000 ? 'Preview truncated to first 5000 characters.' : ''}` } }); } else { // Failed to extract text, fall back to file type detection const textFormats = ['.txt', '.md', '.json', '.js', '.html', '.css', '.xml', '.csv']; if (textFormats.includes(fileExtension)) { // Try reading as plain text (should have been handled by extraction, but fallback) try { const fileContent = await fs.readFile(filePath, 'utf-8'); const previewContent = fileContent.length > 5000 ? fileContent.substring(0, 5000) + '\n\n... (truncated)' : fileContent; return res.json({ success: true, file: { id: file.id, originalName: file.originalName, size: file.size, uploadDate: file.uploadDate, content: previewContent, previewType: 'text' } }); } catch (readError) { return res.json({ success: true, file: { id: file.id, originalName: file.originalName, size: file.size, uploadDate: file.uploadDate, content: 'File preview not available', previewType: 'error', message: `Error reading ${fileExtension.toUpperCase()} file: ${readError.message}` } }); } } else { // Binary format that couldn't be processed return res.json({ success: true, file: { id: file.id, originalName: file.originalName, size: file.size, uploadDate: file.uploadDate, content: 'Preview not available for this file type. File content is available for AI processing.', previewType: 'extraction-failed', message: `Failed to extract text from ${fileExtension.toUpperCase()} file: ${extractionResult.error}. The file has been uploaded and may still be usable for AI processing.` } }); } } } 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", "explanation": "Photosynthesis is the process by which plants use sunlight, water, and carbon dioxide to produce glucose and oxygen.", "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 with retry logic for better AI responses let quizData; let retryCount = 0; const maxRetries = 2; while (retryCount <= maxRetries) { try { const response = await axios.post(`${FLOWISE_API_URL}/${FLOWISE_CHATFLOW_ID}`, { question: retryCount > 0 ? `${prompt}\n\nIMPORTANT: Please provide ONLY a valid JSON array of questions. Do not include any explanatory text, greetings, or additional commentary. Start your response directly with [ and end with ].` : prompt, history: [] }); const responseText = response.data.text || response.data.answer || response.data; // Check if the AI response looks like it's not following instructions if (responseText.toLowerCase().includes('do you have a question') || responseText.toLowerCase().includes('would you like me to help') || responseText.toLowerCase().includes('something else related') || responseText.toLowerCase().includes('how can i help') || (!responseText.includes('[') && !responseText.includes('{'))) { if (retryCount < maxRetries) { retryCount++; console.log(`AI gave improper response, retrying... (attempt ${retryCount + 1})`); continue; } else { throw new Error('The AI is not responding properly to quiz generation requests. Please try again with a more specific topic or try again later.'); } } // 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) { if (retryCount < maxRetries) { retryCount++; console.log(`Could not find JSON in response, retrying... (attempt ${retryCount + 1})`); continue; } else { throw new Error('Could not find valid JSON in the AI response after multiple attempts. Please try generating the quiz again.'); } } quizData = JSON.parse(jsonString); // Validate the parsed data if (!Array.isArray(quizData) || quizData.length === 0) { if (retryCount < maxRetries) { retryCount++; console.log(`Invalid quiz format received, retrying... (attempt ${retryCount + 1})`); continue; } else { throw new Error('The AI generated an invalid quiz format after multiple attempts. Please try again.'); } } // Validate each question has required fields for (let i = 0; i < quizData.length; i++) { const q = quizData[i]; if (!q.question || (!q.correct && !q.answer)) { if (retryCount < maxRetries) { retryCount++; console.log(`Question ${i + 1} missing required fields, retrying... (attempt ${retryCount + 1})`); continue; } else { throw new Error(`Question ${i + 1} is missing required fields after multiple attempts. Please try generating the quiz again.`); } } } // If we get here, the quiz is valid break; } catch (parseError) { if (retryCount < maxRetries) { retryCount++; console.log(`Parse error occurred, retrying... (attempt ${retryCount + 1}):`, parseError.message); continue; } else { console.error('Quiz parsing error after retries:', parseError); console.error('AI Response was:', response?.data?.text || response?.data?.answer || response?.data || 'No response'); // Return error instead of fallback return res.json({ success: false, error: parseError.message || 'Failed to parse quiz questions from AI response after multiple attempts. Please try again with a different topic or rephrase your request.' }); } } } 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 error instead of fallback quiz res.json({ success: false, error: 'Failed to generate quiz. Please check your connection and try again, or try a different topic.' }); } }); 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]; // Handle different question types - short-answer uses 'answer', others use 'correct' const correctAnswer = question.answer || question.correct; const isCorrect = userAnswer === correctAnswer; if (isCorrect) score++; results.push({ question: question.question, userAnswer: userAnswer, correctAnswer: correctAnswer, 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); } // Revised files storage persistence const REVISED_FILES_DIR = path.join(__dirname, 'data', 'revised-files'); // Ensure revised files directory exists async function ensureRevisedFilesDirectory() { await fs.ensureDir(REVISED_FILES_DIR); } // Save revised files to persistent storage async function saveRevisedFiles(userId, files) { try { await ensureRevisedFilesDirectory(); const revisedFilePath = path.join(REVISED_FILES_DIR, `user-${userId}-revised.json`); await fs.writeJSON(revisedFilePath, files, { spaces: 2 }); } catch (error) { console.error('Error saving revised files:', error); } } // Load revised files from persistent storage async function loadRevisedFiles(userId) { try { await ensureRevisedFilesDirectory(); const revisedFilePath = path.join(REVISED_FILES_DIR, `user-${userId}-revised.json`); if (await fs.pathExists(revisedFilePath)) { const files = await fs.readJSON(revisedFilePath); return files || []; } return []; } catch (error) { console.error('Error loading revised files:', error); return []; } } // Add a revised file to user's persistent storage async function saveRevisedFile(userId, fileInfo) { const revisedFiles = await loadRevisedFiles(userId); revisedFiles.push(fileInfo); await saveRevisedFiles(userId, revisedFiles); } // Remove a revised file from user's persistent storage async function removeRevisedFile(userId, fileId) { const revisedFiles = await loadRevisedFiles(userId); const filteredFiles = revisedFiles.filter(f => f.id !== fileId); await saveRevisedFiles(userId, filteredFiles); } // Quiz results storage persistence const QUIZ_RESULTS_DIR = path.join(__dirname, 'data', 'quiz-results'); // Ensure quiz results directory exists async function ensureQuizResultsDirectory() { await fs.ensureDir(QUIZ_RESULTS_DIR); } // Save quiz results to persistent storage async function saveQuizResults(allResults) { try { await ensureQuizResultsDirectory(); const resultsPath = path.join(QUIZ_RESULTS_DIR, 'quiz-results.json'); await fs.writeJSON(resultsPath, allResults, { spaces: 2 }); } catch (error) { console.error('Error saving quiz results:', error); throw error; } } // Load all quiz results from persistent storage async function loadQuizResults() { try { await ensureQuizResultsDirectory(); const resultsPath = path.join(QUIZ_RESULTS_DIR, 'quiz-results.json'); if (await fs.pathExists(resultsPath)) { return await fs.readJSON(resultsPath); } else { return {}; } } catch (error) { console.error('Error loading quiz results:', error); return {}; } } // Get quiz results for a specific user async function getUserQuizResults(userId) { try { const allResults = await loadQuizResults(); return allResults[userId] || []; } catch (error) { console.error('Error loading user quiz results:', error); return []; } } // Add a quiz result to user's persistent storage async function addQuizResult(userId, quizResult) { try { const allResults = await loadQuizResults(); if (!allResults[userId]) { allResults[userId] = []; } allResults[userId].push(quizResult); await saveQuizResults(allResults); } catch (error) { console.error('Error adding quiz result:', error); throw error; } } // Clear quiz results for a specific user async function clearUserQuizResults(userId) { try { const allResults = await loadQuizResults(); allResults[userId] = []; await saveQuizResults(allResults); } catch (error) { console.error('Error clearing user quiz results:', error); throw error; } } // 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; // Clear quiz history for this user using the dedicated function await clearUserQuizResults(userId); 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}`); }); // Helper function to convert markdown to safe HTML async function markdownToSafeHtml(markdownText) { try { // Initialize marked with dynamic import const markedInstance = await initializeMarked(); // Configure marked options for better security and features markedInstance.setOptions({ gfm: true, // GitHub Flavored Markdown breaks: true, // Convert line breaks to
sanitize: false, // We'll use DOMPurify instead for better control smartLists: true, smartypants: true, highlight: null // No code highlighting to avoid security issues }); // Convert markdown to HTML const rawHtml = markedInstance.parse(markdownText); // Sanitize the HTML with DOMPurify const cleanHtml = DOMPurify.sanitize(rawHtml, { ALLOWED_TAGS: [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'hr', 'strong', 'b', 'em', 'i', 'u', 'mark', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'a', 'img', 'div', 'span' ], ALLOWED_ATTR: [ 'href', 'target', 'rel', 'src', 'alt', 'title', 'class', 'id', 'width', 'height' ], ALLOW_DATA_ATTR: false, FORBID_TAGS: ['script', 'object', 'embed', 'iframe', 'form', 'input'], FORBID_ATTR: ['onclick', 'onload', 'onerror', 'style'], ADD_ATTR: { 'a': { 'target': '_blank', 'rel': 'noopener noreferrer' } } }); return cleanHtml; } catch (error) { console.error('Error converting markdown to HTML:', error); // Return escaped plain text as fallback return escapeHtml(markdownText); } } // Helper function to escape HTML function escapeHtml(text) { const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }; return text.replace(/[&<>"']/g, function(m) { return map[m]; }); } // Helper function to detect if content is likely markdown function isLikelyMarkdown(text) { const markdownIndicators = [ /^#{1,6}\s+.+$/m, // Headers /\*{1,2}[^*]+\*{1,2}/, // Bold/italic /^[\s]*[-*+]\s+/m, // Bullet lists /^\d+\.\s+/m, // Numbered lists /```[\s\S]*?```/, // Code blocks /`[^`]+`/, // Inline code /\[.+\]\(.+\)/, // Links /^\>.+$/m // Blockquotes ]; return markdownIndicators.some(pattern => pattern.test(text)); } // Chat history storage persistence const CHAT_HISTORY_DIR = path.join(__dirname, 'data', 'chat-history'); // Ensure chat history directory exists async function ensureChatHistoryDirectory() { await fs.ensureDir(CHAT_HISTORY_DIR); } // Save chat history to persistent storage async function saveChatHistory(userId, chatHistory) { try { console.log(`Saving chat history for user ${userId}, ${chatHistory.length} messages`); await ensureChatHistoryDirectory(); const historyPath = path.join(CHAT_HISTORY_DIR, `chat-${userId}.json`); await fs.writeJSON(historyPath, chatHistory, { spaces: 2 }); console.log(`Chat history saved successfully to ${historyPath}`); } catch (error) { console.error('Error saving chat history:', error); throw error; } } // Load chat history from persistent storage async function loadChatHistory(userId) { try { console.log(`Loading chat history for user ${userId}`); await ensureChatHistoryDirectory(); const historyPath = path.join(CHAT_HISTORY_DIR, `chat-${userId}.json`); if (await fs.pathExists(historyPath)) { const history = await fs.readJSON(historyPath); console.log(`Loaded ${history.length} chat messages for user ${userId}`); return history; } else { console.log(`No chat history file found for user ${userId}`); return []; } } catch (error) { console.error('Error loading chat history:', error); return []; } } // Clear chat history for a user async function clearChatHistory(userId) { try { await ensureChatHistoryDirectory(); const historyPath = path.join(CHAT_HISTORY_DIR, `chat-${userId}.json`); if (await fs.pathExists(historyPath)) { await fs.unlink(historyPath); } } catch (error) { console.error('Error clearing chat history:', error); throw error; } } // Initialize directory structures on startup async function initializeDataDirectories() { await ensureUserFilesDirectory(); await ensureRevisedFilesDirectory(); await ensureQuizResultsDirectory(); await ensureChatHistoryDirectory(); } // Call initialization initializeDataDirectories().catch(console.error); initializeDataDirectories().catch(console.error);