document.addEventListener('DOMContentLoaded', function() {
// DOM Elements
const conversationEl = document.getElementById('conversation');
const promptInputEl = document.getElementById('promptInput');
const actionBtnEl = document.getElementById('actionBtn');
const downloadBtnEl = document.getElementById('downloadBtn');
const notebookPreviewEl = document.getElementById('notebookPreview');
const notebookTitleEl = document.getElementById('notebookTitle');
const apiKeyInputEl = document.getElementById('apiKeyInput');
const saveApiKeyBtnEl = document.getElementById('saveApiKeyBtn');
const apiKeyFeedbackEl = document.getElementById('apiKeyFeedback');
const modelSelectEl = document.getElementById('modelSelect');
const generateModeBtnEl = document.getElementById('generateModeBtn');
const editModeBtnEl = document.getElementById('editModeBtn');
// Create scroll-to-bottom button
const scrollToBottomBtn = document.createElement('button');
scrollToBottomBtn.className = 'scroll-to-bottom';
scrollToBottomBtn.innerHTML = '';
scrollToBottomBtn.title = 'Scroll to bottom';
document.body.appendChild(scrollToBottomBtn);
// Scroll to bottom button event listener
scrollToBottomBtn.addEventListener('click', function() {
notebookPreviewEl.scrollTo({
top: notebookPreviewEl.scrollHeight,
behavior: 'smooth'
});
});
// Show/hide scroll to bottom button based on scroll position
notebookPreviewEl.addEventListener('scroll', function() {
const scrollPosition = notebookPreviewEl.scrollTop + notebookPreviewEl.clientHeight;
const scrollThreshold = notebookPreviewEl.scrollHeight - 100;
if (scrollPosition < scrollThreshold) {
scrollToBottomBtn.classList.add('visible');
} else {
scrollToBottomBtn.classList.remove('visible');
}
});
// Global state
let currentNotebook = null;
let eventSource = null;
let aiResponseText = '';
let currentMode = 'generate'; // 'generate' or 'edit'
// Add a debounce flag to prevent button spamming
let isActionInProgress = false;
// Initialize tooltips
const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function(tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Check if we have an API key in localStorage on page load
const savedApiKey = localStorage.getItem('notegenie_api_key');
if (savedApiKey) {
// Try to set the API key automatically
apiKeyInputEl.value = savedApiKey;
checkAndSetApiKey();
}
// Event Listeners
saveApiKeyBtnEl.addEventListener('click', saveApiKey);
actionBtnEl.addEventListener('click', handleAction);
downloadBtnEl.addEventListener('click', downloadNotebook);
generateModeBtnEl.addEventListener('click', () => setMode('generate'));
editModeBtnEl.addEventListener('click', () => setMode('edit'));
// Allow Enter key to submit prompt (Shift+Enter for new line)
promptInputEl.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAction();
}
});
// Set mode (generate or edit)
function setMode(mode) {
currentMode = mode;
if (mode === 'generate') {
generateModeBtnEl.classList.add('active');
generateModeBtnEl.classList.remove('btn-outline-primary');
generateModeBtnEl.classList.add('btn-primary');
editModeBtnEl.classList.remove('active');
editModeBtnEl.classList.remove('btn-primary');
editModeBtnEl.classList.add('btn-outline-primary');
promptInputEl.placeholder = 'Describe the notebook you want...';
} else { // edit mode
editModeBtnEl.classList.add('active');
editModeBtnEl.classList.remove('btn-outline-primary');
editModeBtnEl.classList.add('btn-primary');
generateModeBtnEl.classList.remove('active');
generateModeBtnEl.classList.remove('btn-primary');
generateModeBtnEl.classList.add('btn-outline-primary');
promptInputEl.placeholder = 'Describe what changes you want to make to the notebook...';
promptInputEl.focus();
}
}
// Handle action button click based on current mode
function handleAction() {
// Prevent multiple rapid clicks
if (isActionInProgress) return;
// Check if API key is set
const apiKey = localStorage.getItem('notegenie_api_key');
if (!apiKey) {
addSystemMessage(' API key is not set. Please set your API key first.');
showApiKeyModal();
return;
}
// Get the prompt text and check if it's empty
const prompt = promptInputEl.value.trim();
if (currentMode === 'generate') {
if (!prompt) {
addSystemMessage(' Please enter a prompt to generate a notebook.');
return;
}
// Set the debounce flag and disable the button
isActionInProgress = true;
actionBtnEl.disabled = true;
generateNotebook();
} else {
if (!prompt || !currentNotebook) {
if (!currentNotebook) {
addSystemMessage(' No notebook to edit. Generate a notebook first.');
} else if (!prompt) {
addSystemMessage(' Please describe the changes you want to make.');
}
return;
}
// Set the debounce flag and disable the button
isActionInProgress = true;
actionBtnEl.disabled = true;
editNotebook();
}
}
// API Key handling
function checkAndSetApiKey() {
const apiKey = apiKeyInputEl.value.trim();
if (!apiKey) {
return;
}
fetch('/set_api_key', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
'api_key': apiKey
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Store API key in localStorage as backup
localStorage.setItem('notegenie_api_key', apiKey);
if (document.querySelector('#apiKeyModal.show')) {
showApiKeyFeedback('API key saved successfully!', 'success');
setTimeout(() => {
document.querySelector('#apiKeyModal .btn-close').click();
}, 1500);
}
} else {
showApiKeyFeedback(`Error: ${data.message}`, 'danger');
localStorage.removeItem('notegenie_api_key'); // Remove invalid key
}
})
.catch(error => {
showApiKeyFeedback(`Error: ${error.message}`, 'danger');
localStorage.removeItem('notegenie_api_key'); // Remove key on error
});
}
function saveApiKey() {
checkAndSetApiKey();
}
function showApiKeyFeedback(message, type) {
apiKeyFeedbackEl.innerHTML = `
${message}
`;
setTimeout(() => {
apiKeyFeedbackEl.innerHTML = '';
}, 5000);
}
// Notebook generation
function generateNotebook() {
const prompt = promptInputEl.value.trim();
if (!prompt) {
addSystemMessage(' Please enter a prompt to generate a notebook.');
return;
}
// Clear any existing notebook
notebookPreviewEl.innerHTML = '';
downloadBtnEl.disabled = true;
editModeBtnEl.disabled = true;
currentNotebook = null;
// Add user message to conversation
addUserMessage(prompt);
// Disable input during generation
setGeneratingState(true);
const modelName = modelSelectEl.value;
// Add AI typing indicator
const aiMessageId = 'ai-typing-' + Date.now();
addTypingIndicator(aiMessageId);
// Always use streaming for better UX
handleStreamingResponse(prompt, modelName, aiMessageId);
// Clear the input field after sending
promptInputEl.value = '';
}
// Notebook editing
function editNotebook() {
const editPrompt = promptInputEl.value.trim();
if (!editPrompt || !currentNotebook) {
if (!currentNotebook) {
addSystemMessage(' No notebook to edit. Generate a notebook first.');
} else {
addSystemMessage(' Please describe the changes you want to make.');
}
return;
}
// Add user message to conversation
addUserMessage('Edit request: ' + editPrompt);
// Disable input during editing
setGeneratingState(true);
const modelName = modelSelectEl.value;
// Add AI typing indicator
const aiMessageId = 'ai-typing-' + Date.now();
addTypingIndicator(aiMessageId);
// Update AI message to show we're starting the edit
updateAiMessage(aiMessageId, "**NoteGenie:** Starting to edit your notebook...");
handleEditStreamingResponse(editPrompt, currentNotebook, modelName, aiMessageId);
// Clear the input field after sending
promptInputEl.value = '';
}
// Create a custom EventSource that supports headers
function createEventSourceWithHeaders(url, headers) {
if (typeof EventSourcePolyfill !== 'undefined') {
return new EventSourcePolyfill(url, { headers: headers });
}
// If native EventSource is all we have but we need headers,
// we fall back to using URL parameters for authentication
return new EventSource(url);
}
function handleStreamingResponse(prompt, modelName, aiMessageId) {
aiResponseText = '';
// Clear any existing notebook preview and add loading indicator
notebookPreviewEl.innerHTML = 'Building notebook preview...
';
// Close any existing event source
if (eventSource) {
eventSource.close();
}
// Keep track of when we should update the preview (not on every tiny chunk)
let lastPreviewUpdate = 0;
const PREVIEW_UPDATE_INTERVAL = 1000; // Update preview every 1 second during streaming
// Track the last time we got a chunk - for timeout detection
let lastChunkTime = Date.now();
const CONNECTION_TIMEOUT = 30000; // 30 seconds without data = timeout
// Start a monitoring timer to detect stalled connections
const connectionTimer = setInterval(() => {
if (Date.now() - lastChunkTime > CONNECTION_TIMEOUT) {
clearInterval(connectionTimer);
if (eventSource) {
console.log("Connection timed out - closing event source");
eventSource.close();
eventSource = null;
// Process what we've got so far
updateAiMessage(aiMessageId, "**NoteGenie:** Generation timed out, but I'll process what I've received so far.");
processNotebookResponse(aiResponseText);
setGeneratingState(false);
}
}
}, 5000); // Check every 5 seconds
// Get API key from localStorage as a backup
const backupApiKey = localStorage.getItem('notegenie_api_key');
// Create URL parameters including API key as fallback
const urlParams = new URLSearchParams({
prompt: prompt,
model: modelName,
stream: true
});
// Add API key to URL params if available from localStorage (as a fallback)
if (backupApiKey) {
urlParams.append('api_key', backupApiKey);
}
// Set headers with API key if available
const headers = {};
if (backupApiKey) {
headers['X-API-Key'] = backupApiKey;
}
// Create a new event source with API key included in both URL and headers
eventSource = createEventSourceWithHeaders(`/generate_notebook?${urlParams.toString()}`, headers);
eventSource.onmessage = function(event) {
// Update our last-activity timestamp
lastChunkTime = Date.now();
try {
const data = JSON.parse(event.data);
// Handle errors sent from the server
if (data.error) {
console.error("Server error:", data.error);
updateAiMessage(aiMessageId, `**Error:** ${data.error}`);
// Try to salvage what we have so far
if (aiResponseText && (aiResponseText.includes('MARKDOWN CELL') || aiResponseText.includes('CODE CELL'))) {
processNotebookResponse(aiResponseText);
}
eventSource.close();
eventSource = null;
setGeneratingState(false);
clearInterval(connectionTimer);
return;
}
if (data.chunk) {
aiResponseText += data.chunk;
// Extract notebook info as soon as it's available
const nameMatch = aiResponseText.match(/NOTEBOOK_NAME:?\s*(.+?)(?:\n|$)/);
const descMatch = aiResponseText.match(/NOTEBOOK_DESCRIPTION:?\s*(.+?)(?:\n|$)/);
if (nameMatch && nameMatch[1].trim()) {
// Update notebook title immediately when found
notebookTitleEl.textContent = nameMatch[1].trim();
}
if (descMatch && descMatch[1].trim()) {
// Update AI message to only display the description, not the full response
updateAiMessage(aiMessageId, `**NoteGenie:** ${descMatch[1].trim()}`);
} else {
// Show a simple generating message while waiting for description
updateAiMessage(aiMessageId, "**NoteGenie:** Generating your notebook...");
}
// Update preview periodically during streaming
const now = Date.now();
if (now - lastPreviewUpdate > PREVIEW_UPDATE_INTERVAL) {
lastPreviewUpdate = now;
// Only try to update preview if we have meaningful content
if (aiResponseText.includes('MARKDOWN CELL') || aiResponseText.includes('CODE CELL')) {
updateNotebookPreviewDuringStream(aiResponseText);
}
}
}
if (data.done) {
eventSource.close();
eventSource = null;
clearInterval(connectionTimer);
// Process the complete response for final rendering
processNotebookResponse(aiResponseText);
setGeneratingState(false);
}
} catch (error) {
console.error('Error parsing event data:', error, event.data);
}
};
eventSource.onerror = function(err) {
console.error('EventSource error:', err);
eventSource.close();
eventSource = null;
clearInterval(connectionTimer);
// Check if it's an auth error (most likely API key not set)
if (err.status === 401) {
updateAiMessage(aiMessageId, '**Error: API key not set or invalid.** \n\nPlease click the API Key button in the top right corner to set your Google Gemini API key.');
showApiKeyModal();
} else {
// Try to salvage what we have so far
if (aiResponseText && (aiResponseText.includes('MARKDOWN CELL') || aiResponseText.includes('CODE CELL'))) {
updateAiMessage(aiMessageId, '**Warning:** Connection issue occurred but I\'ll try to process what I received so far.');
processNotebookResponse(aiResponseText);
} else {
updateAiMessage(aiMessageId, '**Error:** Failed to generate notebook. Please try again.');
}
}
setGeneratingState(false);
};
}
function handleEditStreamingResponse(editPrompt, notebook, modelName, aiMessageId) {
aiResponseText = '';
// Clear any existing notebook preview and add loading indicator
notebookPreviewEl.innerHTML = 'Updating notebook based on your edit request...
';
// Close any existing event source
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Keep track of when we should update the preview (not on every tiny chunk)
let lastPreviewUpdate = 0;
const PREVIEW_UPDATE_INTERVAL = 1000; // Update preview every 1 second during streaming, same as generate
// Get API key from localStorage as a backup
const backupApiKey = localStorage.getItem('notegenie_api_key');
// Make sure notebook is in proper format before sending
const notebookToSend = typeof notebook === 'string' ? JSON.parse(notebook) : notebook;
// Debug logging to help diagnose issues
console.log('Notebook edit request:', {
cellCount: notebookToSend.cells.length,
promptLength: editPrompt.length,
model: modelName
});
// First, send the notebook data to the server so it's available in the session
fetch('/prepare_edit_notebook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// Add API key as header if available
...(backupApiKey && {'X-API-Key': backupApiKey})
},
body: JSON.stringify({
notebook: notebookToSend
})
})
.then(response => {
if (!response.ok) {
throw new Error(`Server error (${response.status}): ${response.statusText}`);
}
return response.json();
})
.then(data => {
if (data.success) {
updateAiMessage(aiMessageId, "**NoteGenie:** Processing your edit request...");
// Create URL parameters including API key as fallback
const urlParams = new URLSearchParams({
edit_prompt: editPrompt,
model: modelName,
stream: true
});
// Add API key to URL params if available from localStorage (as a fallback)
if (backupApiKey) {
urlParams.append('api_key', backupApiKey);
}
// Set headers with API key if available
const headers = {};
if (backupApiKey) {
headers['X-API-Key'] = backupApiKey;
}
console.log('Starting EventSource for edit with headers:', Object.keys(headers).length > 0 ? 'Headers present' : 'No headers');
// Now create a new event source for editing with the API key included in both URL and headers
try {
eventSource = createEventSourceWithHeaders(`/edit_notebook?${urlParams.toString()}`, headers);
// Track the last time we got a chunk - for timeout detection
let lastChunkTime = Date.now();
const CONNECTION_TIMEOUT = 30000; // 30 seconds without data = timeout
// Start a monitoring timer to detect stalled connections
const connectionTimer = setInterval(() => {
if (Date.now() - lastChunkTime > CONNECTION_TIMEOUT) {
clearInterval(connectionTimer);
if (eventSource) {
console.log("Connection timed out - closing event source");
eventSource.close();
eventSource = null;
// Process what we've got so far
updateAiMessage(aiMessageId, "**NoteGenie:** Edit processing timed out, but I'll use what I've received so far.");
processNotebookResponse(aiResponseText);
setGeneratingState(false);
// Switch back to generate mode
setMode('generate');
}
}
}, 5000); // Check every 5 seconds
eventSource.onopen = function(event) {
console.log('EventSource connection opened for edit');
};
eventSource.onmessage = function(event) {
// Update our last-activity timestamp
lastChunkTime = Date.now();
try {
const data = JSON.parse(event.data);
// Handle errors sent from the server
if (data.error) {
console.error("Server error during edit:", data.error);
updateAiMessage(aiMessageId, `**Error:** ${data.error}`);
// Try to salvage what we have so far
if (aiResponseText && (aiResponseText.includes('MARKDOWN CELL') || aiResponseText.includes('CODE CELL'))) {
processNotebookResponse(aiResponseText);
}
eventSource.close();
eventSource = null;
setGeneratingState(false);
clearInterval(connectionTimer);
// Switch back to generate mode
setMode('generate');
return;
}
if (data.chunk) {
aiResponseText += data.chunk;
// Extract notebook info as soon as it's available, similar to generate function
const nameMatch = aiResponseText.match(/NOTEBOOK_NAME:?\s*(.+?)(?:\n|$)/);
const descMatch = aiResponseText.match(/NOTEBOOK_DESCRIPTION:?\s*(.+?)(?:\n|$)/);
if (nameMatch && nameMatch[1].trim()) {
// Update notebook title immediately when found
notebookTitleEl.textContent = nameMatch[1].trim();
}
if (descMatch && descMatch[1].trim()) {
// Update AI message with the actual description from the edited notebook
updateAiMessage(aiMessageId, `**NoteGenie:** ${descMatch[1].trim()}`);
} else {
// Show a simple editing message while waiting for description
updateAiMessage(aiMessageId, "**NoteGenie:** Updating notebook based on your edit request...");
}
// Update preview periodically during streaming, just like in generate function
const now = Date.now();
if (now - lastPreviewUpdate > PREVIEW_UPDATE_INTERVAL) {
lastPreviewUpdate = now;
// Only try to update preview if we have meaningful content
if (aiResponseText.includes('MARKDOWN CELL') || aiResponseText.includes('CODE CELL')) {
updateNotebookPreviewDuringStream(aiResponseText);
}
}
}
if (data.done) {
console.log('Edit completed successfully');
eventSource.close();
eventSource = null;
clearInterval(connectionTimer);
// Process the complete response for final rendering
processNotebookResponse(aiResponseText);
setGeneratingState(false);
// Get the final description for the AI message
const finalDescMatch = aiResponseText.match(/NOTEBOOK_DESCRIPTION:?\s*(.+?)(?=\n\s*---|\n\s*$|$)/);
const finalDesc = finalDescMatch ? finalDescMatch[1].trim() : "I've updated the notebook based on your edit request.";
// Update AI message with final description
updateAiMessage(aiMessageId, `**NoteGenie:** ${finalDesc}`);
// Switch back to generate mode
setMode('generate');
}
} catch (error) {
console.error('Error parsing event data:', error, event.data);
}
};
eventSource.onerror = function(err) {
// More detailed error logging
console.error('EventSource error during edit:', err);
// Get any status text or error message if available
let errorMessage = 'Failed to connect to edit service';
if (err instanceof Event && err.target && err.target.status) {
errorMessage += ` (Status: ${err.target.status})`;
}
// Close and clean up event source
if (eventSource) {
eventSource.close();
eventSource = null;
}
// Clear any timers
clearInterval(connectionTimer);
// Try to salvage what we have so far
if (aiResponseText && (aiResponseText.includes('MARKDOWN CELL') || aiResponseText.includes('CODE CELL'))) {
updateAiMessage(aiMessageId, '**Warning:** Connection issue occurred but I\'ll try to process what I received so far.');
processNotebookResponse(aiResponseText);
} else {
updateAiMessage(aiMessageId, `**Error:** Failed to edit notebook. ${errorMessage}`);
}
setGeneratingState(false);
// Switch back to generate mode
setMode('generate');
};
} catch (e) {
console.error('Error creating EventSource:', e);
updateAiMessage(aiMessageId, `**Error:** Could not establish connection for editing: ${e.message}`);
setGeneratingState(false);
}
} else {
throw new Error(data.message || 'Failed to prepare notebook for editing');
}
})
.catch(error => {
console.error('Error preparing notebook for edit:', error);
updateAiMessage(aiMessageId, '**Error:** ' + error.message);
notebookPreviewEl.innerHTML = 'Error: Failed to prepare notebook for editing. Please try regenerating the notebook.
';
setGeneratingState(false);
});
}
// Show API key modal
function showApiKeyModal() {
const apiKeyModal = new bootstrap.Modal(document.getElementById('apiKeyModal'));
apiKeyModal.show();
}
// Helper functions
function updateNotebookPreviewDuringStream(text) {
try {
// Extract notebook info for title
const nameMatch = text.match(/NOTEBOOK_NAME:?\s*(.+?)(?:\n|$)/);
const name = nameMatch ? nameMatch[1].trim() : 'Generating Notebook...';
// Update notebook title
notebookTitleEl.textContent = name;
// Find all cell markers in order of appearance
const cellMarkers = [...text.matchAll(/---\s*(MARKDOWN|CODE)\s*CELL\s*---/g)];
// If no markers found, don't update preview yet
if (cellMarkers.length === 0) return;
// Check if user is already near the bottom before deciding to auto-scroll
const isNearBottom = notebookPreviewEl.scrollHeight - notebookPreviewEl.scrollTop - notebookPreviewEl.clientHeight < 100;
// Initialize cell counters
let markdownCellCount = 0;
let codeCellCount = 0;
// Clear preview and prepare for new content
notebookPreviewEl.innerHTML = '';
// Process each cell in order
for (let i = 0; i < cellMarkers.length; i++) {
const currentMarker = cellMarkers[i];
const nextMarker = cellMarkers[i + 1];
const cellType = currentMarker[1]; // MARKDOWN or CODE
// Extract cell content between current marker and next marker (or end of text)
const markerEndIndex = currentMarker.index + currentMarker[0].length;
const contentEndIndex = nextMarker ? nextMarker.index : text.length;
let cellContent = text.substring(markerEndIndex, contentEndIndex).trim();
if (cellType === 'MARKDOWN') {
markdownCellCount++;
createMarkdownCell(cellContent, markdownCellCount, notebookPreviewEl);
} else if (cellType === 'CODE') {
// Extract Python code from between ```python and ```
const codeMatch = cellContent.match(/```python\s*([\s\S]*?)```/);
if (codeMatch) {
codeCellCount++;
createCodeCell(codeMatch[1].trim(), codeCellCount, notebookPreviewEl);
}
}
}
// Only auto-scroll if user was already near the bottom
if (isNearBottom) {
notebookPreviewEl.scrollTop = notebookPreviewEl.scrollHeight;
} else {
// Show the scroll to bottom button
scrollToBottomBtn.classList.add('visible');
}
} catch (error) {
console.error('Error updating preview during stream:', error);
// Don't update if there's an error - wait for complete response
}
}
function createMarkdownCell(content, cellNumber, container) {
const cellDiv = document.createElement('div');
cellDiv.className = 'notebook-cell cell-markdown';
// Add header for markdown cell
const headerDiv = document.createElement('div');
headerDiv.className = 'cell-header markdown-header';
headerDiv.innerHTML = `Markdown [${cellNumber}]`;
cellDiv.appendChild(headerDiv);
// Add markdown content
const contentDiv = document.createElement('div');
contentDiv.className = 'cell-content';
contentDiv.innerHTML = marked.parse(content);
cellDiv.appendChild(contentDiv);
container.appendChild(cellDiv);
}
function createCodeCell(content, cellNumber, container) {
const cellDiv = document.createElement('div');
cellDiv.className = 'notebook-cell cell-code';
// Add header for code cell
const headerDiv = document.createElement('div');
headerDiv.className = 'cell-header code-header';
headerDiv.innerHTML = `Code [${cellNumber}]`;
cellDiv.appendChild(headerDiv);
// Add code content
const contentDiv = document.createElement('div');
contentDiv.className = 'cell-content';
const preEl = document.createElement('pre');
const codeEl = document.createElement('code');
codeEl.className = 'language-python';
codeEl.textContent = content;
preEl.appendChild(codeEl);
contentDiv.appendChild(preEl);
cellDiv.appendChild(contentDiv);
container.appendChild(cellDiv);
// Highlight syntax
Prism.highlightElement(codeEl);
}
function processNotebookResponse(text) {
// Extract notebook info using improved regex patterns that handle markdown better
const nameMatch = text.match(/NOTEBOOK_NAME:?\s*(.+?)(?=\n\s*NOTEBOOK_DESCRIPTION|\n\s*---|\n\s*$|$)/);
const descMatch = text.match(/NOTEBOOK_DESCRIPTION:?\s*(.+?)(?=\n\s*---|\n\s*$|$)/);
// Clean up the name and description
let name = nameMatch ? nameMatch[1].trim() : 'Generated Notebook';
let description = descMatch ? descMatch[1].trim() : '';
// Remove markdown formatting
name = name.replace(/\*\*/g, '').replace(/\*/g, '');
description = description.replace(/\*\*/g, '').replace(/\*/g, '');
// Update notebook title
notebookTitleEl.textContent = name;
// Update the AI message with just the description
const chatMessageId = document.querySelector('.ai-message').id;
updateAiMessage(chatMessageId, `**NoteGenie:** ${description}`);
// OPTIMIZATION: Process the notebook client-side if possible for small to medium notebooks
if (text.length < 50000) { // Only try client-side processing for reasonably sized notebooks
try {
// Try to process notebook directly in the client
const notebookJson = clientSideFormatNotebook(text);
renderNotebook(notebookJson);
currentNotebook = notebookJson;
downloadBtnEl.disabled = false;
editModeBtnEl.disabled = false;
return; // Exit if successful
} catch (error) {
console.log("Client-side formatting failed, falling back to server:", error);
// Fall through to server-side processing if client-side fails
}
}
// OPTIMIZATION: Show loading indicator with progress message
notebookPreviewEl.innerHTML = 'Finalizing notebook format...
';
// Make API call to convert text to notebook format with a timeout to prevent UI freezing
setTimeout(() => {
fetch('/generate_notebook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: text, // Send the full AI response as a prompt
model: 'gemini-2.0-flash', // Use a faster model for formatting
stream: false,
format_only: true
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
renderNotebook(data.notebook);
currentNotebook = data.notebook;
downloadBtnEl.disabled = false;
editModeBtnEl.disabled = false;
}
})
.catch(error => {
console.error('Error formatting notebook:', error);
notebookPreviewEl.innerHTML = `Error formatting notebook: ${error.message}
`;
});
}, 10); // Small delay to allow UI to update
}
// ADDED: Client-side notebook formatting function to reduce server dependency
function clientSideFormatNotebook(content) {
// Extract cells with improved regex that captures end of file properly
const markdownCells = content.match(/---\s*MARKDOWN\s*CELL\s*---\s*([\s\S]*?)(?=---\s*(?:MARKDOWN|CODE)\s*CELL\s*---|$)/g) || [];
const codeCells = content.match(/---\s*CODE\s*CELL\s*---\s*```python\s*([\s\S]*?)```/gs) || [];
// Get cell order with improved markers
const cellMarkers = [...content.matchAll(/---\s*(MARKDOWN|CODE)\s*CELL\s*---/g)];
const cellTypes = cellMarkers.map(match => match[1]);
// Initialize notebook structure
const cells = [];
let mdIdx = 0;
let codeIdx = 0;
// Populate cells in the correct order
for (let i = 0; i < cellTypes.length; i++) {
const cellType = cellTypes[i];
if (cellType === 'MARKDOWN' && mdIdx < markdownCells.length) {
// Extract markdown content with improved regex
let md = markdownCells[mdIdx].replace(/---\s*MARKDOWN\s*CELL\s*---/, '').trim();
cells.push({
cell_type: 'markdown',
metadata: {},
source: md.split('\n')
});
mdIdx++;
} else if (cellType === 'CODE' && codeIdx < codeCells.length) {
// Extract code content with improved regex
let codeMatch = codeCells[codeIdx].match(/```python\s*([\s\S]*?)```/s);
let code = codeMatch ? codeMatch[1].trim() : '';
cells.push({
cell_type: 'code',
execution_count: null,
metadata: {},
outputs: [],
source: code.split('\n')
});
codeIdx++;
}
}
// Process the final cell if it wasn't captured above
if (cellMarkers.length > 0) {
const lastMarker = cellMarkers[cellMarkers.length - 1];
const lastMarkerEndIndex = lastMarker.index + lastMarker[0].length;
// If there's content after the last marker, process it
if (lastMarkerEndIndex < content.length) {
const lastCellType = lastMarker[1]; // MARKDOWN or CODE
const lastCellContent = content.substring(lastMarkerEndIndex).trim();
// Only add if not already captured
const isAlreadyCaptured =
(lastCellType === 'MARKDOWN' && mdIdx >= markdownCells.length) ||
(lastCellType === 'CODE' && codeIdx >= codeCells.length);
if (!isAlreadyCaptured && lastCellContent) {
if (lastCellType === 'MARKDOWN') {
cells.push({
cell_type: 'markdown',
metadata: {},
source: lastCellContent.split('\n')
});
} else if (lastCellType === 'CODE') {
// Extract code if present
const codeMatch = lastCellContent.match(/```python\s*([\s\S]*?)```/s);
if (codeMatch) {
cells.push({
cell_type: 'code',
execution_count: null,
metadata: {},
outputs: [],
source: codeMatch[1].trim().split('\n')
});
}
}
}
}
}
// Fallback approach if no cells were found or if the extraction didn't work
// ...existing code...
return {
cells: cells,
metadata: {
kernelspec: {
display_name: 'Python 3',
language: 'python',
name: 'python3'
},
language_info: {
name: 'python',
version: '3.8.0'
}
},
nbformat: 4,
nbformat_minor: 4
};
}
function downloadNotebook() {
if (!currentNotebook) {
return;
}
// Improved filename cleaning - remove markdown formatting and unsafe filename characters
let cleanName = notebookTitleEl.textContent || 'generated_notebook';
// First remove markdown formatting characters
cleanName = cleanName.replace(/\*\*/g, '').replace(/\*/g, '').replace(/_/g, ' ');
// Then remove any characters that aren't safe for filenames
cleanName = cleanName.replace(/[^\w\s-]/gi, '').trim();
// Replace multiple spaces with single space
cleanName = cleanName.replace(/\s+/g, ' ');
const filename = cleanName || 'generated_notebook';
fetch('/download_notebook', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
notebook: currentNotebook,
filename: `${filename}.ipynb`
})
})
.then(response => response.blob())
.then(blob => {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.style.display = 'none';
a.href = url;
a.download = `${filename}.ipynb`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
})
.catch(error => {
console.error('Error downloading notebook:', error);
addSystemMessage('Error downloading notebook: ' + error.message);
});
}
function renderNotebook(notebook) {
notebookPreviewEl.innerHTML = '';
if (!notebook || !notebook.cells || !notebook.cells.length) {
notebookPreviewEl.innerHTML = 'No notebook content available
';
return;
}
let cellCount = 0;
notebook.cells.forEach((cell, index) => {
cellCount++;
if (cell.cell_type === 'markdown') {
const content = Array.isArray(cell.source) ? cell.source.join('\n') : cell.source;
createMarkdownCell(content, cellCount, notebookPreviewEl);
} else if (cell.cell_type === 'code') {
const codeContent = Array.isArray(cell.source) ? cell.source.join('\n') : cell.source;
createCodeCell(codeContent, cellCount, notebookPreviewEl);
}
});
// Check if we should scroll to the bottom after rendering
const isNearBottom = notebookPreviewEl.scrollHeight - notebookPreviewEl.scrollTop - notebookPreviewEl.clientHeight < 100;
if (isNearBottom) {
notebookPreviewEl.scrollTop = notebookPreviewEl.scrollHeight;
} else {
// Show the scroll to bottom button
scrollToBottomBtn.classList.add('visible');
}
// Enable edit and download buttons when notebook is available
downloadBtnEl.disabled = false;
editModeBtnEl.disabled = false;
}
// UI Message handling
function addUserMessage(text) {
// Remove welcome message if present
const welcomeMessage = document.querySelector('.welcome-message');
if (welcomeMessage) {
welcomeMessage.remove();
}
const messageDiv = document.createElement('div');
messageDiv.className = 'message user-message';
// Fix: Create a span for the "You:" prefix and handle the text separately
// to prevent marked from adding paragraph tags that cause line breaks
const userPrefix = document.createElement('span');
userPrefix.className = 'user-prefix';
userPrefix.innerHTML = 'You: ';
const messageContent = document.createElement('span');
messageContent.className = 'message-content';
messageContent.innerHTML = marked.parse(text);
// Remove any leading and trailing
tags that marked adds
messageContent.innerHTML = messageContent.innerHTML
.replace(/^/, '')
.replace(/<\/p>$/, '');
messageDiv.appendChild(userPrefix);
messageDiv.appendChild(messageContent);
conversationEl.appendChild(messageDiv);
conversationEl.scrollTop = conversationEl.scrollHeight;
}
function addTypingIndicator(id) {
const messageDiv = document.createElement('div');
messageDiv.className = 'message ai-message';
messageDiv.id = id;
const typingDiv = document.createElement('div');
typingDiv.className = 'typing-indicator';
for (let i = 0; i < 3; i++) {
const dot = document.createElement('span');
dot.className = 'typing-dot';
typingDiv.appendChild(dot);
}
messageDiv.appendChild(typingDiv);
conversationEl.appendChild(messageDiv);
conversationEl.scrollTop = conversationEl.scrollHeight;
}
function updateAiMessage(id, text) {
const messageDiv = document.getElementById(id);
if (messageDiv) {
if (text.startsWith('**NoteGenie:**')) {
text = text.replace('**NoteGenie:**', '**NoteGenie:**');
} else if (text.startsWith('**Error:**')) {
text = text.replace('**Error:**', '** Error:**');
}
messageDiv.innerHTML = marked.parse(text);
// Ensure auto-scrolling in the chat panel
conversationEl.scrollTop = conversationEl.scrollHeight;
}
}
function addSystemMessage(text) {
const messageDiv = document.createElement('div');
messageDiv.className = 'info-card mb-3';
messageDiv.innerHTML = text;
conversationEl.appendChild(messageDiv);
conversationEl.scrollTop = conversationEl.scrollHeight;
}
// UI state management
function setGeneratingState(isGenerating) {
if (isGenerating) {
actionBtnEl.disabled = true;
// Update loading state for the send button
actionBtnEl.innerHTML = 'autorenew';
promptInputEl.disabled = true;
// Disable buttons but preserve their active/inactive visual state
generateModeBtnEl.disabled = true;
editModeBtnEl.disabled = true;
// Make sure mode toggle visual state is preserved
if (currentMode === 'generate') {
generateModeBtnEl.classList.add('active');
editModeBtnEl.classList.remove('active');
} else {
editModeBtnEl.classList.add('active');
generateModeBtnEl.classList.remove('active');
}
} else {
// Reset the debounce flag when generation completes
isActionInProgress = false;
actionBtnEl.disabled = false;
// Always use the send icon for the inline button
actionBtnEl.innerHTML = 'send';
promptInputEl.disabled = false;
promptInputEl.focus();
generateModeBtnEl.disabled = false;
// Only enable edit mode button if we have a current notebook
editModeBtnEl.disabled = !currentNotebook;
}
}
});