/* ═══════════════════════════════════════════════════════════════ MedBot v4 — Premium Medical Chat UI Splash screen, typewriter effects, scroll management, and chat engine ═══════════════════════════════════════════════════════════════ */ (() => { 'use strict'; const state = { sessionId: null, isLoading: false, sidebarOpen: false, hasScrolledUp: false }; const $ = id => document.getElementById(id); const D = { input: $('chat-input'), sendBtn: $('send-btn'), msgList: $('messages-list'), scroll: $('messages-scroll'), typing: $('typing-indicator'), welcome: $('welcome'), counter: $('counter'), statusDot: document.querySelector('.status-dot'), statusLabel: $('status-label'), statChunks: $('stat-chunks'), statModel: $('stat-model'), modelSelect: $('model-select'), inputWrap: $('input-wrap'), sidebar: $('sidebar'), hamburger: $('hamburger'), backdrop: $('mobile-backdrop'), btnNew: $('btn-new-chat'), btnClear: $('btn-clear-chat'), splash: $('splash-screen'), greeting: $('greeting-text'), scrollFab: $('scroll-fab') }; /* ══════════════════════════════════════════ 1. UI EFFECTS & INITIALIZATION ══════════════════════════════════════════ */ function initSplash() { // Only show splash on initial load if no session exists if (D.splash) { setTimeout(() => { D.splash.classList.add('hide'); // Start welcome animations after splash hides setTimeout(typewriterGreeting, 400); }, 2500); } else { typewriterGreeting(); } } function typewriterGreeting() { if (!D.greeting) return; const text = "Hello, Clinician."; D.greeting.textContent = ""; let i = 0; function typeWriter() { if (i < text.length) { D.greeting.textContent += text.charAt(i); i++; setTimeout(typeWriter, 50 + Math.random() * 50); // random delay for realism } } // Add a slight delay before typing starts setTimeout(typeWriter, 200); } /* ══════════════════════════════════════════ 2. MARKDOWN RENDERER ══════════════════════════════════════════ */ function md(raw) { if (!raw) return ''; let h = raw.replace(/&/g, '&').replace(//g, '>'); h = h.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, l, c) => `
${c.trim()}
`); h = h.replace(/`([^`\n]+)`/g, '$1'); h = h.replace(/^#### (.+)$/gm, '

$1

'); h = h.replace(/^### (.+)$/gm, '

$1

'); h = h.replace(/^## (.+)$/gm, '

$1

'); h = h.replace(/^# (.+)$/gm, '

$1

'); h = h.replace(/\*\*\*(.+?)\*\*\*/g, '$1'); h = h.replace(/\*\*(.+?)\*\*/g, '$1'); h = h.replace(/\*([^*\n]+)\*/g, '$1'); h = h.replace(/^> (.+)$/gm, '
$1
'); h = h.replace(/^---+$/gm, '
'); h = h.replace(/^[\s]*[-*•] (.+)$/gm, '
  • $1
  • '); h = h.replace(/((?:
  • .*<\/li>\n?)+)/g, ''); h = h.replace(/^\d+\. (.+)$/gm, '$1'); h = h.replace(/((?:.*<\/oli>\n?)+)/g, m => '
      ' + m.replace(//g, '
    1. ').replace(/<\/oli>/g, '
    2. ') + '
    '); const lines = h.split('\n'); let inT = false; const out = lines.map(line => { const t = line.trim(); if (t.startsWith('|') && t.endsWith('|')) { const cells = t.slice(1, -1).split('|').map(c => c.trim()); if (cells.every(c => /^[-:]+$/.test(c))) { inT = true; return ''; } const tag = !inT ? 'th' : 'td'; if (!inT) inT = true; return '' + cells.map(c => `<${tag}>${c}`).join('') + ''; } if (inT) { inT = false; return '' + line; } return line; }); h = out.join('\n'); h = h.replace(/(.*<\/tr>(\n|$))+/g, m => `${m}
    `); const blocks = h.split(/\n{2,}/); h = blocks.map(b => { const t = b.trim(); if (!t) return ''; if (/^<(h[1-6]|ul|ol|pre|table|blockquote|hr)/.test(t)) return t; return `

    ${t.replace(/\n/g, '
    ')}

    `; }).join('\n'); return h; } /* ══════════════════════════════════════════ 3. CHAT ENGINE & SCROLLING ══════════════════════════════════════════ */ function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; } function ts() { return new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } function scrollEnd(force = false) { if (!D.scroll) return; // Don't auto-scroll if user has manually scrolled up, unless forced if (state.hasScrolledUp && !force) return; requestAnimationFrame(() => { D.scroll.scrollTop = D.scroll.scrollHeight; state.hasScrolledUp = false; }); } // Monitor scrolling to show/hide FAB and pause auto-scroll if (D.scroll) { D.scroll.addEventListener('scroll', () => { const isAtBottom = D.scroll.scrollHeight - D.scroll.scrollTop <= D.scroll.clientHeight + 50; state.hasScrolledUp = !isAtBottom; if (D.scrollFab) { if (state.hasScrolledUp) { D.scrollFab.classList.add('visible'); } else { D.scrollFab.classList.remove('visible'); } } }, { passive: true }); } if (D.scrollFab) { D.scrollFab.addEventListener('click', () => { state.hasScrolledUp = false; scrollEnd(true); }); } function addMsg(content, role, sources = []) { if (D.welcome) D.welcome.style.display = 'none'; const wrap = document.createElement('div'); wrap.className = `msg msg--${role}`; const avHTML = role === 'user' ? `
    You
    ` : `
    💊
    `; let bodyHTML; if (role === 'user') { bodyHTML = `
    ${esc(content)}
    ${ts()}
    `; } else { const srcHTML = sources.length ? `
    ${sources.map(s => `📄 ${s.book} · ${s.section}` ).join('')}
    ` : ''; bodyHTML = `
    ${md(content)}
    ${srcHTML}
    ${ts()}
    `; } wrap.innerHTML = avHTML + bodyHTML; if (D.msgList) D.msgList.appendChild(wrap); scrollEnd(true); // Force scroll on new message return wrap; } function setLoading(on) { state.isLoading = on; if (D.sendBtn) { D.sendBtn.disabled = on || !D.input.value.trim(); if (on) D.sendBtn.classList.add('sending'); else D.sendBtn.classList.remove('sending'); } if (D.input) D.input.disabled = on; if (D.typing) D.typing.style.display = on ? 'flex' : 'none'; if (on) scrollEnd(true); } async function send(text) { const msg = text.trim(); if (!msg || state.isLoading) return; addMsg(msg, 'user'); if (D.input) { D.input.value = ''; autoH(); } updateCounter(); setLoading(true); state.hasScrolledUp = false; // Reset scroll state when sending try { const selectedModel = D.modelSelect ? D.modelSelect.value : undefined; const r = await fetch(window.location.origin + '/api/chat_stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: msg, session_id: state.sessionId, model: selectedModel }), }); if (!r.ok) { if (r.status === 404) throw new Error("API endpoint not found. Server configuration issue."); if (r.status === 500) throw new Error("Internal server error. Model API might be rate-limited or the backend crashed."); throw new Error(`HTTP ${r.status}`); } // Hide typing indicator immediately when stream connects if (D.typing) D.typing.style.display = 'none'; const reader = r.body.getReader(); const decoder = new TextDecoder("utf-8"); const botMsgEl = addMsg('', 'bot'); const msgBodyEl = botMsgEl.querySelector('.msg-body'); const msgSourcesEl = botMsgEl.querySelector('.msg-sources'); let fullAnswer = ''; // Add streaming cursor msgBodyEl.classList.add('streaming-cursor'); let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); // Keep the last partial line in the buffer buffer = lines.pop(); for (const line of lines) { if (!line.trim()) continue; try { const obj = JSON.parse(line); if (obj.type === "metadata" && obj.sources && obj.sources.length > 0) { msgSourcesEl.style.display = 'flex'; msgSourcesEl.innerHTML = obj.sources.map(s => `📄 ${s.book} · ${s.section}`).join(''); } else if (obj.type === "chunk") { fullAnswer += obj.content; msgBodyEl.innerHTML = md(fullAnswer); scrollEnd(); } else if (obj.type === "session") { state.sessionId = obj.session_id; } else if (obj.type === "error") { addMsg(`⚠️ **Error:** ${obj.content}`, 'bot'); } } catch (e) { console.warn("Could not parse NDJSON line:", line); } } } msgBodyEl.classList.remove('streaming-cursor'); // Format any remaining buffer if (buffer.trim()) { try { const obj = JSON.parse(buffer); if (obj.type === "chunk") { fullAnswer += obj.content; msgBodyEl.innerHTML = md(fullAnswer); } } catch(e){} } } catch (err) { console.error("Chat flow error:", err); let errMsg = err.message; if (errMsg.toLowerCase().includes("failed to fetch")) { errMsg = "Could not connect to the local MedBot backend server. Please verify the `start.sh` script is running in your terminal."; } addMsg(`⚠️ **Connection Error:** ${errMsg}\n\nPlease try again or restart the server using \`./start.sh\`.`, 'bot'); } finally { setLoading(false); if (window.innerWidth > 768 && D.input) D.input.focus(); } } window.medbotSend = send; /* ══════════════════════════════════════════ 4. UI & MOBILE EVENTS ══════════════════════════════════════════ */ function autoH() { if (!D.input) return; D.input.style.height = 'auto'; D.input.style.height = Math.min(D.input.scrollHeight, 120) + 'px'; } function updateCounter() { if (!D.input || !D.counter) return; const n = D.input.value.length; D.counter.textContent = `${n} / 2000`; D.counter.classList.toggle('warn', n > 1800); if (D.sendBtn) D.sendBtn.disabled = !D.input.value.trim() || state.isLoading; } if (D.input) { D.input.addEventListener('input', () => { updateCounter(); autoH(); }); D.input.addEventListener('focus', () => D.inputWrap?.classList.add('focused')); D.input.addEventListener('blur', () => D.inputWrap?.classList.remove('focused')); D.input.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); // Check button instead of state.isLoading to respect button disabled state if (!D.sendBtn.disabled && D.input.value.trim()) send(D.input.value); } }); } D.sendBtn?.addEventListener('click', () => { if (D.input.value.trim() && !D.sendBtn.disabled) send(D.input.value); }); D.modelSelect?.addEventListener('change', (e) => { const short = e.target.value.split('/').pop().replace(':free', ''); if (D.statModel) D.statModel.textContent = short; }); document.querySelectorAll('.sug-card').forEach(btn => { btn.addEventListener('click', () => { const q = btn.dataset.query; if (q) send(q); }); }); function toggleSidebar(close) { state.sidebarOpen = close ? false : !state.sidebarOpen; if (window.innerWidth > 768) { // Desktop: toggle collapsed class D.sidebar?.classList.toggle('collapsed', !state.sidebarOpen); localStorage.setItem('sidebarCollapsed', !state.sidebarOpen); } else { // Mobile: toggle open class D.sidebar?.classList.toggle('open', state.sidebarOpen); D.backdrop?.classList.toggle('active', state.sidebarOpen); } } D.hamburger?.addEventListener('click', () => toggleSidebar()); D.backdrop?.addEventListener('click', () => toggleSidebar(true)); function resetChat() { state.sessionId = null; if (D.msgList) D.msgList.innerHTML = ''; if (D.welcome) D.welcome.style.display = ''; if (D.input) { D.input.value = ''; updateCounter(); autoH(); } typewriterGreeting(); // Retrigger the greeting typing effect if (state.sidebarOpen) toggleSidebar(true); } D.btnNew?.addEventListener('click', resetChat); D.btnClear?.addEventListener('click', async () => { if (state.sessionId) { try { await fetch('/api/clear', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ session_id: state.sessionId }) }); } catch { } } resetChat(); }); /* ══════════════════════════════════════════ 5. HEALTH CHECK ══════════════════════════════════════════ */ async function checkHealth() { try { const [hr, sr] = await Promise.all([fetch('/api/health'), fetch('/api/stats')]); const h = await hr.json(), s = await sr.json(); if (h.database_ready) { D.statusDot?.classList.add('online'); if (D.statusLabel) D.statusLabel.textContent = 'Online · Ready'; } if (s.total_chunks && D.statChunks) D.statChunks.textContent = s.total_chunks.toLocaleString(); if (s.llm_model && !D.modelSelect) { const short = s.llm_model.split('/').pop().replace(':free', ''); if (D.statModel) D.statModel.textContent = short; } else if (D.modelSelect) { const short = D.modelSelect.value.split('/').pop().replace(':free', ''); if (D.statModel) D.statModel.textContent = short; } } catch { if (D.statusLabel) D.statusLabel.textContent = 'Offline'; D.statusDot?.classList.remove('online'); } } checkHealth(); setInterval(checkHealth, 30000); /* ══════════════════════════════════════════ 7. INIT ══════════════════════════════════════════ */ function init() { initSplash(); // Initialize desktop sidebar state if (window.innerWidth > 768) { const isCollapsed = localStorage.getItem('sidebarCollapsed') === 'true'; state.sidebarOpen = !isCollapsed; if (isCollapsed) D.sidebar?.classList.add('collapsed'); } if (window.innerWidth > 768 && D.input) { D.input.focus(); } console.log('%c💊 MedBot v4 — Premium UI', 'color:#00E5A0;font-weight:700;font-size:14px'); } document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', init) : init(); })();