adeyemi001's picture
Update frontend/script.js
6ce3c99 verified
// API Configuration - Use relative URL for HuggingFace
const API_BASE_URL = window.location.origin;
// Session Management
let currentSessionId = null;
let conversationHistory = []; // Store all Q&A pairs
let queryHistory = [];
// DOM Elements
const queryInput = document.getElementById('queryInput');
const submitBtn = document.getElementById('submitBtn');
const btnText = submitBtn.querySelector('.btn-text');
const loader = submitBtn.querySelector('.loader');
const responseSection = document.getElementById('responseSection');
const initialState = document.getElementById('initialState');
const loadingState = document.getElementById('loadingState');
const answerDiv = document.getElementById('answer');
const sourcesDiv = document.getElementById('sources');
const errorToast = document.getElementById('errorToast');
const errorMessage = document.getElementById('errorMessage');
const expandedQueriesDiv = document.getElementById('expandedQueries');
const queriesList = document.getElementById('queriesList');
const exportBtn = document.getElementById('exportBtn');
const githubBtn = document.getElementById('githubBtn');
const historySelect = document.getElementById('historySelect');
// Initialize
function init() {
currentSessionId = sessionStorage.getItem('sessionId') || generateSessionId();
sessionStorage.setItem('sessionId', currentSessionId);
// Load history
const saved = sessionStorage.getItem('queryHistory');
if (saved) {
try {
queryHistory = JSON.parse(saved);
updateHistoryDropdown();
} catch (e) {
queryHistory = [];
}
}
setupEventListeners();
checkHealth();
}
function generateSessionId() {
return `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
function setupEventListeners() {
// Submit button
if (submitBtn) {
submitBtn.addEventListener('click', handleSubmit);
}
// Enter key (Ctrl+Enter)
if (queryInput) {
queryInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.ctrlKey) {
handleSubmit();
}
});
}
// Example queries
document.querySelectorAll('.example-item').forEach(item => {
item.addEventListener('click', () => {
const query = item.getAttribute('data-query');
if (query && queryInput) {
queryInput.value = query;
handleSubmit();
}
});
});
// Export button
if (exportBtn) {
exportBtn.addEventListener('click', exportToPDF);
}
// GitHub button
if (githubBtn) {
githubBtn.addEventListener('click', () => {
window.open('https://github.com/Adeyemi0/FinSight-RAG-Application-', '_blank');
});
}
// History dropdown
if (historySelect) {
historySelect.addEventListener('change', (e) => {
const query = e.target.value;
if (query && queryInput) {
queryInput.value = query;
}
});
}
}
// Update history dropdown
function updateHistoryDropdown() {
if (!historySelect) return;
historySelect.innerHTML = '<option value="">Select a recent query</option>';
queryHistory.slice().reverse().forEach((item, index) => {
const option = document.createElement('option');
option.value = item.query;
option.textContent = item.query.substring(0, 60) + (item.query.length > 60 ? '...' : '');
historySelect.appendChild(option);
});
}
// Save to history
function saveToHistory(query, answer) {
queryHistory.push({
query,
answer: answer.substring(0, 500),
timestamp: new Date().toISOString()
});
// Keep last 20
if (queryHistory.length > 20) {
queryHistory = queryHistory.slice(-20);
}
try {
sessionStorage.setItem('queryHistory', JSON.stringify(queryHistory));
updateHistoryDropdown();
} catch (e) {
console.error('Failed to save history:', e);
}
}
// Main submit handler
async function handleSubmit() {
if (!queryInput) return;
const query = queryInput.value.trim();
if (!query) {
showError('Please enter a question');
return;
}
// Show loading
setLoading(true);
hideError();
// Show loading indicator
if (loadingState) loadingState.style.display = 'flex';
// Hide initial state on first query
if (initialState && conversationHistory.length === 0) {
initialState.style.display = 'none';
}
// Show response section
if (responseSection) responseSection.style.display = 'block';
try {
// Extract ticker from query if mentioned
const tickerMatch = query.match(/\b([A-Z]{1,5})\b/);
const detectedTicker = tickerMatch ? tickerMatch[1] : null;
const requestData = {
query,
ticker: detectedTicker,
doc_types: null,
top_k: 10,
session_id: currentSessionId
};
console.log('Request data:', requestData);
const response = await fetch(`${API_BASE_URL}/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail || `HTTP ${response.status}`);
}
const data = await response.json();
if (!data) {
throw new Error('Empty response from server');
}
// Add to conversation history
conversationHistory.push({
query,
answer: data.answer,
sources: data.sources,
expanded_queries: data.expanded_queries,
timestamp: new Date()
});
// Save to history
saveToHistory(query, data.answer || '');
// Display all conversation history
displayConversationHistory();
// Clear input
queryInput.value = '';
// Scroll to latest answer
setTimeout(() => {
const rightPanel = document.querySelector('.right-panel');
if (rightPanel) {
rightPanel.scrollTop = rightPanel.scrollHeight;
}
}, 100);
} catch (error) {
console.error('Error:', error);
showError(error.message || 'Failed to process query');
// Show initial state again if no history
if (conversationHistory.length === 0) {
if (loadingState) loadingState.style.display = 'none';
if (initialState) initialState.style.display = 'flex';
}
} finally {
setLoading(false);
if (loadingState) loadingState.style.display = 'none';
}
}
// Display entire conversation history
function displayConversationHistory() {
if (!answerDiv || !sourcesDiv) return;
// Clear existing content
answerDiv.innerHTML = '';
sourcesDiv.innerHTML = '';
// Render each Q&A pair
conversationHistory.forEach((item, index) => {
// Create conversation item container
const conversationItem = document.createElement('div');
conversationItem.className = 'conversation-item';
conversationItem.style.cssText = `
margin-bottom: 2rem;
padding-bottom: 2rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
`;
// Add question
const questionDiv = document.createElement('div');
questionDiv.className = 'question-block';
questionDiv.style.cssText = `
background: rgba(59, 130, 246, 0.1);
border-left: 3px solid #3b82f6;
padding: 1rem 1.5rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
`;
questionDiv.innerHTML = `
<div style="color: #60a5fa; font-size: 0.875rem; margin-bottom: 0.5rem;">Question ${index + 1}</div>
<div style="color: #e5e7eb; font-size: 1rem;">${escapeHtml(item.query)}</div>
`;
conversationItem.appendChild(questionDiv);
// Add answer
const answerBlock = document.createElement('div');
answerBlock.className = 'answer-block';
answerBlock.style.cssText = `
background: rgba(16, 185, 129, 0.05);
border-left: 3px solid #10b981;
padding: 1rem 1.5rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
`;
answerBlock.innerHTML = `
<div style="color: #34d399; font-size: 0.875rem; margin-bottom: 0.5rem;">Answer</div>
<div style="color: #e5e7eb; line-height: 1.7;">${formatAnswer(item.answer || 'No answer available')}</div>
`;
conversationItem.appendChild(answerBlock);
// Add expanded queries if available
if (item.expanded_queries && item.expanded_queries.length > 1) {
const expandedDiv = document.createElement('div');
expandedDiv.className = 'expanded-queries-block';
expandedDiv.style.cssText = `
background: rgba(139, 92, 246, 0.05);
border-left: 3px solid #8b5cf6;
padding: 1rem 1.5rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
`;
expandedDiv.innerHTML = `
<div style="color: #a78bfa; font-size: 0.875rem; margin-bottom: 0.5rem;">Expanded Queries</div>
<ul style="margin: 0; padding-left: 1.5rem; color: #d1d5db;">
${item.expanded_queries.map(q => `<li style="margin-bottom: 0.5rem;">${escapeHtml(q)}</li>`).join('')}
</ul>
`;
conversationItem.appendChild(expandedDiv);
}
// Add sources
if (item.sources && item.sources.length > 0) {
const sourcesHeader = document.createElement('div');
sourcesHeader.style.cssText = `
color: #f59e0b;
font-size: 0.875rem;
margin-bottom: 1rem;
font-weight: 600;
`;
sourcesHeader.textContent = `Sources (${item.sources.length})`;
conversationItem.appendChild(sourcesHeader);
const sourcesContainer = document.createElement('div');
sourcesContainer.innerHTML = item.sources.map((source, sourceIndex) => {
if (!source) return '';
const docTypeLabel = source.doc_type === '10k' ? '10-K Report' :
source.doc_type.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase());
return `
<div class="source-card" id="source-${index}-${sourceIndex}" style="
background: rgba(31, 41, 55, 0.5);
border: 1px solid rgba(75, 85, 99, 0.5);
border-radius: 0.5rem;
margin-bottom: 0.75rem;
overflow: hidden;
transition: all 0.3s ease;
">
<div class="source-header" onclick="toggleSource(${index}, ${sourceIndex})" style="
padding: 0.75rem 1rem;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
background: rgba(17, 24, 39, 0.5);
">
<div class="source-title" style="display: flex; align-items: center; gap: 0.5rem;">
<span class="source-badge" style="
background: rgba(59, 130, 246, 0.2);
color: #60a5fa;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
">${docTypeLabel}</span>
<span style="color: #e5e7eb; font-size: 0.875rem;">${escapeHtml(source.filename || 'Unknown')}</span>
</div>
<div class="source-similarity" style="color: #9ca3af; font-size: 0.875rem;">
Score: <strong style="color: #10b981;">${source.similarity_score ? (source.similarity_score * 100).toFixed(0) + '%' : 'N/A'}</strong>
</div>
</div>
<div class="source-content" style="
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
padding: 0 1rem;
">
<div class="source-details" style="
padding: 0.75rem 0;
display: flex;
gap: 1rem;
flex-wrap: wrap;
border-bottom: 1px solid rgba(75, 85, 99, 0.3);
">
${source.ticker ? `<span style="color: #9ca3af; font-size: 0.875rem;"><strong style="color: #e5e7eb;">Ticker:</strong> ${escapeHtml(source.ticker)}</span>` : ''}
${source.chunk_id ? `<span style="color: #9ca3af; font-size: 0.875rem;"><strong style="color: #e5e7eb;">Chunk:</strong> ${escapeHtml(source.chunk_id)}</span>` : ''}
</div>
<div class="source-preview" style="
padding: 1rem 0;
color: #d1d5db;
font-size: 0.875rem;
line-height: 1.6;
font-style: italic;
">
"${escapeHtml(source.text_preview || 'No preview available')}"
</div>
</div>
</div>
`;
}).filter(Boolean).join('');
conversationItem.appendChild(sourcesContainer);
}
// Add timestamp
const timestamp = document.createElement('div');
timestamp.style.cssText = `
color: #6b7280;
font-size: 0.75rem;
text-align: right;
margin-top: 1rem;
`;
timestamp.textContent = item.timestamp.toLocaleTimeString();
conversationItem.appendChild(timestamp);
answerDiv.appendChild(conversationItem);
});
// Show export button if there's content
if (exportBtn && conversationHistory.length > 0) {
exportBtn.style.display = 'flex';
}
}
// Toggle source expansion
function toggleSource(conversationIndex, sourceIndex) {
const card = document.getElementById(`source-${conversationIndex}-${sourceIndex}`);
if (!card) return;
const content = card.querySelector('.source-content');
if (!content) return;
if (content.style.maxHeight && content.style.maxHeight !== '0px') {
content.style.maxHeight = '0px';
content.style.padding = '0 1rem';
} else {
content.style.maxHeight = content.scrollHeight + 'px';
content.style.padding = '0 1rem';
}
}
// Format answer with enhanced styling - IMPROVED VERSION
function formatAnswer(answer) {
if (!answer) return '';
let formatted = escapeHtml(answer);
// ============================================
// 1. CONVERT LATEX MATH TO READABLE FORMAT
// ============================================
// Block math: \[ ... \] → Display as formatted equation
formatted = formatted.replace(/\\\[(.*?)\\\]/gs, (match, equation) => {
// Clean up the equation
let cleanEq = equation.trim()
.replace(/\\text\{([^}]+)\}/g, '$1') // Remove \text{}
.replace(/\\frac\{([^}]+)\}\{([^}]+)\}/g, '($1) ÷ ($2)') // Convert fractions
.replace(/\\_/g, '_') // Fix escaped underscores
.replace(/\\\\/g, '') // Remove double backslashes
.replace(/\\times/g, '×') // Convert \times to ×
.replace(/\\div/g, '÷') // Convert \div to ÷
.replace(/\\approx/g, '≈') // Convert \approx to ≈
.replace(/\\cdot/g, '·'); // Convert \cdot to ·
return `<div class="math-formula">${cleanEq}</div>`;
});
// Inline math: \( ... \) → Convert to readable format
formatted = formatted.replace(/\\\((.*?)\\\)/g, (match, equation) => {
let cleanEq = equation.trim()
.replace(/\\text\{([^}]+)\}/g, '$1')
.replace(/\\frac\{([^}]+)\}\{([^}]+)\}/g, '($1) ÷ ($2)')
.replace(/\\times/g, '×')
.replace(/\\div/g, '÷')
.replace(/\\approx/g, '≈');
return `<span class="math-inline">${cleanEq}</span>`;
});
// ============================================
// 2. CLEAN UP ASTERISKS AND FORMATTING
// ============================================
// Remove asterisks used for emphasis (markdown bold/italic)
formatted = formatted.replace(/\*\*\*([^*]+)\*\*\*/g, '<strong><em>$1</em></strong>'); // ***bold italic***
formatted = formatted.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>'); // **bold**
formatted = formatted.replace(/\*([^*\n]+)\*/g, '<em>$1</em>'); // *italic*
// Replace any remaining asterisks with multiplication symbol
formatted = formatted.replace(/\s\*\s/g, ' × ');
// ============================================
// 3. FORMAT SECTIONS AND HEADERS
// ============================================
// Line breaks
formatted = formatted.replace(/\n\n/g, '<br><br>');
formatted = formatted.replace(/\n/g, '<br>');
// Headers with ###
formatted = formatted.replace(/###\s*([^<]+?)(<br>|$)/g, '<h4 class="answer-header">$1</h4>');
// Section headers that end with colon (e.g., "Analysis:", "Sources:")
formatted = formatted.replace(/<strong>([^<]+?):<\/strong>/g, '<div class="section-header">$1:</div>');
// ============================================
// 4. HIGHLIGHT FINANCIAL DATA
// ============================================
// Citations [Source N]
formatted = formatted.replace(/\[Source (\d+)\]/g,
'<span class="citation">[Source $1]</span>');
// Currency values (e.g., $2.54B, $1.26M, $1,234.56)
formatted = formatted.replace(/\$[\d,]+\.?\d*[BMK]?/g,
match => `<span class="currency-value">${match}</span>`);
// Percentages
formatted = formatted.replace(/(\d+\.?\d*)%/g,
'<span class="percentage-value">$1%</span>');
// Standalone ratios (e.g., 2.29, 1.5x, 3:1)
formatted = formatted.replace(/\b(\d+\.?\d+)(x|:1)(\s|<|$)/g,
'<span class="ratio-value">$1$2</span>$3');
// ============================================
// 5. FORMAT LISTS
// ============================================
// Numbered lists with bold headers
formatted = formatted.replace(/(\d+)\.\s+<strong>([^<]+)<\/strong>/g,
'<div class="list-item"><strong>$1. $2</strong></div>');
// Bullet points
formatted = formatted.replace(/(<br>|^)[\s]*[-•]\s+/g, '$1<span class="bullet-point">• </span>');
// ============================================
// 6. CLEAN UP DOUBLE SPACES
// ============================================
formatted = formatted.replace(/\s{2,}/g, ' ');
return formatted;
}
// Export to PDF function
function exportToPDF() {
if (conversationHistory.length === 0) {
showError('No conversation to export. Please ask a question first.');
return;
}
try {
if (typeof window.jspdf === 'undefined') {
showError('PDF library not loaded. Please refresh the page.');
return;
}
const { jsPDF } = window.jspdf;
const doc = new jsPDF();
// Set title
doc.setFontSize(18);
doc.setFont(undefined, 'bold');
doc.text('FinSage Analytics Report', 20, 20);
doc.setFontSize(10);
doc.setFont(undefined, 'normal');
doc.setTextColor(100);
doc.text(`Generated on ${new Date().toLocaleString()}`, 20, 28);
doc.text(`Total Questions: ${conversationHistory.length}`, 20, 34);
let yPos = 45;
conversationHistory.forEach((item, index) => {
// Check for page break
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
// Question
doc.setFontSize(12);
doc.setFont(undefined, 'bold');
doc.setTextColor(0);
doc.text(`Question ${index + 1}:`, 20, yPos);
yPos += 8;
doc.setFont(undefined, 'normal');
doc.setFontSize(10);
const queryLines = doc.splitTextToSize(item.query, 170);
doc.text(queryLines, 20, yPos);
yPos += queryLines.length * 7 + 5;
// Answer
if (yPos > 250) {
doc.addPage();
yPos = 20;
}
doc.setFontSize(11);
doc.setFont(undefined, 'bold');
doc.text('Answer:', 20, yPos);
yPos += 8;
doc.setFont(undefined, 'normal');
doc.setFontSize(10);
let cleanAnswer = item.answer
.replace(/<[^>]*>/g, '')
.replace(/\[Source \d+\]/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/\\/g, '');
const answerLines = doc.splitTextToSize(cleanAnswer, 170);
answerLines.forEach(line => {
if (yPos > 270) {
doc.addPage();
yPos = 20;
}
doc.text(line, 20, yPos);
yPos += 7;
});
yPos += 10;
});
const filename = `FinSage_Conversation_${Date.now()}.pdf`;
doc.save(filename);
} catch (error) {
console.error('PDF Export Error:', error);
showError('Failed to export PDF. Please try again.');
}
}
// Loading state
function setLoading(isLoading) {
if (!submitBtn) return;
submitBtn.disabled = isLoading;
if (btnText && loader) {
btnText.style.display = isLoading ? 'none' : 'inline';
loader.style.display = isLoading ? 'inline-block' : 'none';
}
}
// Error handling
function showError(message) {
if (!errorToast || !errorMessage) {
alert(message);
return;
}
errorMessage.textContent = message;
errorToast.style.display = 'block';
setTimeout(() => {
errorToast.style.display = 'none';
}, 5000);
}
function hideError() {
if (errorToast) {
errorToast.style.display = 'none';
}
}
// Utility: Escape HTML
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Health check
async function checkHealth() {
try {
const response = await fetch(`${API_BASE_URL}/health`, {
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
console.warn('API health check failed');
}
} catch (error) {
console.error('Cannot connect to API:', error);
showError('Backend API is not responding. Please start the server.');
}
}
// Make toggleSource available globally
window.toggleSource = toggleSource;
// Initialize on load
init();