2485 lines
82 KiB
JavaScript
2485 lines
82 KiB
JavaScript
const GPTResearcher = (() => {
|
|
let isResearchActive = false;
|
|
let connectionTimeout = null;
|
|
let conversationHistory = [];
|
|
let isInitialLoad = true; // Flag to track initial page load
|
|
let cookiesEnabled = true; // Flag to track if cookies are enabled
|
|
let allReports = ''; // Store all reports cumulatively
|
|
let currentReport = ''; // Store the current report (will be overwritten)
|
|
let isFirstReport = true; // Flag to track if this is the first report
|
|
let chatContainer = null; // Global reference to chat container
|
|
let lastRequestData = null; // Store the last request data for reconnection
|
|
|
|
// Add WebSocket monitoring variables
|
|
let socket = null;
|
|
let connectionStartTime = null;
|
|
let lastActivityTime = null;
|
|
let connectionAttempts = 0;
|
|
let messagesReceived = 0;
|
|
let websocketMonitorInterval = null;
|
|
let dispose_socket = null; // Re-add dispose_socket
|
|
let reconnectAttempts = 0;
|
|
let maxReconnectAttempts = 5;
|
|
let reconnectInterval = 2000; // Start with 2 seconds
|
|
|
|
const init = () => {
|
|
// Check if cookies are enabled
|
|
checkCookiesEnabled();
|
|
|
|
// Load history immediately on page load
|
|
loadConversationHistory();
|
|
|
|
// After a short delay, mark initial load as complete
|
|
setTimeout(() => {
|
|
isInitialLoad = false;
|
|
}, 1000);
|
|
|
|
// Setup form submission
|
|
document.getElementById('researchForm').addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
startResearch();
|
|
return false;
|
|
});
|
|
|
|
document
|
|
.getElementById('copyToClipboard')
|
|
.addEventListener('click', copyToClipboard)
|
|
|
|
// Add event listener for the top copy button
|
|
const topCopyButton = document.getElementById('copyToClipboardTop');
|
|
if (topCopyButton) {
|
|
topCopyButton.addEventListener('click', copyToClipboard);
|
|
}
|
|
|
|
// Initialize expand buttons
|
|
initExpandButtons();
|
|
|
|
// Initialize history panel functionality
|
|
initHistoryPanel();
|
|
|
|
// Initialize WebSocket monitoring panel
|
|
initWebSocketPanel();
|
|
|
|
// Initialize MCP functionality
|
|
initMCPSection();
|
|
|
|
// The download bar is now fixed in place with CSS
|
|
// No need to set display property here
|
|
|
|
updateState('initial');
|
|
|
|
// Initialize research icon to not spinning
|
|
updateResearchIcon(false);
|
|
|
|
// Hide loading overlay if it exists
|
|
const loadingOverlay = document.getElementById('loadingOverlay');
|
|
if (loadingOverlay) {
|
|
loadingOverlay.classList.add('loading-hidden');
|
|
}
|
|
}
|
|
|
|
// Check if cookies are enabled
|
|
const checkCookiesEnabled = () => {
|
|
try {
|
|
// Try to set a test cookie
|
|
document.cookie = "testcookie=1; path=/";
|
|
const cookieEnabled = document.cookie.indexOf("testcookie") !== -1;
|
|
|
|
if (!cookieEnabled) {
|
|
console.warn("Cookies are disabled in this browser");
|
|
cookiesEnabled = false;
|
|
showToast("Cookies are disabled. History will use localStorage instead.", 5000);
|
|
} else {
|
|
// Clean up test cookie
|
|
document.cookie = "testcookie=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
|
cookiesEnabled = true;
|
|
}
|
|
|
|
return cookieEnabled;
|
|
} catch (e) {
|
|
console.error("Error checking cookies:", e);
|
|
cookiesEnabled = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Initialize conversation history panel functionality
|
|
const initHistoryPanel = () => {
|
|
// Load history from cookie
|
|
loadConversationHistory();
|
|
|
|
// Setup history panel toggle button
|
|
const historyPanelOpenBtn = document.getElementById('historyPanelOpenBtn');
|
|
const historyPanel = document.getElementById('historyPanel');
|
|
const historyPanelToggle = document.getElementById('historyPanelToggle');
|
|
|
|
if (historyPanelOpenBtn) {
|
|
historyPanelOpenBtn.addEventListener('click', () => {
|
|
loadConversationHistory(); // Reload history when opening panel
|
|
historyPanel.classList.add('open');
|
|
});
|
|
}
|
|
|
|
if (historyPanelToggle) {
|
|
historyPanelToggle.addEventListener('click', () => {
|
|
historyPanel.classList.remove('open');
|
|
});
|
|
}
|
|
|
|
// Close panel when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
// If the panel is open and the click is outside the panel and not on the toggle button
|
|
if (historyPanel.classList.contains('open') &&
|
|
!historyPanel.contains(e.target) &&
|
|
e.target !== historyPanelOpenBtn &&
|
|
!historyPanelOpenBtn.contains(e.target)) {
|
|
historyPanel.classList.remove('open');
|
|
}
|
|
});
|
|
|
|
// Setup search functionality
|
|
const historySearch = document.getElementById('historySearch');
|
|
const historySearchBtn = document.getElementById('historySearchBtn');
|
|
|
|
if (historySearch && historySearchBtn) {
|
|
historySearch.addEventListener('input', filterHistoryEntries);
|
|
historySearchBtn.addEventListener('click', () => filterHistoryEntries());
|
|
}
|
|
|
|
// Setup sort functionality
|
|
const historySortOrder = document.getElementById('historySortOrder');
|
|
if (historySortOrder) {
|
|
historySortOrder.addEventListener('change', () => {
|
|
sortHistoryEntries(historySortOrder.value);
|
|
renderHistoryEntries();
|
|
});
|
|
}
|
|
|
|
// Setup clear history button
|
|
const historyClearBtn = document.getElementById('historyClearBtn');
|
|
if (historyClearBtn) {
|
|
historyClearBtn.addEventListener('click', clearConversationHistory);
|
|
}
|
|
|
|
// Add action buttons to history panel
|
|
const historyFilters = document.querySelector('.history-panel-filters');
|
|
if (historyFilters) {
|
|
// Create a container for the buttons
|
|
const actionsContainer = document.createElement('div');
|
|
actionsContainer.className = 'history-actions-container';
|
|
|
|
// Add export history button with enhanced styling and tooltip
|
|
const exportBtn = document.createElement('button');
|
|
exportBtn.className = 'history-action-btn';
|
|
exportBtn.title = 'Export research history to file';
|
|
exportBtn.innerHTML = '<i class="fas fa-file-export"></i>';
|
|
exportBtn.addEventListener('click', exportHistory);
|
|
|
|
// Add import history button with enhanced styling and tooltip
|
|
const importBtn = document.createElement('button');
|
|
importBtn.className = 'history-action-btn';
|
|
importBtn.title = 'Import research history from file';
|
|
importBtn.innerHTML = '<i class="fas fa-file-import"></i>';
|
|
importBtn.addEventListener('click', triggerImportHistory);
|
|
|
|
// Add cookie debug button with enhanced styling and tooltip
|
|
const debugBtn = document.createElement('button');
|
|
debugBtn.className = 'history-action-btn';
|
|
debugBtn.title = 'Check storage status';
|
|
debugBtn.innerHTML = '<i class="fas fa-database"></i>';
|
|
debugBtn.addEventListener('click', checkCookieStatus);
|
|
|
|
// Add buttons to container in a logical order
|
|
actionsContainer.appendChild(importBtn);
|
|
actionsContainer.appendChild(exportBtn);
|
|
actionsContainer.appendChild(debugBtn);
|
|
|
|
// Create a hidden file input for importing
|
|
const fileInput = document.createElement('input');
|
|
fileInput.type = 'file';
|
|
fileInput.id = 'historyFileInput';
|
|
fileInput.accept = '.json';
|
|
fileInput.style.display = 'none';
|
|
fileInput.addEventListener('change', handleFileImport);
|
|
|
|
// Add container and file input to filters
|
|
historyFilters.appendChild(actionsContainer);
|
|
historyFilters.appendChild(fileInput);
|
|
}
|
|
|
|
// Initial render of history entries
|
|
renderHistoryEntries();
|
|
}
|
|
|
|
// Initialize WebSocket monitoring panel
|
|
const initWebSocketPanel = () => {
|
|
const websocketPanel = document.getElementById('websocketPanel');
|
|
const websocketPanelOpenBtn = document.getElementById('websocketPanelOpenBtn');
|
|
const websocketPanelToggle = document.getElementById('websocketPanelToggle');
|
|
|
|
if (!websocketPanel || !websocketPanelOpenBtn || !websocketPanelToggle) {
|
|
console.error("WebSocket panel elements not found");
|
|
return;
|
|
}
|
|
|
|
console.log("Initializing WebSocket panel");
|
|
|
|
// Ensure it starts hidden
|
|
websocketPanel.classList.remove('open');
|
|
|
|
websocketPanelOpenBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
console.log("Opening WebSocket panel");
|
|
websocketPanel.classList.add('open');
|
|
});
|
|
|
|
websocketPanelToggle.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
console.log("Closing WebSocket panel");
|
|
websocketPanel.classList.remove('open');
|
|
});
|
|
|
|
// Close panel when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
// If the panel is open and the click is outside the panel and not on the toggle button
|
|
if (websocketPanel.classList.contains('open') &&
|
|
!websocketPanel.contains(e.target) &&
|
|
e.target !== websocketPanelOpenBtn &&
|
|
!websocketPanelOpenBtn.contains(e.target)) {
|
|
websocketPanel.classList.remove('open');
|
|
}
|
|
});
|
|
|
|
// Start periodic WebSocket status updates
|
|
startWebSocketMonitoring();
|
|
}
|
|
|
|
// Start WebSocket monitoring
|
|
const startWebSocketMonitoring = () => {
|
|
console.log("Starting WebSocket monitoring");
|
|
|
|
// Update status immediately
|
|
updateWebSocketStatus();
|
|
|
|
// Clear any existing interval
|
|
if (websocketMonitorInterval) {
|
|
clearInterval(websocketMonitorInterval);
|
|
}
|
|
|
|
// Update status every 2 seconds
|
|
websocketMonitorInterval = setInterval(updateWebSocketStatus, 2000);
|
|
}
|
|
|
|
// Update WebSocket status in the panel
|
|
const updateWebSocketStatus = () => {
|
|
// Only proceed if the necessary elements exist
|
|
const connectionStatusEl = document.getElementById('connectionStatus');
|
|
const connectionIndicatorEl = document.getElementById('connectionIndicator');
|
|
const researchStatusEl = document.getElementById('researchStatus');
|
|
const connectionDurationEl = document.getElementById('connectionDuration');
|
|
const lastActivityEl = document.getElementById('lastActivity');
|
|
const readyStateEl = document.getElementById('readyState');
|
|
const connectionAttemptsEl = document.getElementById('connectionAttempts');
|
|
const messagesReceivedEl = document.getElementById('messagesReceived');
|
|
const currentTaskEl = document.getElementById('currentTask');
|
|
|
|
if (!connectionStatusEl || !connectionIndicatorEl) return;
|
|
|
|
// Update connection status
|
|
const socketStatus = getSocketStatus();
|
|
connectionStatusEl.textContent = socketStatus.statusText;
|
|
|
|
// Update indicator class
|
|
connectionIndicatorEl.className = 'status-indicator';
|
|
connectionIndicatorEl.classList.add(socketStatus.indicatorClass);
|
|
|
|
// Update research status
|
|
if (researchStatusEl) {
|
|
researchStatusEl.textContent = isResearchActive ? 'Active' : 'Inactive';
|
|
}
|
|
|
|
// Update connection duration
|
|
if (connectionDurationEl && connectionStartTime) {
|
|
const duration = Math.floor((Date.now() - connectionStartTime) / 1000);
|
|
connectionDurationEl.textContent = formatDuration(duration);
|
|
} else if (connectionDurationEl) {
|
|
connectionDurationEl.textContent = '-';
|
|
}
|
|
|
|
// Update last activity
|
|
if (lastActivityEl && lastActivityTime) {
|
|
const elapsed = Math.floor((Date.now() - lastActivityTime) / 1000);
|
|
lastActivityEl.textContent = elapsed < 60 ? `${elapsed} sec ago` : formatDuration(elapsed) + ' ago';
|
|
} else if (lastActivityEl) {
|
|
lastActivityEl.textContent = '-';
|
|
}
|
|
|
|
// Update ReadyState
|
|
if (readyStateEl && socket) {
|
|
readyStateEl.textContent = getReadyStateText(socket.readyState);
|
|
} else if (readyStateEl) {
|
|
readyStateEl.textContent = '-';
|
|
}
|
|
|
|
// Update connection attempts
|
|
if (connectionAttemptsEl) {
|
|
connectionAttemptsEl.textContent = connectionAttempts.toString();
|
|
}
|
|
|
|
// Update messages received
|
|
if (messagesReceivedEl) {
|
|
messagesReceivedEl.textContent = messagesReceived.toString();
|
|
}
|
|
|
|
// Update current task
|
|
if (currentTaskEl) {
|
|
const taskInput = document.getElementById('task');
|
|
currentTaskEl.textContent = isResearchActive && taskInput && taskInput.value ?
|
|
(taskInput.value.length > 30 ? taskInput.value.substring(0, 27) + '...' : taskInput.value) :
|
|
'-';
|
|
}
|
|
}
|
|
|
|
// Get socket status object
|
|
const getSocketStatus = () => {
|
|
if (!socket) {
|
|
return {
|
|
statusText: 'Disconnected',
|
|
indicatorClass: 'disconnected'
|
|
};
|
|
}
|
|
|
|
switch (socket.readyState) {
|
|
case WebSocket.CONNECTING:
|
|
return {
|
|
statusText: 'Connecting',
|
|
indicatorClass: 'connecting'
|
|
};
|
|
case WebSocket.OPEN:
|
|
return {
|
|
statusText: 'Connected',
|
|
indicatorClass: 'connected'
|
|
};
|
|
case WebSocket.CLOSING:
|
|
return {
|
|
statusText: 'Closing',
|
|
indicatorClass: 'connecting'
|
|
};
|
|
case WebSocket.CLOSED:
|
|
default:
|
|
return {
|
|
statusText: 'Disconnected',
|
|
indicatorClass: 'disconnected'
|
|
};
|
|
}
|
|
}
|
|
|
|
// Get readable text for WebSocket readyState
|
|
const getReadyStateText = (readyState) => {
|
|
switch (readyState) {
|
|
case WebSocket.CONNECTING:
|
|
return '0 (Connecting)';
|
|
case WebSocket.OPEN:
|
|
return '1 (Open)';
|
|
case WebSocket.CLOSING:
|
|
return '2 (Closing)';
|
|
case WebSocket.CLOSED:
|
|
return '3 (Closed)';
|
|
default:
|
|
return `${readyState} (Unknown)`;
|
|
}
|
|
}
|
|
|
|
// Format duration in seconds to human-readable string
|
|
const formatDuration = (seconds) => {
|
|
if (seconds < 60) {
|
|
return `${seconds} sec`;
|
|
} else if (seconds < 3600) {
|
|
return `${Math.floor(seconds / 60)} min ${seconds % 60} sec`;
|
|
} else {
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
return `${hours} hr ${minutes} min`;
|
|
}
|
|
}
|
|
|
|
// Load conversation history from cookie
|
|
const loadConversationHistory = () => {
|
|
try {
|
|
const storedHistory = getCookie('conversationHistory');
|
|
if (storedHistory && storedHistory.trim() !== '') {
|
|
try {
|
|
const parsedHistory = JSON.parse(storedHistory);
|
|
if (Array.isArray(parsedHistory)) {
|
|
conversationHistory = parsedHistory;
|
|
console.debug('Loaded research history from storage:', conversationHistory);
|
|
console.log('Loaded research history:', conversationHistory.length, 'items');
|
|
} else {
|
|
console.warn('History storage does not contain an array');
|
|
conversationHistory = [];
|
|
deleteCookie('conversationHistory');
|
|
}
|
|
} catch (jsonError) {
|
|
console.error('Invalid JSON in history storage:', jsonError);
|
|
conversationHistory = [];
|
|
deleteCookie('conversationHistory');
|
|
}
|
|
} else {
|
|
console.log('No research history found in storage');
|
|
conversationHistory = [];
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading research history from storage:', error);
|
|
conversationHistory = [];
|
|
// If JSON parsing fails, delete the corrupt cookie
|
|
deleteCookie('conversationHistory');
|
|
}
|
|
|
|
// Force render after loading
|
|
renderHistoryEntries();
|
|
}
|
|
|
|
// Save conversation history to cookie
|
|
const saveConversationHistory = () => {
|
|
try {
|
|
if (conversationHistory.length === 0) {
|
|
deleteCookie('conversationHistory');
|
|
console.debug('No history to save, deleted storage');
|
|
return;
|
|
}
|
|
|
|
// Only keep the last 20 entries
|
|
let storageHistory = [...conversationHistory];
|
|
if (storageHistory.length > 20) {
|
|
storageHistory = storageHistory.slice(0, 20);
|
|
console.debug('Trimmed history to last 20 entries');
|
|
}
|
|
|
|
// Only keep minimal fields: prompt, links and timestamp
|
|
storageHistory = storageHistory.map(entry => ({
|
|
prompt: entry.prompt || '',
|
|
links: entry.links || {},
|
|
timestamp: entry.timestamp || new Date().toISOString()
|
|
}));
|
|
|
|
const jsonString = JSON.stringify(storageHistory);
|
|
console.debug('History JSON size:', jsonString.length, 'characters');
|
|
|
|
setCookie('conversationHistory', jsonString, 30);
|
|
|
|
if (storageHistory.length > 0 && !isInitialLoad) {
|
|
showToast('Research history saved!');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error saving research history:', error);
|
|
showToast('Error saving history. Some entries may not be saved.');
|
|
}
|
|
}
|
|
|
|
// Delete a history entry
|
|
const deleteHistoryEntry = (index) => {
|
|
if (confirm('Are you sure you want to delete this research entry?')) {
|
|
conversationHistory.splice(index, 1);
|
|
saveConversationHistory();
|
|
renderHistoryEntries();
|
|
showToast('Entry deleted successfully');
|
|
}
|
|
}
|
|
|
|
// Clear all conversation history
|
|
const clearConversationHistory = () => {
|
|
if (confirm('Are you sure you want to clear all research history? This cannot be undone.')) {
|
|
conversationHistory = [];
|
|
saveConversationHistory();
|
|
renderHistoryEntries();
|
|
showToast('Research history cleared successfully');
|
|
}
|
|
}
|
|
|
|
// Filter history entries based on search term
|
|
const filterHistoryEntries = () => {
|
|
const searchTerm = document.getElementById('historySearch').value.toLowerCase();
|
|
const historyEntries = document.getElementById('historyEntries');
|
|
|
|
if (!historyEntries) return;
|
|
|
|
const entries = historyEntries.querySelectorAll('.history-entry');
|
|
|
|
entries.forEach(entry => {
|
|
const title = entry.querySelector('.history-entry-title').textContent.toLowerCase();
|
|
// Search only in the title since we no longer have preview text
|
|
if (title.includes(searchTerm)) {
|
|
entry.style.display = 'block';
|
|
} else {
|
|
entry.style.display = 'none';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Sort history entries by timestamp
|
|
const sortHistoryEntries = (order) => {
|
|
conversationHistory.sort((a, b) => {
|
|
// Default to newest first if timestamps don't exist
|
|
if (!a.timestamp || !b.timestamp) return 0;
|
|
|
|
if (order === 'newest') {
|
|
return new Date(b.timestamp) - new Date(a.timestamp);
|
|
} else {
|
|
return new Date(a.timestamp) - new Date(b.timestamp);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Render history entries in the panel
|
|
const renderHistoryEntries = () => {
|
|
const historyEntries = document.getElementById('historyEntries');
|
|
if (!historyEntries) return;
|
|
|
|
historyEntries.innerHTML = '';
|
|
|
|
if (!conversationHistory || conversationHistory.length === 0) {
|
|
historyEntries.innerHTML = '<p class="text-center mt-4 text-muted">No research history yet.</p>';
|
|
return;
|
|
}
|
|
|
|
// Sort by the current selection
|
|
const sortOrder = document.getElementById('historySortOrder')?.value || 'newest';
|
|
sortHistoryEntries(sortOrder);
|
|
console.debug('Sorted history entries:', sortOrder);
|
|
|
|
conversationHistory.forEach((entry, index) => {
|
|
const entryElement = document.createElement('div');
|
|
entryElement.className = 'history-entry';
|
|
entryElement.setAttribute('data-id', index);
|
|
|
|
// Make the entire entry clickable to load it
|
|
entryElement.addEventListener('click', () => {
|
|
loadResearchEntry(index);
|
|
});
|
|
|
|
// Format timestamp if available
|
|
let timestampHTML = '';
|
|
if (entry.timestamp) {
|
|
try {
|
|
const timestamp = new Date(entry.timestamp);
|
|
const formattedDate = timestamp.toLocaleDateString();
|
|
const formattedTime = timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
timestampHTML = `<span class="history-entry-timestamp">${formattedDate} ${formattedTime}</span>`;
|
|
} catch (e) {
|
|
console.error('Error formatting timestamp:', e);
|
|
}
|
|
}
|
|
|
|
// Make sure links object exists
|
|
const links = entry.links || {};
|
|
|
|
// Build the HTML for the entry with enhanced formatting
|
|
entryElement.innerHTML = `
|
|
<div class="history-entry-header">
|
|
<h4 class="history-entry-title">${entry.prompt || 'Unnamed Research'}</h4>
|
|
${timestampHTML}
|
|
</div>
|
|
<div class="history-entry-format">
|
|
${links.pdf ? `<a href="${links.pdf}" class="history-entry-action" target="_blank" title="Open PDF Report"><i class="fas fa-file-pdf"></i> PDF</a>` : ''}
|
|
${links.docx ? `<a href="${links.docx}" class="history-entry-action" target="_blank" title="Open Word Document"><i class="fas fa-file-word"></i> Word</a>` : ''}
|
|
${links.md ? `<a href="${links.md}" class="history-entry-action" target="_blank" title="Open Markdown File"><i class="fas fa-file-lines"></i> MD</a>` : ''}
|
|
${links.json ? `<a href="${links.json}" class="history-entry-action" target="_blank" title="Open JSON Data"><i class="fas fa-file-code"></i> JSON</a>` : ''}
|
|
</div>
|
|
<div class="history-entry-actions">
|
|
<button class="history-entry-action delete-entry" title="Delete this research entry"><i class="fas fa-trash-alt"></i></button>
|
|
</div>
|
|
`;
|
|
|
|
// Add action button handlers
|
|
const deleteBtn = entryElement.querySelector('.delete-entry');
|
|
if (deleteBtn) {
|
|
deleteBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
deleteHistoryEntry(index);
|
|
});
|
|
}
|
|
|
|
historyEntries.appendChild(entryElement);
|
|
setTimeout(() => {
|
|
entryElement.style.animationDelay = `${index * 50}ms`;
|
|
}, 0);
|
|
});
|
|
}
|
|
|
|
// Load a research entry from history
|
|
const loadResearchEntry = (index) => {
|
|
const entry = conversationHistory[index];
|
|
if (!entry) return;
|
|
|
|
// Fill form with the entry data
|
|
document.getElementById('task').value = entry.prompt; // Changed from entry.task for consistency
|
|
|
|
// Check if report_type, report_source, and tone are in entry, otherwise use defaults or skip
|
|
const reportTypeSelect = document.querySelector('select[name="report_type"]');
|
|
if (reportTypeSelect && entry.reportType) {
|
|
reportTypeSelect.value = entry.reportType;
|
|
} else if (reportTypeSelect) {
|
|
reportTypeSelect.value = reportTypeSelect.options[0].value; // Default to first option
|
|
}
|
|
|
|
const reportSourceSelect = document.querySelector('select[name="report_source"]');
|
|
if (reportSourceSelect && entry.reportSource) {
|
|
reportSourceSelect.value = entry.reportSource;
|
|
} else if (reportSourceSelect) {
|
|
reportSourceSelect.value = reportSourceSelect.options[0].value; // Default to first option
|
|
}
|
|
|
|
const toneSelect = document.querySelector('select[name="tone"]');
|
|
if (toneSelect && entry.tone) {
|
|
toneSelect.value = entry.tone;
|
|
} else if (toneSelect) {
|
|
toneSelect.value = toneSelect.options[0].value; // Default to first option
|
|
}
|
|
|
|
const queryDomainsInput = document.querySelector('input[name="query_domains"]');
|
|
if (queryDomainsInput) {
|
|
if (entry.queryDomains && Array.isArray(entry.queryDomains) && entry.queryDomains.length > 0) {
|
|
queryDomainsInput.value = entry.queryDomains.join(', ');
|
|
} else {
|
|
queryDomainsInput.value = ''; // Clear if not present
|
|
}
|
|
}
|
|
|
|
// Clear current research/report areas
|
|
document.getElementById('output').innerHTML = '';
|
|
document.getElementById('reportContainer').innerHTML = '';
|
|
document.getElementById('selectedImagesContainer').innerHTML = '';
|
|
document.getElementById('selectedImagesContainer').style.display = 'none';
|
|
|
|
// Hide download bar and chat
|
|
const stickyDownloadsBar = document.getElementById('stickyDownloadsBar');
|
|
if (stickyDownloadsBar) {
|
|
stickyDownloadsBar.classList.remove('visible');
|
|
}
|
|
const chatContainer = document.getElementById('chatContainer');
|
|
if (chatContainer) {
|
|
chatContainer.style.display = 'none';
|
|
}
|
|
|
|
// Reset UI state and report-specific buttons
|
|
updateState('initial'); // This will hide copy buttons etc.
|
|
|
|
// Close the history panel
|
|
const historyPanel = document.getElementById('historyPanel');
|
|
if (historyPanel) {
|
|
historyPanel.classList.remove('open');
|
|
}
|
|
|
|
// Scroll to the form
|
|
const formElement = document.getElementById('form');
|
|
if (formElement) {
|
|
formElement.scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
|
|
// Inform user
|
|
showToast('Research parameters loaded. You can start the research again.');
|
|
}
|
|
|
|
// Copy entry content to clipboard
|
|
const copyEntryToClipboard = (index) => {
|
|
const entry = conversationHistory[index];
|
|
if (!entry || !entry.content) return;
|
|
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = entry.content;
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(textarea);
|
|
|
|
// Show a toast notification
|
|
showToast('Research content copied to clipboard!');
|
|
}
|
|
|
|
// Show a toast notification
|
|
const showToast = (message, duration = 3000) => {
|
|
// Create toast element if it doesn't exist
|
|
let toast = document.getElementById('toast-notification');
|
|
if (!toast) {
|
|
toast = document.createElement('div');
|
|
toast.id = 'toast-notification';
|
|
toast.className = 'toast-notification';
|
|
document.body.appendChild(toast);
|
|
}
|
|
|
|
// Set message and show
|
|
toast.textContent = message;
|
|
toast.classList.add('show');
|
|
|
|
// Hide after specified duration
|
|
setTimeout(() => {
|
|
toast.classList.remove('show');
|
|
}, duration);
|
|
}
|
|
|
|
// Save current research to history (minimal: prompt and links only)
|
|
const saveToHistory = (report, downloadLinks) => {
|
|
if (!downloadLinks) {
|
|
console.error('No download links provided');
|
|
showToast('Error: Could not save research to history');
|
|
return;
|
|
}
|
|
|
|
const prompt = document.getElementById('task').value;
|
|
|
|
// Create links object with proper structure
|
|
const links = {
|
|
pdf: downloadLinks.pdf || '',
|
|
docx: downloadLinks.docx || '',
|
|
md: downloadLinks.md || '',
|
|
json: downloadLinks.json || ''
|
|
};
|
|
|
|
console.debug('Saving history with links:', links);
|
|
|
|
// Create history entry with timestamp
|
|
const historyEntry = {
|
|
prompt,
|
|
links,
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
// Add to beginning of array if it's not empty
|
|
if (!conversationHistory) {
|
|
conversationHistory = [];
|
|
}
|
|
|
|
conversationHistory.unshift(historyEntry);
|
|
saveConversationHistory();
|
|
renderHistoryEntries();
|
|
document.getElementById('historyPanel').classList.add('open');
|
|
|
|
// Prompt user about storage method
|
|
if (cookiesEnabled) {
|
|
showToast('Research saved! Your history is stored in a browser cookie.');
|
|
} else {
|
|
showToast('Research saved! Your history is stored using localStorage.');
|
|
}
|
|
}
|
|
|
|
// Function to update the research icon spinning state
|
|
const updateResearchIcon = (isSpinning) => {
|
|
const modernSpinner = document.getElementById('modernSpinner');
|
|
if (modernSpinner) {
|
|
if (isSpinning) {
|
|
modernSpinner.classList.add('spinning');
|
|
} else {
|
|
modernSpinner.classList.remove('spinning');
|
|
}
|
|
}
|
|
};
|
|
|
|
const startResearch = () => {
|
|
document.getElementById('output').innerHTML = ''
|
|
document.getElementById('reportContainer').innerHTML = ''
|
|
dispose_socket?.() // Call previous dispose function if it exists
|
|
|
|
// Reset report variables
|
|
allReports = '';
|
|
currentReport = '';
|
|
isFirstReport = true;
|
|
|
|
// Hide the download bar
|
|
const stickyDownloadsBar = document.getElementById('stickyDownloadsBar');
|
|
if (stickyDownloadsBar) {
|
|
stickyDownloadsBar.classList.remove('visible');
|
|
}
|
|
|
|
// Hide the chat container
|
|
chatContainer = document.getElementById('chatContainer');
|
|
if (chatContainer) {
|
|
chatContainer.style.display = 'none';
|
|
}
|
|
|
|
const imageContainer = document.getElementById('selectedImagesContainer')
|
|
imageContainer.innerHTML = ''
|
|
imageContainer.style.display = 'none'
|
|
|
|
updateState('in_progress')
|
|
|
|
addAgentResponse({
|
|
output: '🧙♂️ Gathering information and analyzing your research topic...',
|
|
})
|
|
|
|
// Scroll to the "Research Progress" section
|
|
const researchOutputContainer = document.querySelector('.research-output-container');
|
|
if (researchOutputContainer) {
|
|
researchOutputContainer.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'start'
|
|
});
|
|
}
|
|
|
|
dispose_socket = listenToSockEvents() // Assign the new dispose function
|
|
}
|
|
|
|
const listenToSockEvents = () => {
|
|
const { protocol, host, pathname } = window.location
|
|
const ws_uri = `${protocol === 'https:' ? 'wss:' : 'ws:'
|
|
}//${host}${pathname}ws`
|
|
|
|
// Set a timeout for connection - if it takes too long, stop the spinner
|
|
connectionTimeout = setTimeout(() => {
|
|
updateResearchIcon(false);
|
|
console.log("WebSocket connection timed out");
|
|
}, 10000); // 10 seconds timeout
|
|
|
|
// Configure Showdown converter to properly handle code blocks
|
|
const converter = new showdown.Converter({
|
|
ghCodeBlocks: true, // GitHub style code blocks
|
|
tables: true, // Enable tables
|
|
tasklists: true, // Enable task lists
|
|
smartIndentationFix: true, // Fix weird indentation
|
|
simpleLineBreaks: true, // Treat newlines as <br>
|
|
openLinksInNewWindow: true, // Open links in new tab
|
|
parseImgDimensions: true // Parse image dimensions from markdown
|
|
});
|
|
|
|
// Fix issues with code block formatting
|
|
converter.setOption('literalMidWordUnderscores', true);
|
|
|
|
// Increment connection attempts counter
|
|
connectionAttempts++;
|
|
|
|
// Update WebSocket status
|
|
updateWebSocketStatus();
|
|
|
|
socket = new WebSocket(ws_uri)
|
|
let reportContent = ''; // Store the report content for history
|
|
let downloadLinkData = null; // Store download links
|
|
|
|
socket.onmessage = (event) => {
|
|
// Reset reconnect attempts on successful message
|
|
reconnectAttempts = 0;
|
|
|
|
const data = JSON.parse(event.data)
|
|
console.log("Received message:", data); // Debug log
|
|
|
|
// Update WebSocket metrics
|
|
messagesReceived++;
|
|
lastActivityTime = Date.now();
|
|
updateWebSocketStatus();
|
|
|
|
if (data.type === 'logs') {
|
|
if (data.content === 'subqueries' && data.metadata && Array.isArray(data.metadata)) {
|
|
displaySubQuestions(data.metadata)
|
|
}
|
|
addAgentResponse(data)
|
|
} else if (data.type === 'images') {
|
|
console.log("Received images:", data); // Debug log
|
|
displaySelectedImages(data)
|
|
} else if (data.type === 'report') {
|
|
// Add to reportContent for history
|
|
reportContent += data.output;
|
|
|
|
// Get the current report_type
|
|
const report_type = document.querySelector('select[name="report_type"]').value;
|
|
|
|
// Determine if we're using detailed_report
|
|
const isDetailedReport = report_type === 'detailed_report';
|
|
|
|
if (isDetailedReport) {
|
|
allReports += data.output; // Accumulate raw markdown
|
|
// Always render the HTML of *all accumulated markdown* for detailed reports during streaming.
|
|
// writeReport will replace the container's content.
|
|
writeReport({ output: allReports, type: 'report' }, converter, false, false);
|
|
} else {
|
|
// For all other report types, append HTML of current chunk to the container.
|
|
writeReport({ output: data.output, type: 'report' }, converter, false, true); // append = true
|
|
}
|
|
} else if (data.type === 'path') {
|
|
updateState('finished')
|
|
downloadLinkData = updateDownloadLink(data)
|
|
isResearchActive = false;
|
|
|
|
// Get the current report_type
|
|
const report_type = document.querySelector('select[name="report_type"]').value;
|
|
|
|
// Only for detailed_report, show the complete accumulated report at the end
|
|
if (report_type === 'detailed_report' && allReports) {
|
|
const finalData = { output: allReports, type: 'report' };
|
|
writeReport(finalData, converter, true, false); // isFinal=true, append=false
|
|
}
|
|
|
|
// Save to history now that research is complete
|
|
if (reportContent && downloadLinkData) {
|
|
saveToHistory(reportContent, downloadLinkData);
|
|
|
|
// Reset variables for next research session
|
|
reportContent = '';
|
|
allReports = '';
|
|
currentReport = '';
|
|
isFirstReport = true;
|
|
}
|
|
|
|
// Update WebSocket status
|
|
updateWebSocketStatus();
|
|
} else if (data.type === 'chat') {
|
|
// Handle chat messages from the AI
|
|
// Remove loading indicator and add AI's response
|
|
const loadingElements = document.querySelectorAll('.chat-loading');
|
|
if (loadingElements.length > 0) {
|
|
loadingElements[loadingElements.length - 1].remove();
|
|
}
|
|
|
|
// Add AI message to chat
|
|
if (data.content) {
|
|
addChatMessage(data.content, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
socket.onopen = (event) => {
|
|
// Clear the connection timeout
|
|
clearTimeout(connectionTimeout);
|
|
|
|
// Update WebSocket metrics
|
|
connectionStartTime = Date.now();
|
|
lastActivityTime = Date.now();
|
|
updateWebSocketStatus();
|
|
|
|
// Reset reconnect attempts on successful connection
|
|
reconnectAttempts = 0;
|
|
|
|
// Ensure the research icon is spinning when connection is established
|
|
updateResearchIcon(true);
|
|
|
|
// If this is a reconnection and we're in research mode, don't send a new start command
|
|
if (isResearchActive && lastRequestData) {
|
|
console.log("Reconnected during active research, not sending new start command");
|
|
return;
|
|
}
|
|
|
|
const task = document.getElementById('task').value
|
|
const report_type = document.querySelector(
|
|
'select[name="report_type"]'
|
|
).value
|
|
const report_source = document.querySelector(
|
|
'select[name="report_source"]'
|
|
).value
|
|
const tone = document.querySelector('select[name="tone"]').value
|
|
const agent = document.querySelector('input[name="agent"]:checked').value
|
|
let source_urls = tags
|
|
|
|
if (report_source !== 'sources' && source_urls.length > 0) {
|
|
source_urls = source_urls.slice(0, source_urls.length - 1)
|
|
}
|
|
|
|
const query_domains_str = document.querySelector('input[name="query_domains"]').value
|
|
let query_domains = []
|
|
if (query_domains_str) {
|
|
query_domains = query_domains_str.split(',')
|
|
.map((domain) => domain.trim())
|
|
.filter((domain) => domain.length > 0);
|
|
}
|
|
|
|
const requestData = {
|
|
task: task,
|
|
report_type: report_type,
|
|
report_source: report_source,
|
|
source_urls: source_urls,
|
|
tone: tone,
|
|
agent: agent,
|
|
query_domains: query_domains,
|
|
max_search_results: parseInt(document.getElementById('maxSearchResults').value, 10) || 5,
|
|
}
|
|
|
|
// Add MCP configuration if enabled
|
|
const mcpData = collectMCPData();
|
|
if (mcpData) {
|
|
Object.assign(requestData, mcpData);
|
|
console.log('Including MCP configuration:', mcpData);
|
|
}
|
|
|
|
// Store the request data for potential reconnection
|
|
lastRequestData = requestData;
|
|
|
|
socket.send(`start ${JSON.stringify(requestData)}`)
|
|
}
|
|
|
|
socket.onclose = (event) => {
|
|
// Update metrics and status when connection closes
|
|
connectionStartTime = null;
|
|
updateWebSocketStatus();
|
|
|
|
console.log("WebSocket connection closed", event);
|
|
|
|
// If research is active, try to automatically reconnect
|
|
if (isResearchActive) {
|
|
reconnectWebSocket();
|
|
}
|
|
}
|
|
|
|
socket.onerror = (error) => {
|
|
console.error("WebSocket error:", error);
|
|
updateWebSocketStatus();
|
|
}
|
|
|
|
// return dispose function
|
|
return () => {
|
|
try {
|
|
isResearchActive = false; // Mark research as inactive
|
|
if (socket && socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) {
|
|
socket.close();
|
|
}
|
|
|
|
// Update metrics on socket disposal
|
|
connectionStartTime = null;
|
|
updateWebSocketStatus();
|
|
} catch (e) {
|
|
console.error('Error closing socket:', e)
|
|
}
|
|
};
|
|
}
|
|
|
|
// Sanitize HTML before inserting it into the DOM. Report and agent content is
|
|
// derived from untrusted sources (scraped web pages and LLM output), so it
|
|
// must be sanitized to prevent cross-site scripting (XSS).
|
|
const sanitizeHtml = (html) => {
|
|
if (typeof DOMPurify !== 'undefined') {
|
|
return DOMPurify.sanitize(html, { ADD_ATTR: ['target'] });
|
|
}
|
|
// Defensive fallback if DOMPurify failed to load: escape everything.
|
|
console.warn('DOMPurify not loaded; escaping content as a fallback.');
|
|
const tmp = document.createElement('div');
|
|
tmp.textContent = html;
|
|
return tmp.innerHTML;
|
|
};
|
|
|
|
const addAgentResponse = (data) => {
|
|
const output = document.getElementById('output');
|
|
const responseDiv = document.createElement('div');
|
|
responseDiv.className = 'agent_response';
|
|
responseDiv.innerHTML = sanitizeHtml(data.output);
|
|
output.appendChild(responseDiv);
|
|
output.scrollTop = output.scrollHeight;
|
|
output.style.display = 'block';
|
|
}
|
|
|
|
const displaySubQuestions = (questions) => {
|
|
const output = document.getElementById('output');
|
|
const container = document.createElement('div');
|
|
container.className = 'sub-questions';
|
|
|
|
const heading = document.createElement('p');
|
|
heading.className = 'sub-questions-heading';
|
|
heading.textContent = '🤔 Pondering your question from several angles';
|
|
container.appendChild(heading);
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'sub-questions-list';
|
|
questions.forEach((q) => {
|
|
const pill = document.createElement('span');
|
|
pill.className = 'sub-question-pill';
|
|
pill.textContent = q;
|
|
list.appendChild(pill);
|
|
});
|
|
container.appendChild(list);
|
|
|
|
output.appendChild(container);
|
|
output.scrollTop = output.scrollHeight;
|
|
output.style.display = 'block';
|
|
}
|
|
|
|
const writeReport = (data, converter, isFinal = false, append = false) => {
|
|
const reportContainer = document.getElementById('reportContainer');
|
|
|
|
// Convert markdown to HTML, then sanitize to prevent XSS from untrusted
|
|
// report content (scraped pages / LLM output).
|
|
const markdownOutput = sanitizeHtml(converter.makeHtml(data.output));
|
|
|
|
// If this is the final report or we should append
|
|
if (isFinal) {
|
|
// For final reports, always replace content
|
|
reportContainer.innerHTML = markdownOutput;
|
|
} else if (append) {
|
|
// Append mode - add to existing content
|
|
reportContainer.innerHTML += markdownOutput;
|
|
} else {
|
|
// Replace mode - overwrite existing content
|
|
reportContainer.innerHTML = markdownOutput;
|
|
}
|
|
|
|
// Auto-scroll to the bottom of the container
|
|
reportContainer.scrollTop = reportContainer.scrollHeight;
|
|
}
|
|
|
|
const updateDownloadLink = (data) => {
|
|
if (!data.output) {
|
|
console.error('No output data received');
|
|
return;
|
|
}
|
|
|
|
const { pdf, docx, md, json } = data.output;
|
|
console.log('Received paths:', { pdf, docx, md, json });
|
|
|
|
// Store these links for history
|
|
const currentLinks = { pdf, docx, md, json };
|
|
|
|
// Helper function to safely update link
|
|
const updateLink = (id, path) => {
|
|
const element = document.getElementById(id);
|
|
if (element && path) {
|
|
console.log(`Setting ${id} href to:`, path);
|
|
element.setAttribute('href', path);
|
|
element.classList.remove('disabled');
|
|
} else {
|
|
console.warn(`Either element ${id} not found or path not provided`);
|
|
}
|
|
};
|
|
|
|
// Update links in sticky download bar
|
|
updateLink('downloadLink', pdf);
|
|
updateLink('downloadLinkWord', docx);
|
|
updateLink('downloadLinkMd', md);
|
|
updateLink('downloadLinkJson', json);
|
|
|
|
// Update duplicate buttons above the report
|
|
updateLink('downloadLinkTop', pdf);
|
|
updateLink('downloadLinkWordTop', docx);
|
|
updateLink('downloadLinkMdTop', md);
|
|
updateLink('downloadLinkJsonTop', json);
|
|
|
|
// Make sure download buttons are visible when download links are ready
|
|
showDownloadPanels();
|
|
|
|
// Return links for history saving
|
|
return currentLinks;
|
|
}
|
|
|
|
const copyToClipboard = () => {
|
|
const textarea = document.createElement('textarea')
|
|
textarea.id = 'temp_element'
|
|
textarea.style.height = 0
|
|
document.body.appendChild(textarea)
|
|
textarea.value = document.getElementById('reportContainer').innerText
|
|
const selector = document.querySelector('#temp_element')
|
|
selector.select()
|
|
document.execCommand('copy')
|
|
document.body.removeChild(textarea)
|
|
|
|
// Show a temporary success message with icon change and toast notification
|
|
const copyBtn = document.getElementById('copyToClipboard');
|
|
const copyBtnTop = document.getElementById('copyToClipboardTop');
|
|
|
|
// Function to reset the icon for both buttons
|
|
const resetIcons = () => {
|
|
if (copyBtn) {
|
|
copyBtn.innerHTML = '<i class="fas fa-copy"></i> Copy';
|
|
}
|
|
if (copyBtnTop) {
|
|
copyBtnTop.innerHTML = '<i class="fas fa-copy"></i>';
|
|
}
|
|
};
|
|
|
|
// Change to green check mark
|
|
if (copyBtn) {
|
|
copyBtn.innerHTML = '<i class="fas fa-check" style="color: green;"></i> Copied!';
|
|
}
|
|
if (copyBtnTop) {
|
|
copyBtnTop.innerHTML = '<i class="fas fa-check" style="color: green;"></i>';
|
|
}
|
|
|
|
// Show toast notification
|
|
showToast('Copied to clipboard!');
|
|
|
|
// Reset the button after 3 seconds
|
|
setTimeout(resetIcons, 3000);
|
|
}
|
|
|
|
const updateState = (state) => {
|
|
var status = ''
|
|
switch (state) {
|
|
case 'in_progress':
|
|
status = 'Research in progress...'
|
|
setReportActionsStatus('disabled')
|
|
isResearchActive = true;
|
|
// Make the research icon spin
|
|
updateResearchIcon(true);
|
|
// Hide chat container during research
|
|
chatContainer = document.getElementById('chatContainer');
|
|
if (chatContainer) {
|
|
chatContainer.style.display = 'none';
|
|
}
|
|
// Hide the copy button in the header
|
|
const copyBtnTop = document.getElementById('copyToClipboardTop');
|
|
if (copyBtnTop) {
|
|
copyBtnTop.style.display = 'none';
|
|
}
|
|
// Hide the JSON button container
|
|
const jsonContainer = document.getElementById('jsonButtonContainer');
|
|
if (jsonContainer) {
|
|
jsonContainer.style.display = 'none';
|
|
}
|
|
break
|
|
case 'finished':
|
|
status = 'Research finished!'
|
|
setReportActionsStatus('enabled')
|
|
isResearchActive = false;
|
|
// Stop the research icon spinning
|
|
updateResearchIcon(false);
|
|
|
|
// Show download panels and hide feature panels when research is finished
|
|
showDownloadPanels();
|
|
|
|
// Enable the copy button
|
|
const copyButton = document.getElementById('copyToClipboard');
|
|
if (copyButton) {
|
|
copyButton.classList.remove('disabled');
|
|
}
|
|
|
|
// Show copy button in the header
|
|
const topCopyButton = document.getElementById('copyToClipboardTop');
|
|
if (topCopyButton) {
|
|
topCopyButton.style.display = 'inline-block';
|
|
topCopyButton.addEventListener('click', copyToClipboard);
|
|
}
|
|
|
|
// Show JSON button container
|
|
const jsonButtonContainer = document.getElementById('jsonButtonContainer');
|
|
if (jsonButtonContainer) {
|
|
jsonButtonContainer.style.display = 'block';
|
|
}
|
|
|
|
// Show chat container when research is finished
|
|
chatContainer = document.getElementById('chatContainer');
|
|
if (chatContainer) {
|
|
chatContainer.style.display = 'block';
|
|
// Initialize chat if not already initialized
|
|
initChat();
|
|
}
|
|
break
|
|
case 'error':
|
|
status = 'Research failed!'
|
|
setReportActionsStatus('disabled')
|
|
isResearchActive = false;
|
|
// Stop the research icon spinning
|
|
updateResearchIcon(false);
|
|
break
|
|
case 'initial':
|
|
status = ''
|
|
setReportActionsStatus('hidden')
|
|
isResearchActive = false;
|
|
// Make sure the research icon is not spinning initially
|
|
updateResearchIcon(false);
|
|
// Hide the copy button in the header
|
|
const initialCopyBtnTop = document.getElementById('copyToClipboardTop');
|
|
if (initialCopyBtnTop) {
|
|
initialCopyBtnTop.style.display = 'none';
|
|
}
|
|
// Hide the JSON button container
|
|
const initialJsonContainer = document.getElementById('jsonButtonContainer');
|
|
if (initialJsonContainer) {
|
|
initialJsonContainer.style.display = 'none';
|
|
}
|
|
break
|
|
default:
|
|
setReportActionsStatus('disabled')
|
|
}
|
|
document.getElementById('status').innerHTML = status
|
|
if (document.getElementById('status').innerHTML == '') {
|
|
document.getElementById('status').style.display = 'none'
|
|
} else {
|
|
document.getElementById('status').style.display = 'block'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shows or hides the download and copy buttons
|
|
* @param {str} status Kind of hacky. Takes "enabled", "disabled", or "hidden". "Hidden is same as disabled but also hides the div"
|
|
*/
|
|
const setReportActionsStatus = (status) => {
|
|
const reportActions = document.getElementById('reportActions')
|
|
// Disable everything in reportActions until research is finished
|
|
|
|
if (status == 'enabled') {
|
|
reportActions.querySelectorAll('a').forEach((link) => {
|
|
link.classList.remove('disabled')
|
|
link.removeAttribute('onclick')
|
|
reportActions.style.display = 'block'
|
|
})
|
|
} else {
|
|
reportActions.querySelectorAll('a').forEach((link) => {
|
|
link.classList.add('disabled')
|
|
link.setAttribute('onclick', 'return false;')
|
|
})
|
|
if (status == 'hidden') {
|
|
reportActions.style.display = 'none'
|
|
}
|
|
}
|
|
}
|
|
|
|
const tagsInput = document.getElementById('tags-input');
|
|
const input = document.getElementById('custom_source');
|
|
|
|
const tags = [];
|
|
|
|
const addTag = (url) => {
|
|
if (tags.includes(url)) return;
|
|
tags.push(url);
|
|
|
|
const tagElement = document.createElement('span');
|
|
tagElement.className = 'tag';
|
|
tagElement.textContent = url;
|
|
|
|
const removeButton = document.createElement('span');
|
|
removeButton.className = 'remove-tag';
|
|
removeButton.textContent = 'x';
|
|
removeButton.onclick = function () {
|
|
tagsInput.removeChild(tagElement);
|
|
tags.splice(tags.indexOf(url), 1);
|
|
};
|
|
|
|
tagElement.appendChild(removeButton);
|
|
tagsInput.insertBefore(tagElement, input);
|
|
}
|
|
|
|
const displaySelectedImages = (data) => {
|
|
const imageContainer = document.getElementById('selectedImagesContainer')
|
|
//imageContainer.innerHTML = '<h3>Selected Images</h3>'
|
|
const images = JSON.parse(data.output)
|
|
console.log("Received images:", images); // Debug log
|
|
if (images && images.length > 0) {
|
|
images.forEach(imageUrl => {
|
|
const imgElement = document.createElement('img')
|
|
imgElement.src = imageUrl
|
|
imgElement.alt = 'Research Image'
|
|
imgElement.style.maxWidth = '200px'
|
|
imgElement.style.margin = '5px'
|
|
imgElement.style.cursor = 'pointer'
|
|
imgElement.onclick = () => showImageDialog(imageUrl)
|
|
imageContainer.appendChild(imgElement)
|
|
})
|
|
imageContainer.style.display = 'block'
|
|
} else {
|
|
imageContainer.innerHTML += '<p>No images found for this research.</p>'
|
|
}
|
|
}
|
|
|
|
const showImageDialog = (imageUrl) => {
|
|
let dialog = document.querySelector('.image-dialog');
|
|
if (!dialog) {
|
|
dialog = document.createElement('div');
|
|
dialog.className = 'image-dialog';
|
|
|
|
const img = document.createElement('img');
|
|
img.alt = 'Full-size Research Image';
|
|
|
|
const closeBtn = document.createElement('button');
|
|
closeBtn.textContent = 'Close';
|
|
closeBtn.className = 'close-btn'; // Added class for styling
|
|
|
|
dialog.appendChild(img);
|
|
dialog.appendChild(closeBtn);
|
|
document.body.appendChild(dialog);
|
|
|
|
closeBtn.onclick = () => {
|
|
dialog.classList.remove('visible');
|
|
};
|
|
// Close on clicking backdrop
|
|
dialog.addEventListener('click', (e) => {
|
|
if (e.target === dialog) {
|
|
dialog.classList.remove('visible');
|
|
}
|
|
});
|
|
}
|
|
|
|
const imgElement = dialog.querySelector('img');
|
|
imgElement.src = imageUrl;
|
|
dialog.classList.add('visible');
|
|
|
|
// Close with Escape key
|
|
const escapeKeyListener = (e) => {
|
|
if (e.key === 'Escape') {
|
|
dialog.classList.remove('visible');
|
|
document.removeEventListener('keydown', escapeKeyListener);
|
|
}
|
|
};
|
|
document.addEventListener('keydown', escapeKeyListener);
|
|
}
|
|
|
|
// Function to show download bar and enable buttons
|
|
const showDownloadPanels = () => {
|
|
// Show the bar by adding the visible class
|
|
const stickyDownloadsBar = document.getElementById('stickyDownloadsBar');
|
|
if (stickyDownloadsBar) {
|
|
stickyDownloadsBar.classList.add('visible');
|
|
}
|
|
|
|
// Enable all download buttons
|
|
const downloadButtons = document.querySelectorAll('.download-option-btn, .report-action-btn');
|
|
downloadButtons.forEach(button => {
|
|
button.classList.remove('disabled');
|
|
});
|
|
|
|
// Make top buttons report-actions section visible
|
|
const reportActions = document.querySelector('.report-actions');
|
|
if (reportActions) {
|
|
reportActions.style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
// --- Storage Helpers (Cookies or LocalStorage) ---
|
|
function setCookie(name, value, days) {
|
|
// Maximum cookie size is around 4KB (4096 bytes)
|
|
const MAX_COOKIE_SIZE = 4000;
|
|
|
|
// If cookies are disabled, use localStorage instead
|
|
if (!cookiesEnabled) {
|
|
try {
|
|
localStorage.setItem(name, value);
|
|
console.debug(`Data saved to localStorage: ${name}`);
|
|
return true;
|
|
} catch (e) {
|
|
console.error("Error saving to localStorage:", e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
let expires = '';
|
|
if (days) {
|
|
const date = new Date();
|
|
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
|
|
expires = '; expires=' + date.toUTCString();
|
|
}
|
|
|
|
// Encode the value
|
|
const encodedValue = encodeURIComponent(value);
|
|
|
|
// Calculate cookie size
|
|
const cookieSize = (name + '=' + encodedValue + expires + '; path=/').length;
|
|
console.debug(`Setting cookie: ${name}, size: ${cookieSize} bytes`);
|
|
|
|
// If cookie is too large, display warning and truncate history
|
|
if (cookieSize > MAX_COOKIE_SIZE) {
|
|
console.warn(`Cookie size (${cookieSize} bytes) exceeds the ${MAX_COOKIE_SIZE} bytes limit!`);
|
|
showToast('Warning: History too large for cookie storage! Oldest entries will be removed.');
|
|
|
|
if (name === 'conversationHistory') {
|
|
try {
|
|
// Parse, reduce entries, and try again
|
|
const historyData = JSON.parse(value);
|
|
if (Array.isArray(historyData) && historyData.length > 1) {
|
|
// Remove the last entry and try again recursively
|
|
const reducedHistory = historyData.slice(0, -1);
|
|
console.debug(`Reducing history from ${historyData.length} to ${reducedHistory.length} entries`);
|
|
setCookie(name, JSON.stringify(reducedHistory), days);
|
|
return; // Exit after recursive call
|
|
}
|
|
} catch (e) {
|
|
console.error('Could not parse history to reduce size:', e);
|
|
}
|
|
}
|
|
|
|
return false; // Indicate failure
|
|
}
|
|
|
|
// Set the cookie
|
|
document.cookie = name + '=' + encodedValue + expires + '; path=/';
|
|
console.debug(`Cookie set: ${name}`);
|
|
return true; // Indicate success
|
|
}
|
|
|
|
function getCookie(name) {
|
|
console.debug(`Getting data: ${name}`);
|
|
|
|
// If cookies are disabled, use localStorage instead
|
|
if (!cookiesEnabled) {
|
|
try {
|
|
const value = localStorage.getItem(name);
|
|
if (value) {
|
|
console.debug(`Data found in localStorage: ${name}, length: ${value.length} chars`);
|
|
return value;
|
|
}
|
|
console.debug(`Data not found in localStorage: ${name}`);
|
|
return null;
|
|
} catch (e) {
|
|
console.error("Error retrieving from localStorage:", e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const nameEQ = name + '=';
|
|
const ca = document.cookie.split(';');
|
|
for (let i = 0; i < ca.length; i++) {
|
|
let c = ca[i];
|
|
while (c.charAt(0) == ' ') c = c.substring(1, c.length);
|
|
if (c.indexOf(nameEQ) == 0) {
|
|
const value = decodeURIComponent(c.substring(nameEQ.length, c.length));
|
|
console.debug(`Found cookie: ${name}, length: ${value.length} chars`);
|
|
return value;
|
|
}
|
|
}
|
|
console.debug(`Cookie not found: ${name}`);
|
|
return null;
|
|
}
|
|
|
|
function deleteCookie(name) {
|
|
console.debug(`Deleting storage: ${name}`);
|
|
|
|
// If cookies are disabled, use localStorage instead
|
|
if (!cookiesEnabled) {
|
|
try {
|
|
localStorage.removeItem(name);
|
|
return;
|
|
} catch (e) {
|
|
console.error("Error removing from localStorage:", e);
|
|
return;
|
|
}
|
|
}
|
|
|
|
document.cookie = name + '=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
|
}
|
|
// --- End Storage Helpers ---
|
|
|
|
// Debug Helper - check cookie status
|
|
const checkCookieStatus = () => {
|
|
if (!cookiesEnabled) {
|
|
const storageData = localStorage.getItem('conversationHistory');
|
|
if (storageData) {
|
|
const byteSize = new Blob([storageData]).size;
|
|
const kilobyteSize = (byteSize / 1024).toFixed(2);
|
|
|
|
try {
|
|
const parsed = JSON.parse(storageData);
|
|
const entryCount = Array.isArray(parsed) ? parsed.length : 0;
|
|
|
|
showToast(`Using localStorage: ${kilobyteSize}KB, ${entryCount} entries`);
|
|
console.debug(`LocalStorage size: ${byteSize} bytes, ${kilobyteSize}KB`);
|
|
console.debug(`LocalStorage entries: ${entryCount}`);
|
|
} catch (e) {
|
|
showToast(`LocalStorage contains invalid data: ${kilobyteSize}KB`);
|
|
console.error('LocalStorage parse error:', e);
|
|
}
|
|
} else {
|
|
showToast('No research history found in localStorage');
|
|
}
|
|
return;
|
|
}
|
|
|
|
const allCookies = document.cookie;
|
|
console.debug('All cookies:', allCookies);
|
|
|
|
const conversationCookie = getCookie('conversationHistory');
|
|
if (conversationCookie) {
|
|
const byteSize = new Blob([conversationCookie]).size;
|
|
const kilobyteSize = (byteSize / 1024).toFixed(2);
|
|
|
|
try {
|
|
const parsed = JSON.parse(conversationCookie);
|
|
const entryCount = Array.isArray(parsed) ? parsed.length : 0;
|
|
|
|
showToast(`Cookie found: ${kilobyteSize}KB, ${entryCount} research entries`);
|
|
console.debug(`Cookie size: ${byteSize} bytes, ${kilobyteSize}KB`);
|
|
console.debug(`Cookie entries: ${entryCount}`);
|
|
} catch (e) {
|
|
showToast(`Cookie found but invalid: ${kilobyteSize}KB`);
|
|
console.error('Cookie parse error:', e);
|
|
}
|
|
} else {
|
|
showToast('No research history cookie found');
|
|
}
|
|
}
|
|
|
|
// Export history to a downloadable JSON file
|
|
const exportHistory = () => {
|
|
try {
|
|
if (!conversationHistory || conversationHistory.length === 0) {
|
|
showToast('No research history to export');
|
|
return;
|
|
}
|
|
|
|
// Create a formatted JSON string with pretty-printing
|
|
const historyJson = JSON.stringify(conversationHistory, null, 2);
|
|
|
|
// Create a Blob containing the data
|
|
const blob = new Blob([historyJson], { type: 'application/json' });
|
|
|
|
// Create an object URL for the blob
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
// Create a temporary link element
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
|
|
// Set download attribute with filename
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
link.download = `research-history-${timestamp}.json`;
|
|
|
|
// Append to the document
|
|
document.body.appendChild(link);
|
|
|
|
// Programmatically click the link to trigger the download
|
|
link.click();
|
|
|
|
// Clean up
|
|
document.body.removeChild(link);
|
|
URL.revokeObjectURL(url);
|
|
|
|
showToast('Research history exported to JSON file');
|
|
console.debug('History exported, entries:', conversationHistory.length);
|
|
} catch (error) {
|
|
console.error('Error exporting history:', error);
|
|
showToast('Error exporting research history');
|
|
}
|
|
}
|
|
|
|
// Trigger the file input for importing history
|
|
const triggerImportHistory = () => {
|
|
const fileInput = document.getElementById('historyFileInput');
|
|
if (fileInput) {
|
|
fileInput.click();
|
|
} else {
|
|
showToast('Import functionality not available');
|
|
}
|
|
}
|
|
|
|
// Handle the file import for history
|
|
const handleFileImport = (event) => {
|
|
const file = event.target.files[0];
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = (e) => {
|
|
try {
|
|
const content = e.target.result;
|
|
const importedData = JSON.parse(content);
|
|
|
|
// Validate the imported data
|
|
if (!Array.isArray(importedData)) {
|
|
throw new Error('Imported data is not an array');
|
|
}
|
|
|
|
// Check if each entry has the required fields
|
|
const validEntries = importedData.filter(entry => {
|
|
return entry &&
|
|
typeof entry === 'object' &&
|
|
(entry.prompt || entry.task) && // Allow both prompt and legacy task field
|
|
(entry.links || entry.downloadLinks); // Allow both links and legacy downloadLinks
|
|
});
|
|
|
|
if (validEntries.length === 0) {
|
|
showToast('No valid research entries found in the imported file');
|
|
return;
|
|
}
|
|
|
|
// Map the entries to the current structure if needed
|
|
const mappedEntries = validEntries.map(entry => {
|
|
return {
|
|
prompt: entry.prompt || entry.task || '',
|
|
links: entry.links || entry.downloadLinks || {},
|
|
timestamp: entry.timestamp || new Date().toISOString()
|
|
};
|
|
});
|
|
|
|
// Confirm before overwriting existing history
|
|
if (conversationHistory && conversationHistory.length > 0) {
|
|
if (confirm(`You have ${conversationHistory.length} existing research entries. Do you want to:
|
|
- Click OK to MERGE imported history with existing history
|
|
- Click Cancel to REPLACE all existing history with imported data`)) {
|
|
// Merge with existing history
|
|
conversationHistory = [...mappedEntries, ...conversationHistory];
|
|
} else {
|
|
// Replace existing history
|
|
conversationHistory = mappedEntries;
|
|
}
|
|
} else {
|
|
// No existing history, just set the imported data
|
|
conversationHistory = mappedEntries;
|
|
}
|
|
|
|
// Save the new history and update the UI
|
|
saveConversationHistory();
|
|
renderHistoryEntries();
|
|
|
|
showToast(`Successfully imported ${validEntries.length} research entries`);
|
|
console.debug('Research history imported, valid entries:', validEntries.length);
|
|
|
|
} catch (error) {
|
|
console.error('Error importing history:', error);
|
|
showToast('Error importing research history: Invalid file format');
|
|
}
|
|
|
|
// Reset the file input so the same file can be selected again
|
|
event.target.value = '';
|
|
};
|
|
|
|
reader.onerror = () => {
|
|
console.error('Error reading file');
|
|
showToast('Error reading the imported file');
|
|
event.target.value = '';
|
|
};
|
|
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
// Initialize chat functionality
|
|
const initChat = () => {
|
|
const chatInput = document.getElementById('chatInput');
|
|
const sendChatBtn = document.getElementById('sendChatBtn');
|
|
const voiceInputBtn = document.getElementById('voiceInputBtn');
|
|
|
|
if (!chatInput || !sendChatBtn) return;
|
|
|
|
// Clear previous messages
|
|
const chatMessages = document.getElementById('chatMessages');
|
|
if (chatMessages) {
|
|
chatMessages.innerHTML = '';
|
|
}
|
|
|
|
// Add event listeners for chat input
|
|
chatInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendChatMessage();
|
|
}
|
|
});
|
|
|
|
sendChatBtn.addEventListener('click', sendChatMessage);
|
|
|
|
// Initialize speech recognition if supported
|
|
if (voiceInputBtn) {
|
|
initSpeechRecognition(voiceInputBtn, chatInput);
|
|
}
|
|
|
|
// Auto-resize textarea as content grows
|
|
chatInput.addEventListener('input', () => {
|
|
chatInput.style.height = 'auto';
|
|
chatInput.style.height = (chatInput.scrollHeight) + 'px';
|
|
});
|
|
|
|
// Add welcome message
|
|
addChatMessage('I can answer questions about the research report. What would you like to know?', false);
|
|
}
|
|
|
|
// Initialize speech recognition
|
|
const initSpeechRecognition = (button, inputElement) => {
|
|
// Check if browser supports speech recognition
|
|
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
|
|
|
if (!SpeechRecognition) {
|
|
console.warn('Speech recognition not supported in this browser');
|
|
button.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
const recognition = new SpeechRecognition();
|
|
|
|
// Configure speech recognition
|
|
recognition.continuous = false;
|
|
recognition.lang = 'en-US';
|
|
recognition.interimResults = true;
|
|
recognition.maxAlternatives = 1;
|
|
|
|
let isListening = false;
|
|
let finalTranscript = '';
|
|
|
|
// Add event listeners for speech recognition
|
|
recognition.onstart = () => {
|
|
isListening = true;
|
|
finalTranscript = '';
|
|
button.classList.add('listening');
|
|
button.innerHTML = '<i class="fas fa-microphone-slash"></i>';
|
|
button.title = 'Stop listening';
|
|
|
|
// Show visual feedback
|
|
showToast('Listening...', 1000);
|
|
};
|
|
|
|
recognition.onresult = (event) => {
|
|
let interimTranscript = '';
|
|
|
|
// Loop through the results
|
|
for (let i = event.resultIndex; i < event.results.length; i++) {
|
|
const transcript = event.results[i][0].transcript;
|
|
|
|
if (event.results[i].isFinal) {
|
|
finalTranscript += transcript;
|
|
} else {
|
|
interimTranscript += transcript;
|
|
}
|
|
}
|
|
|
|
// Update the input element with the transcription
|
|
inputElement.value = finalTranscript + interimTranscript;
|
|
|
|
// Trigger input event to resize textarea
|
|
const inputEvent = new Event('input', { bubbles: true });
|
|
inputElement.dispatchEvent(inputEvent);
|
|
};
|
|
|
|
recognition.onerror = (event) => {
|
|
console.error('Speech recognition error', event.error);
|
|
resetRecognition();
|
|
|
|
if (event.error === 'not-allowed') {
|
|
showToast('Microphone access denied. Please allow microphone access in your browser settings.', 3000);
|
|
} else {
|
|
showToast('Speech recognition error: ' + event.error, 3000);
|
|
}
|
|
};
|
|
|
|
recognition.onend = () => {
|
|
resetRecognition();
|
|
};
|
|
|
|
// Reset the recognition state
|
|
const resetRecognition = () => {
|
|
isListening = false;
|
|
button.classList.remove('listening');
|
|
button.innerHTML = '<i class="fas fa-microphone"></i>';
|
|
button.title = 'Use voice input';
|
|
};
|
|
|
|
// Toggle speech recognition on button click
|
|
button.addEventListener('click', () => {
|
|
if (isListening) {
|
|
recognition.stop();
|
|
} else {
|
|
recognition.start();
|
|
}
|
|
});
|
|
};
|
|
|
|
// Create a new function to handle WebSocket reconnection
|
|
const reconnectWebSocket = (message = null) => {
|
|
// Don't attempt too many reconnections
|
|
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
console.error(`Failed to reconnect after ${maxReconnectAttempts} attempts`);
|
|
addChatMessage(`Unable to reconnect after ${maxReconnectAttempts} attempts. Please refresh the page.`, false);
|
|
return false;
|
|
}
|
|
|
|
reconnectAttempts++;
|
|
|
|
// Calculate backoff time (exponential backoff)
|
|
const backoff = reconnectInterval * Math.pow(1.5, reconnectAttempts - 1);
|
|
console.log(`Attempting to reconnect (${reconnectAttempts}/${maxReconnectAttempts}) in ${backoff}ms...`);
|
|
|
|
// Show reconnection status to user
|
|
addChatMessage(`Connection lost. Attempting to reconnect (${reconnectAttempts}/${maxReconnectAttempts})...`, false);
|
|
|
|
// Try to reconnect after delay
|
|
setTimeout(() => {
|
|
try {
|
|
// Setup new WebSocket connection
|
|
dispose_socket = listenToSockEvents();
|
|
|
|
// Set up a one-time handler to send the message after reconnection
|
|
if (message) {
|
|
const messageToSend = message;
|
|
const checkConnectionAndSend = () => {
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
console.log("Reconnected successfully, sending queued message");
|
|
socket.send(messageToSend);
|
|
return true;
|
|
} else if (reconnectAttempts < maxReconnectAttempts) {
|
|
console.log("Socket not ready yet, retrying...");
|
|
setTimeout(checkConnectionAndSend, 1000);
|
|
return false;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
setTimeout(checkConnectionAndSend, 1000);
|
|
}
|
|
|
|
return true;
|
|
} catch (e) {
|
|
console.error("Error during reconnection:", e);
|
|
return false;
|
|
}
|
|
}, backoff);
|
|
|
|
return true;
|
|
};
|
|
|
|
// Send a chat message
|
|
const sendChatMessage = () => {
|
|
const chatInput = document.getElementById('chatInput');
|
|
if (!chatInput || !chatInput.value.trim()) return;
|
|
|
|
const message = chatInput.value.trim();
|
|
|
|
// Add user message to chat
|
|
addChatMessage(message, true);
|
|
|
|
// Clear input
|
|
chatInput.value = '';
|
|
chatInput.style.height = 'auto';
|
|
|
|
// Add loading indicator
|
|
const loadingId = addLoadingIndicator();
|
|
|
|
// Prepare the message to send
|
|
const messageToSend = `chat ${JSON.stringify({ message: message })}`;
|
|
|
|
// Send message through WebSocket
|
|
if (socket && socket.readyState === WebSocket.OPEN) {
|
|
socket.send(messageToSend);
|
|
} else {
|
|
// If socket is closed, try to reconnect
|
|
removeLoadingIndicator(loadingId);
|
|
|
|
// Reset reconnect attempts if this is a new chat session
|
|
if (reconnectAttempts >= maxReconnectAttempts) {
|
|
reconnectAttempts = 0;
|
|
}
|
|
|
|
// Attempt to reconnect and queue the message to be sent after reconnection
|
|
if (!reconnectWebSocket(messageToSend)) {
|
|
// If reconnection fails or max attempts reached
|
|
addChatMessage('Unable to send message. Connection is unavailable.', false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add a chat message to the UI
|
|
const addChatMessage = (message, isUser = false) => {
|
|
const chatMessages = document.getElementById('chatMessages');
|
|
if (!chatMessages) return;
|
|
|
|
const messageEl = document.createElement('div');
|
|
messageEl.className = `chat-message ${isUser ? 'user-message' : 'ai-message'}`;
|
|
|
|
// Process message for AI responses (convert markdown to HTML for AI messages)
|
|
let processedMessage = message;
|
|
if (!isUser) {
|
|
// Use showdown for markdown conversion
|
|
const converter = new showdown.Converter({
|
|
ghCodeBlocks: true,
|
|
tables: true,
|
|
tasklists: true,
|
|
openLinksInNewWindow: true
|
|
});
|
|
processedMessage = converter.makeHtml(message);
|
|
}
|
|
|
|
// Set message content
|
|
messageEl.innerHTML = isUser ? escapeHtml(processedMessage) : processedMessage;
|
|
|
|
// Add timestamp
|
|
const timestampEl = document.createElement('div');
|
|
timestampEl.className = 'chat-timestamp';
|
|
const now = new Date();
|
|
timestampEl.textContent = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
messageEl.appendChild(timestampEl);
|
|
|
|
// Add to chat container
|
|
chatMessages.appendChild(messageEl);
|
|
|
|
// Scroll to bottom
|
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
}
|
|
|
|
// Add a loading indicator
|
|
const addLoadingIndicator = () => {
|
|
const chatMessages = document.getElementById('chatMessages');
|
|
if (!chatMessages) return null;
|
|
|
|
const loadingId = 'loading-' + Date.now();
|
|
const loadingEl = document.createElement('div');
|
|
loadingEl.className = 'chat-message ai-message chat-loading';
|
|
loadingEl.id = loadingId;
|
|
|
|
// Create the dots
|
|
for (let i = 0; i < 3; i++) {
|
|
const dot = document.createElement('div');
|
|
dot.className = 'chat-dot';
|
|
loadingEl.appendChild(dot);
|
|
}
|
|
|
|
chatMessages.appendChild(loadingEl);
|
|
chatMessages.scrollTop = chatMessages.scrollHeight;
|
|
|
|
return loadingId;
|
|
}
|
|
|
|
// Remove loading indicator
|
|
const removeLoadingIndicator = (loadingId) => {
|
|
if (!loadingId) return;
|
|
|
|
const loadingEl = document.getElementById(loadingId);
|
|
if (loadingEl) {
|
|
loadingEl.remove();
|
|
}
|
|
}
|
|
|
|
// Escape HTML to prevent XSS in user messages
|
|
const escapeHtml = (text) => {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Initialize expand buttons
|
|
const initExpandButtons = () => {
|
|
// Report container expand button
|
|
const expandReportBtn = document.getElementById('expandReportBtn');
|
|
if (expandReportBtn) {
|
|
expandReportBtn.addEventListener('click', () => {
|
|
const reportContainer = document.querySelector('.report-container');
|
|
toggleExpand(reportContainer);
|
|
});
|
|
}
|
|
|
|
// Chat container expand button
|
|
const expandChatBtn = document.getElementById('expandChatBtn');
|
|
if (expandChatBtn) {
|
|
expandChatBtn.addEventListener('click', () => {
|
|
const chatContainer = document.getElementById('chatContainer');
|
|
toggleExpand(chatContainer);
|
|
});
|
|
}
|
|
|
|
// Output container expand button
|
|
const expandOutputBtn = document.getElementById('expandOutputBtn');
|
|
if (expandOutputBtn) {
|
|
expandOutputBtn.addEventListener('click', () => {
|
|
const outputContainer = document.querySelector('.research-output-container');
|
|
toggleExpand(outputContainer);
|
|
});
|
|
}
|
|
|
|
// Close expanded view when ESC key is pressed
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
const expandedElements = document.querySelectorAll('.expanded-view');
|
|
expandedElements.forEach(el => {
|
|
// Reset the button icon
|
|
const button = el.querySelector('.expand-button i');
|
|
if (button) {
|
|
button.classList.remove('fa-compress-alt');
|
|
button.classList.add('fa-expand-alt');
|
|
}
|
|
|
|
// Reset content container styles
|
|
const contentContainers = el.querySelectorAll('#reportContainer, #output, #chatMessages');
|
|
contentContainers.forEach(container => {
|
|
if (container) {
|
|
container.style.maxHeight = '';
|
|
}
|
|
});
|
|
|
|
// Remove expanded-view class
|
|
el.classList.remove('expanded-view');
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Toggle expand mode for an element
|
|
const toggleExpand = (element) => {
|
|
if (!element) return;
|
|
|
|
// Toggle expanded-view class
|
|
element.classList.toggle('expanded-view');
|
|
|
|
// Change button icon and title based on state
|
|
const buttonIcon = element.querySelector('.expand-button i');
|
|
const button = element.querySelector('.expand-button');
|
|
|
|
if (buttonIcon && button) {
|
|
if (element.classList.contains('expanded-view')) {
|
|
buttonIcon.classList.remove('fa-compress-alt');
|
|
buttonIcon.classList.add('fa-compress-alt');
|
|
button.title = 'Collapse'; // Update title to Collapse
|
|
|
|
// Find content containers and expand their height
|
|
const contentContainers = element.querySelectorAll('#reportContainer, #output, #chatMessages');
|
|
contentContainers.forEach(container => {
|
|
if (container) {
|
|
// Set expanded heights - no positioning changes
|
|
if (container.id === 'reportContainer') {
|
|
container.style.maxHeight = '800px'; // Fixed expanded height for report
|
|
} else {
|
|
container.style.maxHeight = '600px'; // Fixed expanded height for other content
|
|
}
|
|
}
|
|
});
|
|
} else {
|
|
buttonIcon.classList.remove('fa-compress-alt');
|
|
buttonIcon.classList.add('fa-expand-alt');
|
|
button.title = 'Expand'; // Update title to Expand
|
|
|
|
// Reset heights back to original when collapsed
|
|
const contentContainers = element.querySelectorAll('#reportContainer, #output, #chatMessages');
|
|
contentContainers.forEach(container => {
|
|
if (container) {
|
|
container.style.maxHeight = '';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// MCP Configuration Management
|
|
|
|
// Initialize MCP functionality
|
|
const initMCPSection = () => {
|
|
const mcpEnabled = document.getElementById('mcpEnabled');
|
|
const mcpConfigSection = document.getElementById('mcpConfigSection');
|
|
const mcpInfoBtn = document.getElementById('mcpInfoBtn');
|
|
const mcpConfig = document.getElementById('mcpConfig');
|
|
const mcpFormatBtn = document.getElementById('mcpFormatBtn');
|
|
const mcpExampleLink = document.getElementById('mcpExampleLink');
|
|
|
|
if (!mcpEnabled || !mcpConfigSection) {
|
|
console.warn('MCP elements not found');
|
|
return;
|
|
}
|
|
|
|
// Toggle MCP config section
|
|
mcpEnabled.addEventListener('change', () => {
|
|
if (mcpEnabled.checked) {
|
|
mcpConfigSection.style.display = 'block';
|
|
updateRetrieverForMCP(true);
|
|
} else {
|
|
mcpConfigSection.style.display = 'none';
|
|
updateRetrieverForMCP(false);
|
|
}
|
|
});
|
|
|
|
// MCP info modal
|
|
if (mcpInfoBtn) {
|
|
mcpInfoBtn.addEventListener('click', showMCPInfo);
|
|
}
|
|
|
|
// JSON validation and formatting
|
|
if (mcpConfig) {
|
|
mcpConfig.addEventListener('input', validateMCPConfig);
|
|
mcpConfig.addEventListener('blur', validateMCPConfig);
|
|
}
|
|
|
|
if (mcpFormatBtn) {
|
|
mcpFormatBtn.addEventListener('click', formatMCPConfig);
|
|
}
|
|
|
|
if (mcpExampleLink) {
|
|
mcpExampleLink.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
showMCPExample();
|
|
});
|
|
}
|
|
|
|
// Preset buttons
|
|
const presetButtons = document.querySelectorAll('.preset-btn');
|
|
presetButtons.forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const preset = e.currentTarget.dataset.preset;
|
|
addMCPPreset(preset);
|
|
});
|
|
});
|
|
|
|
// Create MCP info modal
|
|
createMCPInfoModal();
|
|
|
|
// Initial validation
|
|
validateMCPConfig();
|
|
};
|
|
|
|
// Validate MCP JSON configuration
|
|
const validateMCPConfig = () => {
|
|
const mcpConfig = document.getElementById('mcpConfig');
|
|
const mcpConfigStatus = document.getElementById('mcpConfigStatus');
|
|
|
|
if (!mcpConfig || !mcpConfigStatus) return;
|
|
|
|
const configText = mcpConfig.value.trim();
|
|
|
|
if (!configText || configText === '[]') {
|
|
mcpConfig.className = 'form-control mcp-config-textarea';
|
|
mcpConfigStatus.textContent = 'Empty configuration';
|
|
mcpConfigStatus.className = 'mcp-status-text';
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(configText);
|
|
|
|
if (!Array.isArray(parsed)) {
|
|
throw new Error('Configuration must be an array');
|
|
}
|
|
|
|
// Validate each server config
|
|
const errors = [];
|
|
parsed.forEach((server, index) => {
|
|
if (!server.name) {
|
|
errors.push(`Server ${index + 1}: missing name`);
|
|
}
|
|
if (!server.command && !server.connection_url) {
|
|
errors.push(`Server ${index + 1}: missing command or connection_url`);
|
|
}
|
|
});
|
|
|
|
if (errors.length > 0) {
|
|
throw new Error(errors.join('; '));
|
|
}
|
|
|
|
mcpConfig.className = 'form-control mcp-config-textarea valid';
|
|
mcpConfigStatus.textContent = `Valid JSON ✓ (${parsed.length} server${parsed.length !== 1 ? 's' : ''})`;
|
|
mcpConfigStatus.className = 'mcp-status-text valid';
|
|
return true;
|
|
|
|
} catch (error) {
|
|
mcpConfig.className = 'form-control mcp-config-textarea invalid';
|
|
mcpConfigStatus.textContent = `Invalid JSON: ${error.message}`;
|
|
mcpConfigStatus.className = 'mcp-status-text invalid';
|
|
return false;
|
|
}
|
|
};
|
|
|
|
// Format MCP JSON configuration
|
|
const formatMCPConfig = () => {
|
|
const mcpConfig = document.getElementById('mcpConfig');
|
|
if (!mcpConfig) return;
|
|
|
|
try {
|
|
const parsed = JSON.parse(mcpConfig.value.trim() || '[]');
|
|
mcpConfig.value = JSON.stringify(parsed, null, 2);
|
|
validateMCPConfig();
|
|
showToast('JSON formatted successfully!');
|
|
} catch (error) {
|
|
showToast('Cannot format invalid JSON');
|
|
}
|
|
};
|
|
|
|
// Show MCP configuration example
|
|
const showMCPExample = () => {
|
|
const exampleConfig = [
|
|
{
|
|
"name": "github",
|
|
"command": "npx",
|
|
"args": ["-y", "@modelcontextprotocol/server-github"],
|
|
"env": {
|
|
"GITHUB_PERSONAL_ACCESS_TOKEN": "your_github_token_here"
|
|
}
|
|
},
|
|
{
|
|
"name": "filesystem",
|
|
"command": "npx",
|
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/directory"],
|
|
"env": {}
|
|
}
|
|
];
|
|
|
|
const mcpConfig = document.getElementById('mcpConfig');
|
|
if (mcpConfig) {
|
|
mcpConfig.value = JSON.stringify(exampleConfig, null, 2);
|
|
validateMCPConfig();
|
|
showToast('Example configuration loaded!');
|
|
}
|
|
};
|
|
|
|
// Update retriever configuration for MCP
|
|
const updateRetrieverForMCP = (enableMCP) => {
|
|
if (enableMCP) {
|
|
showToast('MCP enabled - will be included in research process');
|
|
} else {
|
|
showToast('MCP disabled - using web search only');
|
|
}
|
|
};
|
|
|
|
// Show MCP information modal
|
|
const showMCPInfo = () => {
|
|
const modal = document.getElementById('mcpInfoModal');
|
|
if (modal) {
|
|
modal.classList.add('visible');
|
|
}
|
|
};
|
|
|
|
// Create MCP info modal
|
|
const createMCPInfoModal = () => {
|
|
if (document.getElementById('mcpInfoModal')) return;
|
|
|
|
const modal = document.createElement('div');
|
|
modal.id = 'mcpInfoModal';
|
|
modal.className = 'mcp-info-modal';
|
|
|
|
modal.innerHTML = `
|
|
<div class="mcp-info-content">
|
|
<button class="mcp-info-close" onclick="closeMCPInfo()">
|
|
<i class="fas fa-times"></i>
|
|
</button>
|
|
<h3>Model Context Protocol (MCP)</h3>
|
|
<p>MCP enables GPT Researcher to connect with external tools and data sources through a standardized protocol.</p>
|
|
|
|
<h4 class="highlight">Benefits:</h4>
|
|
<ul>
|
|
<li><span class="highlight">Access Local Data:</span> Connect to databases, file systems, and APIs</li>
|
|
<li><span class="highlight">Use External Tools:</span> Integrate with web services and third-party tools</li>
|
|
<li><span class="highlight">Extend Capabilities:</span> Add custom functionality through MCP servers</li>
|
|
<li><span class="highlight">Maintain Security:</span> Controlled access with proper authentication</li>
|
|
</ul>
|
|
|
|
<h4 class="highlight">Quick Start:</h4>
|
|
<ul>
|
|
<li>Enable MCP using the checkbox above</li>
|
|
<li>Click a preset to add pre-configured servers to the JSON</li>
|
|
<li>Or paste your own MCP configuration as a JSON array</li>
|
|
<li>Start your research - MCP will run with optimal settings</li>
|
|
</ul>
|
|
|
|
<h4 class="highlight">Configuration Format:</h4>
|
|
<p>Each MCP server should be a JSON object with these properties:</p>
|
|
<ul>
|
|
<li><span class="highlight">name:</span> Unique identifier (e.g., "github", "filesystem")</li>
|
|
<li><span class="highlight">command:</span> Command to run the server (e.g., "npx", "python")</li>
|
|
<li><span class="highlight">args:</span> Array of arguments (e.g., ["-y", "@modelcontextprotocol/server-github"])</li>
|
|
<li><span class="highlight">env:</span> Object with environment variables (e.g., {"API_KEY": "your_key"})</li>
|
|
</ul>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(modal);
|
|
|
|
// Close modal when clicking outside
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
modal.classList.remove('visible');
|
|
}
|
|
});
|
|
|
|
// Close with Escape key
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && modal.classList.contains('visible')) {
|
|
modal.classList.remove('visible');
|
|
}
|
|
});
|
|
};
|
|
|
|
// Close MCP info modal
|
|
window.closeMCPInfo = () => {
|
|
const modal = document.getElementById('mcpInfoModal');
|
|
if (modal) {
|
|
modal.classList.remove('visible');
|
|
}
|
|
};
|
|
|
|
// Add MCP preset configurations
|
|
const addMCPPreset = (preset) => {
|
|
const presets = {
|
|
github: {
|
|
"name": "github",
|
|
"command": "npx",
|
|
"args": ["-y", "@modelcontextprotocol/server-github"],
|
|
"env": {
|
|
"GITHUB_PERSONAL_ACCESS_TOKEN": "your_github_token_here"
|
|
}
|
|
},
|
|
tavily: {
|
|
"name": "tavily",
|
|
"command": "npx",
|
|
"args": ["-y", "tavily-mcp@0.1.2"],
|
|
"env": {
|
|
"TAVILY_API_KEY": "your_tavily_api_key_here"
|
|
}
|
|
},
|
|
filesystem: {
|
|
"name": "filesystem",
|
|
"command": "npx",
|
|
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/allowed/directory"],
|
|
"env": {}
|
|
}
|
|
};
|
|
|
|
const config = presets[preset];
|
|
if (!config) return;
|
|
|
|
const mcpConfig = document.getElementById('mcpConfig');
|
|
if (!mcpConfig) return;
|
|
|
|
try {
|
|
let currentConfig = [];
|
|
const currentText = mcpConfig.value.trim();
|
|
|
|
if (currentText && currentText !== '[]') {
|
|
currentConfig = JSON.parse(currentText);
|
|
}
|
|
|
|
// Check if server already exists
|
|
const existingIndex = currentConfig.findIndex(server => server.name === config.name);
|
|
|
|
if (existingIndex !== -1) {
|
|
// Replace existing server
|
|
currentConfig[existingIndex] = config;
|
|
showToast(`Updated ${preset} MCP server configuration`);
|
|
} else {
|
|
// Add new server
|
|
currentConfig.push(config);
|
|
showToast(`Added ${preset} MCP server configuration`);
|
|
}
|
|
|
|
mcpConfig.value = JSON.stringify(currentConfig, null, 2);
|
|
validateMCPConfig();
|
|
|
|
} catch (error) {
|
|
console.error('Error adding preset:', error);
|
|
showToast('Error adding preset configuration');
|
|
}
|
|
};
|
|
|
|
// Collect MCP configuration data
|
|
const collectMCPData = () => {
|
|
const mcpEnabled = document.getElementById('mcpEnabled');
|
|
if (!mcpEnabled || !mcpEnabled.checked) {
|
|
return null;
|
|
}
|
|
|
|
const mcpConfig = document.getElementById('mcpConfig');
|
|
|
|
if (!mcpConfig) {
|
|
console.warn('MCP config element not found for data collection');
|
|
return null;
|
|
}
|
|
|
|
// Validate configuration before collecting
|
|
if (!validateMCPConfig()) {
|
|
showToast('Invalid MCP configuration - please fix errors before submitting');
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const configText = mcpConfig.value.trim();
|
|
const mcpConfigs = configText && configText !== '[]' ? JSON.parse(configText) : [];
|
|
|
|
return {
|
|
mcp_enabled: true,
|
|
mcp_strategy: "fast", // Always use "fast" strategy as default
|
|
mcp_configs: mcpConfigs
|
|
};
|
|
} catch (error) {
|
|
console.error('Error collecting MCP data:', error);
|
|
showToast('Error processing MCP configuration');
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return {
|
|
init,
|
|
startResearch,
|
|
addTag,
|
|
copyToClipboard,
|
|
displaySelectedImages,
|
|
showImageDialog,
|
|
checkCookieStatus,
|
|
exportHistory,
|
|
importHistory: triggerImportHistory, // Add import function to return object
|
|
initChat,
|
|
sendChatMessage,
|
|
addChatMessage
|
|
}
|
|
})()
|
|
|
|
window.addEventListener('DOMContentLoaded', GPTResearcher.init)
|