Files
EduCatWeb/server.js

1871 lines
54 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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) <!-- comment -->', '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}`);
});