BioRAG / static /js /app.js
aseelflihan's picture
feat: add token usage tracking and display, update sample questions for demo scenarios
5cb4c11
/* ============================================
Bio-RAG — Application Logic
============================================ */
// --- DOM Elements ---
const $ = (sel) => document.querySelector(sel);
const $$ = (sel) => document.querySelectorAll(sel);
const DOM = {
sidebar: $('#sidebar'),
sidebarToggle: $('#sidebarToggle'),
sidebarHistory: $('#sidebarHistory'),
menuBtn: $('#menuBtn'),
newChatBtn: $('#newChatBtn'),
headerNewChat: $('#headerNewChat'),
headerDeleteChat: $('#headerDeleteChat'),
chatArea: $('#chatArea'),
messages: $('#messages'),
welcomeScreen: $('#welcomeScreen'),
questionInput: $('#questionInput'),
sendBtn: $('#sendBtn'),
deleteModal: $('#deleteModal'),
deleteCancelBtn: $('#deleteCancelBtn'),
deleteConfirmBtn: $('#deleteConfirmBtn'),
};
// --- State ---
const state = {
isProcessing: false,
conversations: JSON.parse(localStorage.getItem('biorag_history') || '[]'),
currentMessages: [],
currentChatId: null,
chatToDelete: null,
};
// ============================================
// INITIALIZATION
// ============================================
document.addEventListener('DOMContentLoaded', () => {
initEventListeners();
renderHistory();
autoResizeTextarea();
});
function initEventListeners() {
// Send
DOM.sendBtn.addEventListener('click', handleSend);
DOM.questionInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
});
// Input state
DOM.questionInput.addEventListener('input', () => {
autoResizeTextarea();
DOM.sendBtn.disabled = !DOM.questionInput.value.trim();
});
// Sidebar
DOM.sidebarToggle.addEventListener('click', () => toggleSidebar(false));
DOM.menuBtn.addEventListener('click', () => toggleSidebar(true));
// New chat
DOM.newChatBtn.addEventListener('click', newChat);
DOM.headerNewChat.addEventListener('click', newChat);
// Delete chat
DOM.headerDeleteChat.addEventListener('click', () => {
if (state.currentChatId) {
showDeleteModal(state.currentChatId);
}
});
DOM.deleteCancelBtn.addEventListener('click', hideDeleteModal);
DOM.deleteConfirmBtn.addEventListener('click', confirmDelete);
// Close modal on background click
DOM.deleteModal.addEventListener('click', (e) => {
if (e.target === DOM.deleteModal) {
hideDeleteModal();
}
});
// Suggestion cards
$$('.suggestion-card').forEach(card => {
card.addEventListener('click', () => {
const question = card.dataset.question;
DOM.questionInput.value = question;
DOM.sendBtn.disabled = false;
handleSend();
});
});
}
// ============================================
// SIDEBAR
// ============================================
function toggleSidebar(open) {
if (open) {
DOM.sidebar.classList.remove('collapsed');
DOM.sidebar.classList.add('open');
} else {
DOM.sidebar.classList.add('collapsed');
DOM.sidebar.classList.remove('open');
}
}
function renderHistory() {
DOM.sidebarHistory.innerHTML = '';
if (state.conversations.length === 0) return;
const now = new Date();
const today = [];
const yesterday = [];
const older = [];
state.conversations.forEach(conv => {
const d = new Date(conv.timestamp);
const diffDays = Math.floor((now - d) / 86400000);
if (diffDays === 0) today.push(conv);
else if (diffDays === 1) yesterday.push(conv);
else older.push(conv);
});
if (today.length) addHistorySection('Today', today);
if (yesterday.length) addHistorySection('Yesterday', yesterday);
if (older.length) addHistorySection('Previous', older);
}
function addHistorySection(title, items) {
const h = document.createElement('div');
h.className = 'history-section-title';
h.textContent = title;
DOM.sidebarHistory.appendChild(h);
items.forEach(conv => {
const wrapper = document.createElement('div');
wrapper.className = 'history-item-wrapper';
const btn = document.createElement('button');
btn.className = 'history-item';
btn.textContent = conv.title;
btn.addEventListener('click', () => loadConversation(conv));
const deleteBtn = document.createElement('button');
deleteBtn.className = 'delete-chat-btn';
deleteBtn.title = 'Delete chat';
deleteBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>`;
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
showDeleteModal(conv.id);
});
wrapper.appendChild(btn);
wrapper.appendChild(deleteBtn);
DOM.sidebarHistory.appendChild(wrapper);
});
}
function loadConversation(conv) {
state.currentChatId = conv.id;
state.currentMessages = conv.messages || [];
DOM.messages.innerHTML = '';
DOM.welcomeScreen.style.display = 'none';
DOM.headerDeleteChat.style.display = 'flex';
state.currentMessages.forEach(msg => {
if (msg.role === 'user') {
addUserMessageToDOM(msg.content);
} else {
addBotMessageToDOM(msg.content, msg.resultData, false);
}
});
scrollToBottom();
}
// ============================================
// NEW CHAT
// ============================================
function newChat() {
saveCurrentConversation();
state.currentChatId = null;
state.currentMessages = [];
DOM.messages.innerHTML = '';
DOM.welcomeScreen.style.display = '';
DOM.questionInput.value = '';
DOM.sendBtn.disabled = true;
DOM.headerDeleteChat.style.display = 'none';
scrollToBottom();
}
function saveCurrentConversation() {
if (state.currentMessages.length === 0) return;
const firstUserMsg = state.currentMessages.find(m => m.role === 'user');
const title = firstUserMsg
? firstUserMsg.content.slice(0, 50) + (firstUserMsg.content.length > 50 ? '...' : '')
: 'Untitled';
const conv = {
id: Date.now(),
title,
timestamp: new Date().toISOString(),
messages: state.currentMessages,
};
state.conversations.unshift(conv);
if (state.conversations.length > 30) state.conversations.pop();
localStorage.setItem('biorag_history', JSON.stringify(state.conversations));
renderHistory();
}
// ============================================
// DELETE CHAT
// ============================================
function showDeleteModal(chatId) {
state.chatToDelete = chatId;
DOM.deleteModal.classList.add('show');
}
function hideDeleteModal() {
state.chatToDelete = null;
DOM.deleteModal.classList.remove('show');
}
function confirmDelete() {
if (!state.chatToDelete) return;
// Remove from conversations
state.conversations = state.conversations.filter(c => c.id !== state.chatToDelete);
localStorage.setItem('biorag_history', JSON.stringify(state.conversations));
// If deleting current chat, start new chat
if (state.currentChatId === state.chatToDelete) {
state.currentChatId = null;
state.currentMessages = [];
DOM.messages.innerHTML = '';
DOM.welcomeScreen.style.display = '';
DOM.headerDeleteChat.style.display = 'none';
}
// Update UI
renderHistory();
hideDeleteModal();
}
// ============================================
// SEND & RECEIVE
// ============================================
async function handleSend() {
const question = DOM.questionInput.value.trim();
if (!question || state.isProcessing) return;
state.isProcessing = true;
DOM.sendBtn.disabled = true;
DOM.questionInput.value = '';
autoResizeTextarea();
DOM.welcomeScreen.style.display = 'none';
addUserMessageToDOM(question);
state.currentMessages.push({ role: 'user', content: question });
scrollToBottom();
// Create bot message wrapper
const botWrapper = document.createElement('div');
botWrapper.className = 'msg-bot';
const avatar = document.createElement('div');
avatar.className = 'msg-bot-avatar';
avatar.textContent = '🧬';
const botContent = document.createElement('div');
botContent.className = 'msg-bot-content';
const progressEl = createPipelineProgress();
botContent.appendChild(progressEl);
const textEl = document.createElement('div');
textEl.className = 'msg-bot-text';
textEl.style.display = 'none';
botContent.appendChild(textEl);
botWrapper.appendChild(avatar);
botWrapper.appendChild(botContent);
DOM.messages.appendChild(botWrapper);
animatePipelineStep(progressEl, 0);
scrollToBottom();
try {
const response = await fetch('/api/ask-stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question }),
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let finalResult = null;
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;
try {
const event = JSON.parse(line.slice(6).trim());
if (event.step !== undefined) {
if (event.status === 'active') animatePipelineStep(progressEl, event.step);
else if (event.status === 'done') completePipelineStep(progressEl, event.step);
scrollToBottom();
}
if (event.answer_ready) {
textEl.style.display = '';
typewriter(textEl, event.answer);
}
if (event.complete) finalResult = event.result;
if (event.error) throw new Error(event.error);
} catch (e) { if (e.message && !e.message.includes('JSON')) throw e; }
}
}
// Pipeline complete
const dot = progressEl.querySelector('.pipeline-header-dot');
if (dot) dot.classList.add('done');
const label = progressEl.querySelector('.pipeline-current-label');
if (label) label.textContent = '';
const comp = progressEl.querySelector('.pipeline-complete');
if (comp) {
comp.className = 'pipeline-complete show safe';
comp.innerHTML = '<span class="pipeline-complete-icon">✓</span> Done';
}
if (finalResult) {
const answerText = finalResult.final_answer || finalResult.rejection_message || 'No response.';
const isRejection = !!finalResult.rejection_message && (!finalResult.claim_checks || finalResult.claim_checks.length === 0);
if (isRejection) {
botWrapper.remove();
addRejectionToDOM(answerText);
} else {
textEl.innerHTML = formatText(answerText);
setTimeout(() => highlightRisksInText(textEl, finalResult), 500);
const panel = buildVerificationPanel(finalResult);
botContent.appendChild(panel);
}
state.currentMessages.push({ role: 'assistant', content: answerText, resultData: finalResult });
}
scrollToBottom();
} catch (err) {
botWrapper.remove();
addErrorToDOM(err.message || 'Connection failed.');
}
state.isProcessing = false;
DOM.sendBtn.disabled = !DOM.questionInput.value.trim();
}
// ============================================
// DOM BUILDERS
// ============================================
function addUserMessageToDOM(text) {
const div = document.createElement('div');
div.className = 'msg-user';
div.innerHTML = `<div class="msg-user-bubble">${escapeHTML(text)}</div>`;
DOM.messages.appendChild(div);
}
async function addBotMessageToDOM(text, resultData, animate) {
const wrapper = document.createElement('div');
wrapper.className = 'msg-bot';
const avatar = document.createElement('div');
avatar.className = 'msg-bot-avatar';
avatar.textContent = '🧬';
const content = document.createElement('div');
content.className = 'msg-bot-content';
const textEl = document.createElement('div');
textEl.className = 'msg-bot-text';
content.appendChild(textEl);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
DOM.messages.appendChild(wrapper);
// Typewriter or instant
if (animate) {
await typewriter(textEl, text);
} else {
textEl.innerHTML = formatText(text);
}
// Verification panel
if (resultData && resultData.claim_checks && resultData.claim_checks.length > 0) {
// Apply inline risk highlighting after text is rendered
setTimeout(() => {
highlightRisksInText(textEl, resultData);
}, animate ? 300 : 0);
const panel = buildVerificationPanel(resultData);
content.appendChild(panel);
}
scrollToBottom();
}
function addRejectionToDOM(text) {
const wrapper = document.createElement('div');
wrapper.className = 'msg-bot';
const avatar = document.createElement('div');
avatar.className = 'msg-bot-avatar';
avatar.textContent = '🧬';
const content = document.createElement('div');
content.className = 'msg-bot-content';
const rejection = document.createElement('div');
rejection.className = 'msg-rejection';
rejection.innerHTML = `<span>⚠️</span><span>${escapeHTML(text)}</span>`;
content.appendChild(rejection);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
DOM.messages.appendChild(wrapper);
}
function addErrorToDOM(text) {
const wrapper = document.createElement('div');
wrapper.className = 'msg-error';
const avatar = document.createElement('div');
avatar.className = 'msg-bot-avatar';
avatar.textContent = '🧬';
const content = document.createElement('div');
content.className = 'msg-error-content';
content.textContent = `Error: ${text}`;
wrapper.appendChild(avatar);
wrapper.appendChild(content);
DOM.messages.appendChild(wrapper);
}
// ============================================
// THINKING INDICATOR
// ============================================
function showThinking() {
const wrapper = document.createElement('div');
wrapper.className = 'thinking';
const avatar = document.createElement('div');
avatar.className = 'msg-bot-avatar';
avatar.textContent = '🧬';
const content = document.createElement('div');
content.className = 'thinking-content';
const dots = document.createElement('div');
dots.className = 'thinking-dots';
dots.innerHTML = '<span class="thinking-dot"></span><span class="thinking-dot"></span><span class="thinking-dot"></span>';
const steps = document.createElement('div');
steps.className = 'thinking-steps';
content.appendChild(dots);
content.appendChild(steps);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
DOM.messages.appendChild(wrapper);
return wrapper;
}
function updateThinkingStep(el, index, text) {
const stepsContainer = el.querySelector('.thinking-steps');
if (!stepsContainer) return;
// Mark previous as done
const prevSteps = stepsContainer.querySelectorAll('.thinking-step');
prevSteps.forEach(s => {
s.classList.remove('active');
s.classList.add('done');
const icon = s.querySelector('.step-icon');
if (icon) icon.textContent = '✓';
});
// Add new step
const step = document.createElement('div');
step.className = 'thinking-step active';
step.innerHTML = `<span class="step-icon">○</span> ${escapeHTML(text)}`;
stepsContainer.appendChild(step);
scrollToBottom();
}
// ============================================
// VERIFICATION PANEL
// ============================================
function buildVerificationPanel(data) {
const claims = data.claim_checks || [];
const maxRisk = data.max_risk_score || 0;
const isSafe = data.safe !== false && maxRisk < 0.7;
const evidence = data.evidence || [];
const panel = document.createElement('div');
panel.className = `verification-panel ${isSafe ? 'safe' : 'flagged'}`;
// Summary
const summary = document.createElement('div');
summary.className = 'verification-summary';
summary.innerHTML = `
<div class="verification-info">
<div class="verification-status ${isSafe ? 'safe' : 'flagged'}">
${isSafe ? '✅' : '⚠️'} ${isSafe ? 'Safe' : 'Flagged'} — Risk: ${maxRisk.toFixed(4)}
</div>
<div class="verification-meta">
${claims.length} claims verified • ${Math.min(evidence.length, 3)} sources cited • ${data.processing_time_seconds ? data.processing_time_seconds + 's' : ''}
</div>
${data.processing_stats ? `
<div class="processing-stats" style="margin-top:8px; font-size:12px; color:var(--text-secondary, #888); line-height:1.8;">
<div>📊 <strong>Database:</strong> ${data.processing_stats.total_db_size.toLocaleString()} documents</div>
<div>🔍 <strong>Queries Generated:</strong> ${data.processing_stats.queries_generated}</div>
<div>📄 <strong>Passages Retrieved:</strong> ${data.processing_stats.passages_retrieved} → Top ${Math.min(data.processing_stats.passages_retrieved, 10)} after RRF</div>
<div>✂️ <strong>Claims Decomposed:</strong> ${data.processing_stats.claims_verified}</div>
<div>🔬 <strong>Total Evidence Evaluated:</strong> ${data.processing_stats.total_evidence_evaluated} (${data.processing_stats.claims_verified} claims × ${data.processing_stats.evidence_per_claim} docs)</div>
${data.processing_stats.token_usage ? `
<div>🪙 <strong>Tokens:</strong> Input: ${data.processing_stats.token_usage.prompt_tokens} • Output: ${data.processing_stats.token_usage.completion_tokens} • Total: ${data.processing_stats.token_usage.total_tokens}</div>` : ''}
${data.processing_stats.phase_times ? `
<div style="margin-top:4px;">⏱️ <strong>Phase Times:</strong>
Query Expansion: ${data.processing_stats.phase_times.query_expansion || 0}s •
Retrieval: ${data.processing_stats.phase_times.retrieval || 0}s •
Generation: ${data.processing_stats.phase_times.generation || 0}s •
Decomposition: ${data.processing_stats.phase_times.decomposition || 0}s •
Verification: ${data.processing_stats.phase_times.verification || 0}s
</div>` : ''}
</div>` : ''}
</div>
<div class="verification-toggle">View Details ▼</div>
`;
// Details
const details = document.createElement('div');
details.className = 'verification-details';
let detailsHTML = '<div class="verification-details-inner">';
detailsHTML += '<div class="claims-title">Claims & Risk Scores</div>';
// Sort claims by risk (highest first)
const sortedClaims = [...claims].sort((a, b) => (b.risk_score || 0) - (a.risk_score || 0));
sortedClaims.forEach(c => {
const risk = c.risk_score || 0;
const pct = Math.min(risk * 100, 100);
const level = risk >= 0.7 ? 'high' : risk >= 0.3 ? 'medium' : 'low';
detailsHTML += `
<div class="claim-item">
<div class="claim-risk-bar-container">
<div class="claim-risk-bar">
<div class="claim-risk-bar-fill ${level}" style="width: ${pct}%"></div>
</div>
<span class="claim-risk-value">${risk.toFixed(4)}</span>
</div>
<div class="claim-text">${escapeHTML(c.claim || '')}</div>
</div>
`;
});
// Evidence
if (evidence.length > 0) {
detailsHTML += '<div class="evidence-title">Retrieved Evidence</div>';
evidence.slice(0, 3).forEach((ev, i) => {
const text = typeof ev === 'string' ? ev : (ev.text || JSON.stringify(ev));
detailsHTML += `
<div class="evidence-item">
<span class="evidence-icon">📄</span>
<span class="evidence-text">Doc ${i + 1}: ${escapeHTML(text.slice(0, 150))}...</span>
</div>
`;
});
}
detailsHTML += '</div>';
details.innerHTML = detailsHTML;
// Toggle
summary.addEventListener('click', () => {
const isOpen = details.classList.toggle('open');
summary.querySelector('.verification-toggle').textContent = isOpen ? 'Hide Details ▲' : 'View Details ▼';
});
panel.appendChild(summary);
panel.appendChild(details);
return panel;
}
// ============================================
// TYPEWRITER EFFECT
// ============================================
async function typewriter(element, text) {
const words = text.split(' ');
const cursor = document.createElement('span');
cursor.className = 'cursor';
let currentHTML = '';
element.appendChild(cursor);
for (let i = 0; i < words.length; i++) {
currentHTML += (i > 0 ? ' ' : '') + escapeHTML(words[i]);
element.innerHTML = formatText(currentHTML);
element.appendChild(cursor);
scrollToBottom();
await delay(25);
}
cursor.remove();
element.innerHTML = formatText(text);
}
// ============================================
// INLINE RISK HIGHLIGHTING
// ============================================
function highlightRisksInText(textElement, resultData) {
if (!resultData || !resultData.claim_checks || resultData.claim_checks.length === 0) return;
const originalText = resultData.original_answer || textElement.textContent;
const sentences = splitIntoSentences(originalText);
const claims = resultData.claim_checks;
// Map each sentence to its highest risk score
const sentenceRisks = sentences.map(sentence => {
const matchingClaims = findMatchingClaims(sentence, claims);
const maxRisk = matchingClaims.length > 0
? Math.max(...matchingClaims.map(c => c.risk_score || 0))
: 0;
const topClaim = matchingClaims.sort((a, b) => (b.risk_score || 0) - (a.risk_score || 0))[0];
return { sentence, maxRisk, topClaim };
});
// Build highlighted HTML
let html = '';
sentenceRisks.forEach(({ sentence, maxRisk, topClaim }) => {
if (maxRisk >= 0.7) {
html += buildHighlightedSentence(sentence, maxRisk, topClaim, 'danger');
} else if (maxRisk >= 0.15) {
html += buildHighlightedSentence(sentence, maxRisk, topClaim, 'caution');
} else {
html += escapeHTML(sentence) + ' ';
}
});
// Apply with animation
textElement.innerHTML = `<p>${html.trim()}</p>`;
// Trigger animation
setTimeout(() => {
textElement.querySelectorAll('.risk-sentence').forEach(el => {
el.classList.add('animate-in');
});
}, 100);
}
function buildHighlightedSentence(sentence, risk, claim, level) {
const tooltipLabel = level === 'danger'
? 'Unverified or contradicted'
: 'Low confidence';
const claimText = claim ? escapeHTML(claim.claim || '').slice(0, 60) + '...' : '';
return `<span class="risk-sentence risk-${level}">` +
`${escapeHTML(sentence)} ` +
`<span class="risk-tooltip">` +
`<span class="tooltip-risk ${level}">Risk: ${risk.toFixed(3)}</span><br>` +
`${tooltipLabel}` +
`${claimText ? '<br><em>' + claimText + '</em>' : ''}` +
`</span>` +
`</span> `;
}
function splitIntoSentences(text) {
// Split on sentence boundaries but keep the delimiter
const raw = text.split(/(?<=[.!?])\s+/);
return raw.filter(s => s.trim().length > 5);
}
function findMatchingClaims(sentence, claims) {
const sentenceClean = sentence.toLowerCase().replace(/[^\w\s]/g, '');
const sentenceWords = new Set(
sentenceClean.split(/\s+/).filter(w => w.length > 3)
);
if (sentenceWords.size === 0) return [];
const results = [];
claims.forEach(claim => {
const claimText = (claim.claim || '').toLowerCase().replace(/[^\w\s]/g, '');
const claimWords = claimText.split(/\s+/).filter(w => w.length > 3);
if (claimWords.length === 0) return;
// Count matches in both directions
const claimInSentence = claimWords.filter(w => sentenceWords.has(w)).length;
const sentenceInClaim = [...sentenceWords].filter(w => claimWords.includes(w)).length;
const claimMatchRatio = claimInSentence / claimWords.length;
const sentenceMatchRatio = sentenceInClaim / sentenceWords.size;
// Both directions must match at least 50%
// This prevents a short claim from matching many long sentences
if (claimMatchRatio >= 0.5 && sentenceMatchRatio >= 0.3) {
results.push(claim);
}
});
return results;
}
// ============================================
// PIPELINE PROGRESS INDICATOR
// ============================================
function createPipelineProgress() {
const div = document.createElement('div');
div.className = 'pipeline-progress';
const stepNames = [
'Domain Check', 'Query Expansion', 'Retrieval', 'Generation',
'Decomposition', 'Evidence', 'NLI', 'Risk Score', 'Decision'
];
let html = `<div class="pipeline-header"><span class="pipeline-header-dot"></span>Pipeline</div>`;
html += `<div class="pipeline-phases">`;
html += `<div class="pipeline-phase"><div class="pipeline-steps">`;
for (let i = 0; i < 4; i++) {
html += `<div class="pipeline-step pending" data-step="${i}">
<span class="pipeline-step-icon">✓</span>
<span class="step-tooltip">${stepNames[i]}</span>
</div>`;
}
html += `</div></div>`;
html += `<div class="pipeline-phase-sep"></div>`;
html += `<div class="pipeline-phase"><div class="pipeline-steps">`;
for (let i = 4; i < 9; i++) {
html += `<div class="pipeline-step pending" data-step="${i}">
<span class="pipeline-step-icon">✓</span>
<span class="step-tooltip">${stepNames[i]}</span>
</div>`;
}
html += `</div></div>`;
html += `</div>`;
html += `<div class="pipeline-complete"></div>`;
html += `<span class="pipeline-current-label" id="pipelineLabel"></span>`;
div.innerHTML = html;
return div;
}
async function animatePipelineStep(progressEl, stepIndex) {
const step = progressEl.querySelector(`[data-step="${stepIndex}"]`);
if (!step) return;
const stepNames = [
'Domain Check...', 'Expanding Query...', 'Retrieving Evidence...', 'Generating Answer...',
'Decomposing Claims...', 'Retrieving Per-Claim...', 'NLI Evaluation...', 'Risk Scoring...', 'Final Decision...'
];
for (let i = 0; i < stepIndex; i++) {
const prev = progressEl.querySelector(`[data-step="${i}"]`);
if (prev && !prev.classList.contains('done')) {
prev.classList.remove('pending', 'active');
prev.classList.add('done');
}
}
step.classList.remove('pending');
step.classList.add('active');
// Update label
const label = progressEl.querySelector('.pipeline-current-label');
if (label) label.textContent = stepNames[stepIndex] || '';
scrollToBottom();
}
function completePipelineStep(progressEl, stepIndex) {
const step = progressEl.querySelector(`[data-step="${stepIndex}"]`);
if (!step) return;
step.classList.remove('pending', 'active');
step.classList.add('done');
}
function showPipelineComplete(progressEl) {
// Mark all steps as done
progressEl.querySelectorAll('.pipeline-step').forEach(s => {
s.classList.remove('pending', 'active');
s.classList.add('done');
});
// Update header dot
const dot = progressEl.querySelector('.pipeline-header-dot');
if (dot) dot.classList.add('done');
// Show simple completion
const complete = progressEl.querySelector('.pipeline-complete');
if (complete) {
complete.className = 'pipeline-complete show safe';
complete.innerHTML = '<span class="pipeline-complete-icon">✓</span> Pipeline Complete';
}
scrollToBottom();
// Collapse after 2 seconds
setTimeout(() => {
progressEl.classList.add('collapsed');
}, 2000);
}
// ============================================
// UTILITIES
// ============================================
function escapeHTML(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function formatText(text) {
// Convert line breaks to paragraphs
return text.split(/\n\n+/).map(p => `<p>${p.replace(/\n/g, '<br>')}</p>`).join('');
}
function scrollToBottom() {
DOM.chatArea.scrollTop = DOM.chatArea.scrollHeight;
}
function autoResizeTextarea() {
const el = DOM.questionInput;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
}
function delay(ms) {
return new Promise(r => setTimeout(r, ms));
}