2924 lines
90 KiB
JavaScript
2924 lines
90 KiB
JavaScript
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, '<br>');
|
|
}
|
|
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 <br>
|
|
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);
|
|
|
|
|
|
|
|
|