studyrag / static /js /app.js
beerohan
Flatten directory structure for deployment
5ac3946
const API_BASE = '';
const SESSION_KEY = 'studyson:session_id';
function getSessionId() {
let id = localStorage.getItem(SESSION_KEY);
if (!id) {
id = (crypto.randomUUID && crypto.randomUUID()) || `s_${Date.now()}_${Math.random().toString(36).slice(2)}`;
localStorage.setItem(SESSION_KEY, id);
}
return id;
}
function renderMarkdown(text) {
if (window.marked && window.DOMPurify) {
const html = window.marked.parse(text, { breaks: true, gfm: true });
return window.DOMPurify.sanitize(html);
}
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s;
return div.innerHTML;
}
const toastContainer = document.getElementById('toast-container');
function toast(message, type = 'info', duration = 4000) {
if (!toastContainer) return;
const el = document.createElement('div');
el.className = `toast toast-${type}`;
el.textContent = message;
toastContainer.appendChild(el);
requestAnimationFrame(() => el.classList.add('show'));
setTimeout(() => {
el.classList.remove('show');
setTimeout(() => el.remove(), 300);
}, duration);
}
const navItems = document.querySelectorAll('.nav-item');
const viewContainers = document.querySelectorAll('.view-container');
const currentViewName = document.getElementById('current-view-name');
const viewNames = {
upload: 'Upload Files',
web: 'Web Import',
chat: 'Q&A Chat',
summary: 'Summarize',
};
navItems.forEach(item => {
item.addEventListener('click', () => switchView(item.dataset.view));
});
function switchView(viewId) {
navItems.forEach(n => n.classList.remove('active'));
viewContainers.forEach(v => v.classList.remove('active'));
document.querySelector(`[data-view="${viewId}"]`)?.classList.add('active');
document.getElementById(`view-${viewId}`)?.classList.add('active');
if (currentViewName) currentViewName.textContent = viewNames[viewId] || viewId;
}
const fileInput = document.getElementById('file-input');
const dropZone = document.querySelector('.drop-zone');
const dropZoneTitle = document.getElementById('drop-zone-title');
const fileInfo = document.getElementById('file-info');
const fileNameDisplay = document.getElementById('file-name-display');
const fileSizeDisplay = document.getElementById('file-size-display');
const uploadForm = document.getElementById('upload-form');
const uploadResult = document.getElementById('upload-result');
fileInput.addEventListener('change', e => handleFile(e.target.files[0]));
['dragover', 'dragenter'].forEach(ev =>
dropZone.addEventListener(ev, e => {
e.preventDefault();
dropZone.classList.add('dragging');
})
);
['dragleave', 'drop'].forEach(ev =>
dropZone.addEventListener(ev, e => {
e.preventDefault();
dropZone.classList.remove('dragging');
})
);
dropZone.addEventListener('drop', e => {
const file = e.dataTransfer?.files?.[0];
if (file) {
const dt = new DataTransfer();
dt.items.add(file);
fileInput.files = dt.files;
handleFile(file);
}
});
function handleFile(file) {
if (!file) return;
fileNameDisplay.textContent = file.name;
fileSizeDisplay.textContent = formatFileSize(file.size);
dropZoneTitle.textContent = 'File selected';
fileInfo.style.display = 'flex';
}
function formatFileSize(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
uploadForm.addEventListener('submit', async e => {
e.preventDefault();
const file = fileInput.files[0];
if (!file) {
toast('Please select a file', 'error');
return;
}
const formData = new FormData();
formData.append('file', file);
const submitBtn = uploadForm.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="loading"></span><span>Uploading...</span>';
try {
const response = await fetch(`${API_BASE}/upload`, { method: 'POST', body: formData });
const data = await response.json();
if (response.ok) {
showResult(uploadResult, data.message, 'success');
toast(data.message, 'success');
fileInput.value = '';
fileInfo.style.display = 'none';
dropZoneTitle.textContent = 'Drop your file here';
await updateStatus();
} else {
showResult(uploadResult, data.detail || 'Upload failed', 'error');
toast(data.detail || 'Upload failed', 'error');
}
} catch (error) {
showResult(uploadResult, `Error: ${error.message}`, 'error');
toast(error.message, 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
const scrapeForm = document.getElementById('scrape-form');
const scrapeResult = document.getElementById('scrape-result');
scrapeForm.addEventListener('submit', async e => {
e.preventDefault();
const url = document.getElementById('url-input').value;
const submitBtn = scrapeForm.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="loading"></span><span>Fetching...</span>';
try {
const response = await fetch(`${API_BASE}/scrape_and_index`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url, session_id: getSessionId() }),
});
const data = await response.json();
if (response.ok) {
showResult(scrapeResult, data.message, 'success');
toast(data.message, 'success');
document.getElementById('url-input').value = '';
await updateStatus();
} else {
showResult(scrapeResult, data.detail || 'Scraping failed', 'error');
toast(data.detail || 'Scraping failed', 'error');
}
} catch (error) {
showResult(scrapeResult, `Error: ${error.message}`, 'error');
toast(error.message, 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
const chatForm = document.getElementById('chat-form');
const chatMessages = document.getElementById('chat-messages');
const questionInput = document.getElementById('question-input');
chatForm.addEventListener('submit', async e => {
e.preventDefault();
const question = questionInput.value.trim();
if (!question) return;
chatMessages.querySelector('.empty-state')?.remove();
addMessage(question, 'user');
questionInput.value = '';
const assistantMessage = addMessage('', 'assistant');
const messageContent = assistantMessage.querySelector('.message-content');
messageContent.classList.add('markdown-body');
const submitBtn = chatForm.querySelector('button[type="submit"]');
submitBtn.disabled = true;
try {
const response = await fetch(`${API_BASE}/stream_query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, session_id: getSessionId() }),
});
if (!response.ok) {
const error = await response.json();
messageContent.textContent = `Error: ${error.detail}`;
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let fullAnswer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const parsed = JSON.parse(data);
if (parsed.token) {
fullAnswer += parsed.token;
messageContent.innerHTML = renderMarkdown(fullAnswer);
chatMessages.scrollTop = chatMessages.scrollHeight;
} else if (parsed.final_answer) {
messageContent.innerHTML = renderMarkdown(parsed.final_answer);
if (parsed.sources?.length) {
const sourcesDiv = document.createElement('div');
sourcesDiv.className = 'message-sources';
sourcesDiv.innerHTML = '<strong>Sources</strong>';
parsed.sources.forEach((source, idx) => {
const item = document.createElement('div');
item.className = 'source-item';
const score = source.score != null ? ` (${source.score.toFixed(3)})` : '';
item.innerHTML = `
<strong>${idx + 1}. ${escapeHtml(source.file_name)}${escapeHtml(score)}</strong>
<p>${escapeHtml(source.text)}…</p>`;
sourcesDiv.appendChild(item);
});
assistantMessage.querySelector('.message-bubble').appendChild(sourcesDiv);
}
} else if (parsed.error) {
messageContent.textContent = `Error: ${parsed.error}`;
}
} catch (err) {
console.error('Parse error:', err);
}
}
}
} catch (error) {
messageContent.textContent = `Error: ${error.message}`;
} finally {
submitBtn.disabled = false;
}
});
function addMessage(text, sender) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${sender}`;
const bubbleDiv = document.createElement('div');
bubbleDiv.className = 'message-bubble';
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
contentDiv.textContent = text;
bubbleDiv.appendChild(contentDiv);
messageDiv.appendChild(bubbleDiv);
chatMessages.appendChild(messageDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
return messageDiv;
}
const summarizeForm = document.getElementById('summarize-form');
const summaryResult = document.getElementById('summary-result');
const lengthSlider = document.getElementById('max-length');
const lengthDisplay = document.getElementById('length-display');
lengthSlider.addEventListener('input', e => {
lengthDisplay.textContent = e.target.value;
});
summarizeForm.addEventListener('submit', async e => {
e.preventDefault();
const maxLength = parseInt(lengthSlider.value, 10);
const submitBtn = summarizeForm.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="loading"></span><span>Generating...</span>';
try {
const response = await fetch(`${API_BASE}/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ max_length: maxLength }),
});
const data = await response.json();
if (response.ok) {
const sources = data.source_documents.map(escapeHtml).join(', ');
summaryResult.innerHTML = `
<h3>Summary (${data.word_count} words)</h3>
<div class="markdown-body">${renderMarkdown(data.summary)}</div>
<p class="summary-sources"><strong>Sources:</strong> ${sources}</p>`;
summaryResult.classList.add('show', 'success');
} else {
showResult(summaryResult, data.detail || 'Summarization failed', 'error');
toast(data.detail || 'Summarization failed', 'error');
}
} catch (error) {
showResult(summaryResult, `Error: ${error.message}`, 'error');
toast(error.message, 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
const resetBtn = document.getElementById('reset-btn-sidebar');
resetBtn.addEventListener('click', async () => {
if (!confirm('Reset all documents? This cannot be undone.')) return;
try {
const response = await fetch(`${API_BASE}/reset`, { method: 'POST' });
const data = await response.json();
if (response.ok) {
localStorage.removeItem(SESSION_KEY);
toast(data.message, 'success');
chatMessages.innerHTML = '<div class="empty-state"><div class="empty-icon">💬</div><h3>Start a Conversation</h3><p>Ask me anything about your indexed documents</p></div>';
await updateStatus();
} else {
toast(data.detail || 'Reset failed', 'error');
}
} catch (error) {
toast(error.message, 'error');
}
});
function showResult(element, message, type) {
element.innerHTML = escapeHtml(message);
element.className = `result-message show ${type}`;
setTimeout(() => element.classList.remove('show'), 5000);
}
async function updateStatus() {
try {
const response = await fetch(`${API_BASE}/status`);
const data = await response.json();
if (!data.details) return;
const count = data.details.document_count || 0;
document.getElementById('doc-count-sidebar')?.replaceChildren(document.createTextNode(count));
const statusPulse = document.getElementById('status-pulse');
const statusTextSidebar = document.getElementById('status-text-sidebar');
const statusTextTop = document.getElementById('status-text-top');
if (data.details.has_documents) {
if (statusPulse) statusPulse.style.background = 'var(--success)';
if (statusTextSidebar) statusTextSidebar.textContent = 'Ready';
if (statusTextTop) statusTextTop.textContent = `Ready · ${data.details.model || ''}`;
} else {
if (statusPulse) statusPulse.style.background = 'var(--text-light)';
if (statusTextSidebar) statusTextSidebar.textContent = 'No Docs';
if (statusTextTop) statusTextTop.textContent = 'No Documents';
}
} catch (error) {
console.error('Status update failed:', error);
const statusPulse = document.getElementById('status-pulse');
const statusTextTop = document.getElementById('status-text-top');
if (statusPulse) statusPulse.style.background = 'var(--danger)';
if (statusTextTop) statusTextTop.textContent = 'Connection Error';
}
}
getSessionId();
updateStatus();
setInterval(updateStatus, 30000);