// ────────────────────────────── static/script.js ──────────────────────────────
(function() {
// DOM elements
const fileDropZone = document.getElementById('file-drop-zone');
const fileInput = document.getElementById('files');
const fileList = document.getElementById('file-list');
const fileItems = document.getElementById('file-items');
const uploadBtn = document.getElementById('upload-btn');
const uploadProgress = document.getElementById('upload-progress');
const progressStatus = document.getElementById('progress-status');
const progressFill = document.getElementById('progress-fill');
const progressLog = document.getElementById('progress-log');
const questionInput = document.getElementById('question');
const sendBtn = document.getElementById('send-btn');
const chatHint = document.getElementById('chat-hint');
const messages = document.getElementById('messages');
const reportLink = document.getElementById('report-link');
const searchLink = document.getElementById('search-link');
const loadingOverlay = document.getElementById('loading-overlay');
const loadingMessage = document.getElementById('loading-message');
// State
let selectedFiles = [];
let isUploading = false;
let isProcessing = false;
// Initialize
init();
function init() {
setupFileDropZone();
setupEventListeners();
checkUserAuth();
// Listen for project changes
document.addEventListener('projectChanged', () => {
updateUploadButton();
});
// Initial button state update
updateUploadButton();
}
function setupFileDropZone() {
// Click to browse
fileDropZone.addEventListener('click', () => fileInput.click());
// Drag and drop
fileDropZone.addEventListener('dragover', (e) => {
e.preventDefault();
fileDropZone.classList.add('dragover');
});
fileDropZone.addEventListener('dragleave', () => {
fileDropZone.classList.remove('dragover');
});
fileDropZone.addEventListener('drop', (e) => {
e.preventDefault();
fileDropZone.classList.remove('dragover');
const files = Array.from(e.dataTransfer.files);
handleFileSelection(files);
});
// File input change
fileInput.addEventListener('change', (e) => {
const files = Array.from(e.target.files);
handleFileSelection(files);
});
}
function setupEventListeners() {
// Upload form
document.getElementById('upload-form').addEventListener('submit', handleUpload);
// Chat
sendBtn.addEventListener('click', handleAsk);
// Convert to textarea behavior: Enter submits, Shift+Enter for newline
questionInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleAsk();
}
});
questionInput.addEventListener('input', autoGrowTextarea);
// Clear chat history
const clearBtn = document.getElementById('clear-chat-btn');
if (clearBtn) {
clearBtn.addEventListener('click', async () => {
const user = window.__sb_get_user();
const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
const currentSession = window.__sb_get_current_session && window.__sb_get_current_session();
if (!user || !currentProject || !currentSession) {
alert('Please select a session first');
return;
}
if (!confirm('Clear chat history for this session?')) return;
try {
const res = await fetch(`/chat/history?user_id=${encodeURIComponent(user.user_id)}&project_id=${encodeURIComponent(currentProject.project_id)}&session_id=${encodeURIComponent(currentSession)}`, { method: 'DELETE' });
if (res.ok) {
document.getElementById('messages').innerHTML = '';
// Also clear session-specific memory
try {
await fetch('/sessions/clear-memory', {
method: 'POST',
body: new FormData().append('user_id', user.user_id)
.append('project_id', currentProject.project_id)
.append('session_id', currentSession)
});
} catch (e) {
console.warn('Failed to clear session memory:', e);
}
} else {
alert('Failed to clear history');
}
} catch {}
});
}
// Report link toggle
if (reportLink) {
reportLink.addEventListener('click', (e) => {
e.preventDefault();
toggleReportMode();
});
}
// Search link toggle (enables web search augmentation)
if (searchLink) {
searchLink.addEventListener('click', (e) => {
e.preventDefault();
// Visual toggle; can be active concurrently with report mode
searchLink.classList.toggle('active');
});
}
// Quiz link toggle
const quizLink = document.getElementById('quiz-link');
if (quizLink) {
quizLink.addEventListener('click', (e) => {
e.preventDefault();
// Open quiz setup modal
if (window.__sb_open_quiz_setup) {
window.__sb_open_quiz_setup();
}
});
}
}
function handleFileSelection(files) {
const validFiles = files.filter(file => {
const isValid = file.type === 'application/pdf' ||
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
if (!isValid) {
alert(`Unsupported file type: ${file.name}. Please upload PDF or DOCX files only.`);
}
return isValid;
});
if (validFiles.length === 0) return;
selectedFiles = validFiles;
updateFileList();
updateUploadButton();
}
function updateFileList() {
if (selectedFiles.length === 0) {
fileList.style.display = 'none';
return;
}
fileList.style.display = 'block';
fileItems.innerHTML = '';
selectedFiles.forEach((file, index) => {
const fileItem = document.createElement('div');
fileItem.className = 'file-item';
const icon = document.createElement('span');
icon.className = 'file-item-icon';
icon.textContent = file.type.includes('pdf') ? '📄' : '📝';
const name = document.createElement('span');
name.className = 'file-item-name';
name.textContent = file.name;
const size = document.createElement('span');
size.className = 'file-item-size';
size.textContent = formatFileSize(file.size);
const remove = document.createElement('button');
remove.className = 'file-item-remove';
remove.textContent = '×';
remove.title = 'Remove file';
remove.addEventListener('click', () => removeFile(index));
fileItem.appendChild(icon);
fileItem.appendChild(name);
fileItem.appendChild(size);
fileItem.appendChild(remove);
fileItems.appendChild(fileItem);
});
}
// Stored files view
async function loadStoredFiles() {
const user = window.__sb_get_user();
const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
if (!user || !currentProject) return;
try {
const res = await fetch(`/files?user_id=${encodeURIComponent(user.user_id)}&project_id=${encodeURIComponent(currentProject.project_id)}`);
if (!res.ok) return;
const data = await res.json();
const files = data.files || [];
renderStoredFiles(files);
// Enable Report button when at least one file exists
if (reportLink) {
// Disable visually by muted color when no files
reportLink.style.pointerEvents = (files.length === 0) ? 'none' : 'auto';
reportLink.title = 'Generate report from selected document';
}
window.__sb_current_filenames = new Set((data.filenames || []).map(f => (f || '').toLowerCase()));
} catch {}
}
function renderStoredFiles(files) {
const container = document.getElementById('stored-file-items');
if (container) {
if (!files || files.length === 0) {
container.innerHTML = '
No files stored yet.
';
} else {
container.innerHTML = files.map(f => `No files in this project.
';
return;
}
list.innerHTML = files.map((f, idx) => `
${(f.summary || '').replace(/
`).join('');
// bind deletes
list.querySelectorAll('.file-del').forEach(btn => {
btn.addEventListener('click', async () => {
const filename = decodeURIComponent(btn.getAttribute('data-fn'));
if (!confirm(`Delete ${filename}? This will remove all related chunks.`)) return;
const user = window.__sb_get_user();
const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
if (!user || !currentProject) return;
try {
const res = await fetch(`/files?user_id=${encodeURIComponent(user.user_id)}&project_id=${encodeURIComponent(currentProject.project_id)}&filename=${encodeURIComponent(filename)}`, { method: 'DELETE' });
if (res.ok) {
await loadStoredFiles();
} else {
alert('Failed to delete file');
}
} catch {}
});
});
// bind see more/less
list.querySelectorAll('.see-more-btn').forEach(btn => {
btn.addEventListener('click', () => {
const idx = btn.getAttribute('data-idx');
const summary = document.getElementById(`file-summary-${idx}`);
if (!summary) return;
const expanded = summary.classList.toggle('expanded');
btn.textContent = expanded ? 'See less' : '… See more';
});
});
}
// Expose show files section
window.__sb_show_files_section = async () => {
await loadStoredFiles();
};
// Duplicate detection: returns {toUpload, replace, renameMap}
async function resolveDuplicates(files) {
const existing = window.__sb_current_filenames || new Set();
const toUpload = [];
const replace = [];
const renameMap = {};
for (const f of files) {
const name = f.name;
if (existing.has(name.toLowerCase())) {
const choice = await promptDuplicateChoice(name);
if (choice === 'cancel') {
// skip this file
} else if (choice === 'replace') {
replace.push(name);
toUpload.push(f);
} else if (choice && choice.startsWith('rename:')) {
const newName = choice.slice(7);
// create a new File with newName
const renamed = new File([f], newName, { type: f.type, lastModified: f.lastModified });
renameMap[name] = newName;
toUpload.push(renamed);
}
} else {
toUpload.push(f);
}
}
return { toUpload, replace, renameMap };
}
function promptDuplicateChoice(filename) {
return new Promise((resolve) => {
// Minimal UX: use confirm/prompt; can be replaced with real modal later
const msg = `A similar file named ${filename} already exists.\nChoose: [Cancel] to skip, [OK] to choose Replace or Rename.`;
if (!confirm(msg)) { resolve('cancel'); return; }
const answer = prompt('Type "replace" to overwrite, or enter a new filename to rename:', 'replace');
if (!answer) { resolve('cancel'); return; }
if (answer.trim().toLowerCase() === 'replace') { resolve('replace'); return; }
resolve('rename:' + answer.trim());
});
}
function removeFile(index) {
selectedFiles.splice(index, 1);
updateFileList();
updateUploadButton();
}
function updateUploadButton() {
const hasFiles = selectedFiles.length > 0;
const hasProject = window.__sb_get_current_project && window.__sb_get_current_project();
uploadBtn.disabled = !hasFiles || !hasProject || isUploading;
if (hasFiles && hasProject) {
uploadBtn.querySelector('.btn-text').textContent = `Upload ${selectedFiles.length} Document${selectedFiles.length > 1 ? 's' : ''}`;
} else if (!hasProject) {
uploadBtn.querySelector('.btn-text').textContent = 'Select a Project First';
} else {
uploadBtn.querySelector('.btn-text').textContent = 'Upload Documents';
}
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async function handleUpload(e) {
e.preventDefault();
if (selectedFiles.length === 0) {
alert('Please select files to upload');
return;
}
const user = window.__sb_get_user();
if (!user) {
alert('Please sign in to upload files');
window.__sb_show_auth_modal();
return;
}
const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
if (!currentProject) {
alert('Please select a project first');
return;
}
isUploading = true;
updateUploadButton();
showUploadProgress();
try {
// Check duplicates against server list first
await loadStoredFiles();
const { toUpload, replace, renameMap } = await resolveDuplicates(selectedFiles);
if (toUpload.length === 0) {
updateProgressStatus('No files to upload');
setTimeout(() => hideUploadProgress(), 1000);
return;
}
const formData = new FormData();
formData.append('user_id', user.user_id);
formData.append('project_id', currentProject.project_id);
toUpload.forEach(file => formData.append('files', file));
if (replace.length > 0) {
formData.append('replace_filenames', JSON.stringify(replace));
}
if (Object.keys(renameMap).length > 0) {
formData.append('rename_map', JSON.stringify(renameMap));
}
const response = await fetch('/upload', { method: 'POST', body: formData });
const data = await response.json();
if (response.ok) {
updateProgressStatus('Upload successful! Processing documents...');
updateProgressFill(0);
// Friendly, non-technical messages only
logProgress('Files uploaded successfully');
// Poll backend for real progress
startUploadStatusPolling(data.job_id, data.total_files || toUpload.length);
// Refresh stored files list after a short delay
setTimeout(loadStoredFiles, 2000);
} else {
throw new Error(data.detail || 'Upload failed');
}
} catch (error) {
logProgress(`Error: ${error.message}`);
updateProgressStatus('Upload failed');
setTimeout(() => hideUploadProgress(), 3000);
} finally {
isUploading = false;
updateUploadButton();
}
}
function showUploadProgress() {
uploadProgress.style.display = 'block';
updateProgressStatus('Uploading files... (DO NOT REFRESH)');
updateProgressFill(0);
progressLog.innerHTML = '';
}
function hideUploadProgress() {
uploadProgress.style.display = 'none';
selectedFiles = [];
updateFileList();
updateUploadButton();
}
function updateProgressStatus(status) {
progressStatus.textContent = status;
}
function updateProgressFill(percentage) {
progressFill.style.width = `${percentage}%`;
}
function logProgress(message) {
const timestamp = new Date().toLocaleTimeString();
progressLog.innerHTML += `[${timestamp}] ${message}\n`;
progressLog.scrollTop = progressLog.scrollHeight;
}
function startUploadStatusPolling(jobId, totalFiles) {
let stopped = false;
let failCount = 0;
const maxFailsBeforeSilentStop = 30; // ~36s at 1200ms
const interval = setInterval(async () => {
if (stopped) return;
try {
const res = await fetch(`/upload/status?job_id=${encodeURIComponent(jobId)}`);
if (!res.ok) { failCount++; return; }
const status = await res.json();
const percent = Math.max(0, Math.min(100, parseInt(status.percent || 0, 10)));
const completed = status.completed || 0;
const total = status.total || totalFiles || 1;
updateProgressFill(percent);
updateProgressStatus(percent >= 100 ? 'Finalizing...' : `Processing documents (${completed}/${total}) · ${percent}%`);
if (status.status === 'completed' || percent >= 100) {
clearInterval(interval);
stopped = true;
updateProgressFill(100);
updateProgressStatus('Processing complete!');
logProgress('You can now start chatting with your documents');
setTimeout(() => hideUploadProgress(), 1500);
enableChat();
// Final refresh of stored files
setTimeout(loadStoredFiles, 1000);
}
} catch (e) {
// Swallow transient errors; update a friendly spinner-like status
failCount++;
if (failCount >= maxFailsBeforeSilentStop) {
clearInterval(interval);
stopped = true;
updateProgressStatus('Still working...');
}
}
}, 1200);
}
function enableChat() {
questionInput.disabled = false;
sendBtn.disabled = false;
chatHint.style.display = 'none';
autoGrowTextarea();
}
async function handleAsk() {
const question = questionInput.value.trim();
if (!question) return;
const user = window.__sb_get_user();
if (!user) {
alert('Please sign in to chat');
window.__sb_show_auth_modal();
return;
}
const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
if (!currentProject) {
alert('Please select a project first');
return;
}
// Get current session ID from session management
const sessionId = window.__sb_get_current_session && window.__sb_get_current_session();
if (!sessionId) {
alert('Please select a session first');
return;
}
// Add user message
appendMessage('user', question);
questionInput.value = '';
autoGrowTextarea();
// Save user message to chat history
await saveChatMessage(user.user_id, currentProject.project_id, 'user', question, null, sessionId);
// Add thinking message with dynamic status
const thinkingMsg = appendMessage('thinking', 'Receiving request...');
// Disable input during processing
questionInput.disabled = true;
sendBtn.disabled = true;
showButtonLoading(sendBtn, true);
// Start status polling
const statusInterval = startStatusPolling(sessionId, thinkingMsg);
try {
// Branch: if report mode is active → call /report with textarea as instructions
if (isReportModeActive()) {
const filename = pickActiveFilename();
if (!filename) throw new Error('Please select a document to generate a report');
const form = new FormData();
form.append('user_id', user.user_id);
form.append('project_id', currentProject.project_id);
form.append('filename', filename);
form.append('outline_words', '200');
form.append('report_words', '1200');
form.append('instructions', question);
form.append('session_id', sessionId);
// If Search is toggled on, enable web augmentation for report
const useWeb = searchLink && searchLink.classList.contains('active');
if (useWeb) {
form.append('use_web', '1');
form.append('max_web', '20');
}
const response = await fetch('/report', { method: 'POST', body: form });
const data = await response.json();
if (response.ok) {
thinkingMsg.remove();
appendMessage('assistant', data.report_markdown || 'No report', true); // isReport = true
if (data.sources && data.sources.length) appendSources(data.sources);
// Save assistant report to chat history for persistence
try { await saveChatMessage(user.user_id, currentProject.project_id, 'assistant', data.report_markdown || 'No report', null, sessionId); } catch {}
} else {
throw new Error(data.detail || 'Failed to generate report');
}
} else {
const formData = new FormData();
formData.append('user_id', user.user_id);
formData.append('project_id', currentProject.project_id);
formData.append('question', question);
formData.append('k', '6');
formData.append('session_id', sessionId);
// If Search is toggled on, enable web augmentation
const useWeb = searchLink && searchLink.classList.contains('active');
if (useWeb) {
formData.append('use_web', '1');
formData.append('max_web', '30');
}
const response = await fetch('/chat', { method: 'POST', body: formData });
const data = await response.json();
if (response.ok) {
thinkingMsg.remove();
appendMessage('assistant', data.answer || 'No answer received');
if (data.sources && data.sources.length > 0) {
appendSources(data.sources);
}
// Handle session auto-naming if returned
if (data.session_name && data.session_id) {
console.log(`[FRONTEND] 🎯 Auto-naming received: session_id=${data.session_id}, name='${data.session_name}'`);
// Update the session name in the UI immediately
if (window.__sb_update_session_name) {
console.log(`[FRONTEND] 🔄 Calling updateSessionName function...`);
window.__sb_update_session_name(data.session_id, data.session_name);
} else {
console.warn(`[FRONTEND] ❌ updateSessionName function not available`);
}
} else {
console.log(`[FRONTEND] ℹ️ No auto-naming data received:`, { session_name: data.session_name, session_id: data.session_id });
}
await saveChatMessage(
user.user_id,
currentProject.project_id,
'assistant',
data.answer || 'No answer received',
(data.sources && data.sources.length > 0) ? data.sources : null,
sessionId
);
} else {
throw new Error(data.detail || 'Failed to get answer');
}
}
} catch (error) {
thinkingMsg.remove();
const errorMsg = `⚠️ Error: ${error.message}`;
appendMessage('assistant', errorMsg);
await saveChatMessage(user.user_id, currentProject.project_id, 'assistant', errorMsg, null, sessionId);
} finally {
// Stop status polling
if (statusInterval) {
clearInterval(statusInterval);
}
// Re-enable input
questionInput.disabled = false;
sendBtn.disabled = false;
showButtonLoading(sendBtn, false);
questionInput.focus();
}
}
function toggleReportMode() {
if (!reportLink) return;
reportLink.classList.toggle('active');
}
function isReportModeActive() {
return reportLink && reportLink.classList.contains('active');
}
function pickActiveFilename() {
const candidates = Array.from(document.querySelectorAll('#stored-file-items .pill'));
let active = candidates.find(el => el.classList.contains('active'));
if (!active && candidates.length) active = candidates[0];
return active ? active.textContent.trim() : '';
}
function autoGrowTextarea() {
if (!questionInput) return;
// Reset height to measure content size
questionInput.style.height = 'auto';
const style = window.getComputedStyle(questionInput);
const borderTop = parseInt(style.borderTopWidth) || 0;
const borderBottom = parseInt(style.borderBottomWidth) || 0;
const paddingTop = parseInt(style.paddingTop) || 0;
const paddingBottom = parseInt(style.paddingBottom) || 0;
const boxExtras = borderTop + borderBottom + paddingTop + paddingBottom;
const contentHeight = questionInput.scrollHeight - boxExtras;
const lineHeight = 22; // approx for 16px font
const minRows = 2;
const maxRows = 7;
// Compute rows required based on content height, clamped
const neededRows = Math.ceil(contentHeight / lineHeight);
const clamped = Math.min(maxRows, Math.max(minRows, neededRows));
questionInput.rows = clamped;
// Prevent jumpy growth for long single lines by restricting until wrap actually occurs
// If no wrap (scrollWidth <= clientWidth), keep at least minRows
const isWrapping = questionInput.scrollWidth > questionInput.clientWidth;
if (!isWrapping) questionInput.rows = Math.min(questionInput.rows, minRows);
}
async function handleGenerateReport() {
const user = window.__sb_get_user();
const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
if (!user || !currentProject) {
alert('Please sign in and select a project');
return;
}
// Determine selected/active file from files section; fallback to first
const candidates = Array.from(document.querySelectorAll('#stored-file-items .pill'));
let active = candidates.find(el => el.classList.contains('active'));
if (!active && candidates.length) active = candidates[0];
if (!active) { alert('Please upload and select a document first'); return; }
const filename = active.textContent.trim();
const instructions = (questionInput && questionInput.value || '').trim();
showLoading('Generating report...');
try {
const form = new FormData();
form.append('user_id', user.user_id);
form.append('project_id', currentProject.project_id);
form.append('filename', filename);
form.append('outline_words', '200');
form.append('report_words', '1200');
form.append('instructions', instructions);
// Respect Search toggle when using quick report button
const useWeb = searchLink && searchLink.classList.contains('active');
if (useWeb) {
form.append('use_web', '1');
form.append('max_web', '20');
}
const res = await fetch('/report', { method: 'POST', body: form });
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'Report failed');
// Append as assistant message
appendMessage('assistant', data.report_markdown || 'No report');
if (data.sources && data.sources.length) {
appendSources(data.sources);
}
} catch (e) {
alert(e.message || 'Failed to generate report');
} finally {
hideLoading();
}
}
// Toggle active file pill selection
document.addEventListener('click', (e) => {
const tgt = e.target;
if (tgt && tgt.classList && tgt.classList.contains('pill') && tgt.parentElement && tgt.parentElement.id === 'stored-file-items') {
document.querySelectorAll('#stored-file-items .pill').forEach(el => el.classList.remove('active'));
tgt.classList.add('active');
// Enable link visually
if (reportLink) reportLink.classList.add('active');
}
});
async function saveChatMessage(userId, projectId, role, content, sources = null, sessionId = null) {
try {
const formData = new FormData();
formData.append('user_id', userId);
formData.append('project_id', projectId);
formData.append('role', role);
formData.append('content', content);
formData.append('timestamp', Date.now() / 1000);
if (sources) {
try { formData.append('sources', JSON.stringify(sources)); } catch {}
}
if (sessionId) {
formData.append('session_id', sessionId);
}
await fetch('/chat/save', { method: 'POST', body: formData });
} catch (error) {
console.error('Failed to save chat message:', error);
}
}
function renderAssistantMarkdown(container, markdown, isReport) {
try {
// Configure marked to keep code blocks for highlight.js
const htmlContent = marked.parse(markdown);
container.innerHTML = htmlContent;
// Normalize heading numbering (H1/H2/H3) without double-numbering
try { renumberHeadings(container); } catch {}
// Render Mermaid if present
renderMermaidInElement(container);
// Add copy buttons to code blocks
addCopyButtonsToCodeBlocks(container);
// Syntax highlight code blocks
try {
container.querySelectorAll('pre code').forEach((block) => {
if (window.hljs && window.hljs.highlightElement) {
window.hljs.highlightElement(block);
}
});
} catch {}
// Add download PDF button for reports
if (isReport) addDownloadPdfButton(container, markdown);
} catch (e) {
container.textContent = markdown;
}
}
function renumberHeadings(root) {
const h1s = Array.from(root.querySelectorAll('h1'));
const h2s = Array.from(root.querySelectorAll('h2'));
const h3s = Array.from(root.querySelectorAll('h3'));
let s1 = 0;
let s2 = 0;
let s3 = 0;
const headers = Array.from(root.querySelectorAll('h1, h2, h3'));
headers.forEach(h => {
const text = h.textContent.trim();
// Strip any existing numeric prefix like "1. ", "1.2 ", "1.2.3 "
const stripped = text.replace(/^\d+(?:\.\d+){0,2}\s+/, '');
if (h.tagName === 'H1') {
s1 += 1; s2 = 0; s3 = 0;
h.textContent = `${s1}. ${stripped}`;
} else if (h.tagName === 'H2') {
if (s1 === 0) { s1 = 1; }
s2 += 1; s3 = 0;
h.textContent = `${s1}.${s2} ${stripped}`;
} else if (h.tagName === 'H3') {
if (s1 === 0) { s1 = 1; }
if (s2 === 0) { s2 = 1; }
s3 += 1;
h.textContent = `${s1}.${s2}.${s3} ${stripped}`;
}
});
}
// Dynamically load Mermaid and render mermaid code blocks
async function ensureMermaidLoaded() {
if (window.mermaid && window.mermaid.initialize) return true;
return new Promise((resolve) => {
const existing = document.querySelector('script[data-sb-mermaid]');
if (existing) { existing.addEventListener('load', () => resolve(true)); return; }
const s = document.createElement('script');
s.src = 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js';
s.async = true;
s.dataset.sbMermaid = '1';
s.onload = () => {
try {
if (window.mermaid && window.mermaid.initialize) {
window.mermaid.initialize({ startOnLoad: false, securityLevel: 'loose', theme: 'default' });
}
} catch {}
resolve(true);
};
document.head.appendChild(s);
});
}
async function renderMermaidInElement(el) {
const mermaidBlocks = el.querySelectorAll('code.language-mermaid, pre code.language-mermaid');
if (!mermaidBlocks.length) return;
await ensureMermaidLoaded();
const isV10 = !!(window.mermaid && window.mermaid.render && typeof window.mermaid.render === 'function');
for (let idx = 0; idx < mermaidBlocks.length; idx++) {
const codeBlock = mermaidBlocks[idx];
let graph = codeBlock.textContent || '';
const wrapper = document.createElement('div');
const id = `mermaid-${Date.now()}-${idx}`;
wrapper.className = 'mermaid';
wrapper.id = id;
const replaceTarget = codeBlock.parentElement && codeBlock.parentElement.tagName.toLowerCase() === 'pre' ? codeBlock.parentElement : codeBlock;
replaceTarget.replaceWith(wrapper);
// Try to render with retry logic
let renderSuccess = false;
let attempt = 0;
const maxAttempts = 3;
while (!renderSuccess && attempt < maxAttempts) {
try {
if (isV10) {
// Pass wrapper as container to avoid document.createElementNS undefined errors
const out = await window.mermaid.render(id + '-svg', graph, wrapper);
if (out && out.svg) {
wrapper.innerHTML = out.svg;
if (out.bindFunctions) { out.bindFunctions(wrapper); }
renderSuccess = true;
}
} else if (window.mermaid && window.mermaid.init) {
// Legacy fallback
wrapper.textContent = graph;
window.mermaid.init(undefined, wrapper);
renderSuccess = true;
}
} catch (e) {
console.warn(`Mermaid render failed (attempt ${attempt + 1}):`, e);
// If this isn't the last attempt, try to fix the mermaid code using AI
if (attempt < maxAttempts - 1) {
try {
console.log('Attempting to fix Mermaid syntax using AI...');
const fixedCode = await fixMermaidWithAI(graph, e.message || e.toString());
if (fixedCode && fixedCode !== graph) {
graph = fixedCode;
console.log('AI provided fixed Mermaid code, retrying...');
} else {
console.warn('AI could not provide fixed Mermaid code');
break;
}
} catch (aiError) {
console.warn('AI Mermaid fix failed:', aiError);
break;
}
}
}
attempt++;
}
// If all attempts failed, show the original code
if (!renderSuccess) {
console.warn('All Mermaid render attempts failed, showing original code');
wrapper.textContent = graph;
}
}
}
async function fixMermaidWithAI(mermaidCode, errorMessage) {
try {
const formData = new FormData();
formData.append('mermaid_code', mermaidCode);
formData.append('error_message', errorMessage);
const response = await fetch('/mermaid/fix', {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
if (result.success && result.was_fixed) {
console.log('Mermaid syntax fixed by AI');
return result.fixed_code;
}
}
} catch (error) {
console.warn('Failed to fix Mermaid with AI:', error);
}
return null;
}
// Expose markdown-aware appenders for use after refresh (projects.js)
window.appendMessage = appendMessage;
window.appendSources = appendSources;
function addCopyButtonsToCodeBlocks(messageDiv) {
const codeBlocks = messageDiv.querySelectorAll('pre code');
codeBlocks.forEach((codeBlock, index) => {
const pre = codeBlock.parentElement;
if (!pre || pre.dataset.sbWrapped === '1') return;
const isMermaid = codeBlock.classList.contains('language-mermaid');
// Do not wrap mermaid blocks; they will be replaced by SVGs
if (isMermaid) return;
const language = codeBlock.className.match(/language-(\w+)/)?.[1] || 'code';
// Ensure syntax highlighting is applied before moving the node
try {
if (window.hljs && window.hljs.highlightElement) {
window.hljs.highlightElement(codeBlock);
}
} catch {}
// Create wrapper
const wrapper = document.createElement('div');
wrapper.className = 'code-block-wrapper';
// Create header with language and action buttons
const header = document.createElement('div');
header.className = 'code-block-header';
// Check if code is long enough to warrant an expand button
const codeText = codeBlock.textContent || '';
const isLongCode = codeText.split('\n').length > 15 || codeText.length > 500;
header.innerHTML = `
${language}
${isLongCode ? `
` : ''}
`;
// Create content wrapper and move the original
inside (preserves highlighting)
const content = document.createElement('div');
content.className = 'code-block-content';
if (isLongCode) {
content.classList.add('collapsed');
}
pre.dataset.sbWrapped = '1';
content.appendChild(pre);
// Assemble wrapper
wrapper.appendChild(header);
wrapper.appendChild(content);
// Insert wrapper where the original pre was
const parent = content.parentNode || messageDiv; // safety
if (pre.parentNode !== content) {
// pre has been moved into content; parent should be original parent of pre
const insertionParent = parent === messageDiv ? messageDiv : pre.parentNode;
}
// Replace in DOM: pre has been moved; place wrapper where pre used to be
const originalParent = content.parentNode ? content.parentNode : messageDiv;
// If pre had a previous sibling, insert wrapper before it; else append
const ref = wrapper.querySelector('.code-block-content pre');
const oldParent = wrapper.querySelector('.code-block-content pre').parentNode;
// oldParent is content; we need to place wrapper at the original location of pre
const originalPlaceholder = document.createComment('code-block-wrapper');
const preOriginalParent = wrapper.querySelector('.code-block-content pre').parentElement; // content
// Since we already moved pre, we can't auto-place; use previousSibling stored before move
// Simpler: insert wrapper after content creation at the position of 'content' parent
// If messageDiv contains multiple elements, just append wrapper now
messageDiv.appendChild(wrapper);
// Add click handlers for copy and expand buttons
const copyBtn = wrapper.querySelector('.copy-code-btn');
const expandBtn = wrapper.querySelector('.expand-code-btn');
if (copyBtn) {
copyBtn.addEventListener('click', () => copyCodeToClipboard(codeBlock.textContent, copyBtn));
}
if (expandBtn) {
expandBtn.addEventListener('click', () => toggleCodeExpansion(content, expandBtn));
}
});
}
function toggleCodeExpansion(content, button) {
const isCollapsed = content.classList.contains('collapsed');
if (isCollapsed) {
content.classList.remove('collapsed');
button.innerHTML = `
Collapse
`;
} else {
content.classList.add('collapsed');
button.innerHTML = `
Expand
`;
}
}
function copyCodeToClipboard(code, button) {
navigator.clipboard.writeText(code).then(() => {
const originalText = button.innerHTML;
button.innerHTML = `
Copied!
`;
button.classList.add('copied');
setTimeout(() => {
button.innerHTML = originalText;
button.classList.remove('copied');
}, 2000);
}).catch(err => {
console.error('Failed to copy code:', err);
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = code;
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
button.innerHTML = `
Copied!
`;
button.classList.add('copied');
setTimeout(() => {
button.innerHTML = `
Copy
`;
button.classList.remove('copied');
}, 2000);
} catch (fallbackErr) {
console.error('Fallback copy failed:', fallbackErr);
}
document.body.removeChild(textArea);
});
}
function addDownloadPdfButton(messageDiv, reportContent) {
const downloadBtn = document.createElement('button');
downloadBtn.className = 'download-pdf-btn';
downloadBtn.innerHTML = `
Download PDF
`;
downloadBtn.addEventListener('click', () => downloadReportAsPdf(reportContent, downloadBtn));
messageDiv.appendChild(downloadBtn);
}
async function downloadReportAsPdf(reportContent, button) {
const user = window.__sb_get_user();
const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
if (!user || !currentProject) {
alert('Please sign in and select a project');
return;
}
button.disabled = true;
button.innerHTML = `
Generating PDF...
`;
try {
// Find sources from the current message or recent sources
const sources = findCurrentSources();
const formData = new FormData();
formData.append('user_id', user.user_id);
formData.append('project_id', currentProject.project_id);
formData.append('report_content', reportContent);
formData.append('sources', JSON.stringify(sources));
const response = await fetch('/report/pdf', {
method: 'POST',
body: formData
});
if (response.ok) {
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `report-${new Date().toISOString().split('T')[0]}.pdf`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
button.innerHTML = `
Downloaded!
`;
setTimeout(() => {
button.innerHTML = `
Download PDF
`;
button.disabled = false;
}, 2000);
} else {
throw new Error('Failed to generate PDF');
}
} catch (error) {
console.error('PDF generation failed:', error);
alert('Failed to generate PDF. Please try again.');
button.innerHTML = `
Download PDF
`;
button.disabled = false;
}
}
function appendSources(sources) {
const sourcesDiv = document.createElement('div');
sourcesDiv.className = 'sources';
const sourcesList = sources.map(source => {
const filename = source.filename || 'unknown';
const topic = source.topic_name ? ` • ${source.topic_name}` : '';
const pages = source.page_span ? ` [pp. ${source.page_span.join('-')}]` : '';
const score = source.score ? ` (${source.score.toFixed(2)})` : '';
return `${filename}${topic}${pages}${score}`;
}).join(' ');
sourcesDiv.innerHTML = `Sources: ${sourcesList}`;
messages.appendChild(sourcesDiv);
// Store sources for PDF generation
window.__sb_current_sources = sources;
requestAnimationFrame(() => {
sourcesDiv.scrollIntoView({ behavior: 'smooth', block: 'end' });
});
}
function findCurrentSources() {
// Try to get sources from the current message context
if (window.__sb_current_sources) {
return window.__sb_current_sources;
}
// Fallback: look for sources in the last assistant message
const lastAssistantMsg = Array.from(messages.children).reverse().find(msg =>
msg.classList.contains('msg') && msg.classList.contains('assistant')
);
if (lastAssistantMsg) {
const sourcesDiv = lastAssistantMsg.nextElementSibling;
if (sourcesDiv && sourcesDiv.classList.contains('sources')) {
// Extract sources from the DOM (this is a fallback)
const pills = sourcesDiv.querySelectorAll('.pill');
const sources = Array.from(pills).map(pill => {
const text = pill.textContent;
const parts = text.split(' • ');
return {
filename: parts[0] || 'Unknown',
topic_name: parts[1] || '',
score: 0.0
};
});
return sources;
}
}
return [];
}
function showButtonLoading(button, isLoading) {
const textSpan = button.querySelector('.btn-text');
const loadingSpan = button.querySelector('.btn-loading');
// Handle buttons with only loading state (like send button)
if (!textSpan && loadingSpan) {
if (isLoading) {
loadingSpan.style.display = 'inline-flex';
button.disabled = true;
} else {
loadingSpan.style.display = 'none';
button.disabled = false;
}
return;
}
// Handle buttons with both text and loading states (like upload button)
if (textSpan && loadingSpan) {
if (isLoading) {
textSpan.style.display = 'none';
loadingSpan.style.display = 'inline-flex';
button.disabled = true;
} else {
textSpan.style.display = 'inline';
loadingSpan.style.display = 'none';
button.disabled = false;
}
return;
}
// Fallback for buttons without proper loading structure
if (isLoading) {
button.disabled = true;
} else {
button.disabled = false;
}
}
function showLoading(message = 'Processing...') {
loadingMessage.textContent = message;
loadingOverlay.classList.remove('hidden');
}
function hideLoading() {
loadingOverlay.classList.add('hidden');
}
function checkUserAuth() {
const user = window.__sb_get_user();
if (user) {
// Check if we have a current project
const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
if (currentProject) {
enableChat();
}
}
// Always update upload button state
updateUploadButton();
}
// Public API
window.__sb_update_upload_button = updateUploadButton;
window.__sb_enable_chat = enableChat;
// Listen for project changes
window.addEventListener('projectChanged', () => {
updateUploadButton();
const currentProject = window.__sb_get_current_project && window.__sb_get_current_project();
if (currentProject) {
enableChat();
}
});
// Load stored files whenever project changes
document.addEventListener('projectChanged', () => {
loadStoredFiles();
});
// Expose to other scripts
window.__sb_load_stored_files = loadStoredFiles;
// Also attempt loading stored files after initial auth/project load
window.addEventListener('load', () => {
setTimeout(loadStoredFiles, 500);
});
// Reveal animations
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('in');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1 });
document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
// Status polling function for real-time updates
function startStatusPolling(sessionId, thinkingMsg) {
const isReportMode = isReportModeActive();
const statusEndpoint = isReportMode ? `/report/status/${sessionId}` : `/chat/status/${sessionId}`;
const interval = setInterval(async () => {
try {
const response = await fetch(statusEndpoint);
if (response.ok) {
const status = await response.json();
updateThinkingMessage(thinkingMsg, status.message, status.progress);
// Stop polling when complete or error
if (status.status === 'complete' || status.status === 'error') {
clearInterval(interval);
}
}
} catch (error) {
console.warn('Status polling failed:', error);
}
}, 500); // Poll every 500ms
return interval;
}
function updateThinkingMessage(thinkingMsg, message, progress) {
if (thinkingMsg && thinkingMsg.querySelector) {
const progressBar = thinkingMsg.querySelector('.progress-bar');
const statusText = thinkingMsg.querySelector('.status-text');
if (statusText) {
statusText.textContent = message;
}
if (progressBar && progress !== undefined) {
progressBar.style.width = `${progress}%`;
}
}
}
// Enhanced thinking message with progress bar
function appendMessage(role, text, isReport = false) {
const messageDiv = document.createElement('div');
messageDiv.className = `msg ${role}`;
if (role === 'thinking') {
messageDiv.innerHTML = `
`;
} else if (role === 'assistant') {
renderAssistantMarkdown(messageDiv, text, isReport);
} else {
messageDiv.textContent = text;
}
messages.appendChild(messageDiv);
requestAnimationFrame(() => {
messageDiv.scrollIntoView({ behavior: 'smooth', block: 'end' });
});
return messageDiv;
}
})();