// ============================================================ // Blu — Cloud & AI Advisor chat client // ============================================================ // API base URL: set via config.js (created by deploy.sh) // For local dev: create frontend/config.js with: // window.API_BASE_URL = 'https://YOUR_API_ID.execute-api.us-east-1.amazonaws.com'; const API_BASE = (window.API_BASE_URL || '').replace(/\/$/, ''); // Blu's hardcoded opening message — displayed immediately without an API call const OPENING_MESSAGE = "Hi! I\u2019m Blu \u2014 a proof-of-concept AI built to explore what a student advisor " + "for a Cloud & AI Management program at Bellevue University could look like. " + "The program itself is still a concept, and I\u2019m not affiliated with BU \u2014 " + "but I\u2019m genuinely curious about your career and whether something like this " + "would be a fit for you. Let\u2019s find out.\n\n" + "First question: What\u2019s your name?"; // ── State ─────────────────────────────────────────────────── // messages is sent to the API. The opening message is stored as an assistant // turn so Blu has context; the Lambda prepends a synthetic user turn if needed // to satisfy Bedrock's requirement that conversations start with "user". let messages = [ { role: 'assistant', content: OPENING_MESSAGE } ]; let isThinking = false; let collectLeadShown = false; // prevent showing consent form more than once // ── DOM refs ───────────────────────────────────────────────── const messageList = document.getElementById('message-list'); const thinkingIndicator = document.getElementById('thinking-indicator'); const chatForm = document.getElementById('chat-form'); const chatInput = document.getElementById('chat-input'); const consentFormWrapper = document.getElementById('consent-form-wrapper'); const leadForm = document.getElementById('lead-form'); const declineBtn = document.getElementById('decline-btn'); const leadNameInput = document.getElementById('lead-name'); const leadEmailInput = document.getElementById('lead-email'); // ── Init ───────────────────────────────────────────────────── renderMessage('assistant', OPENING_MESSAGE); chatInput.focus(); // ── Chat form submit ───────────────────────────────────────── chatForm.addEventListener('submit', async (e) => { e.preventDefault(); const text = chatInput.value.trim(); if (!text || isThinking) return; chatInput.value = ''; await sendUserMessage(text); }); async function sendUserMessage(text) { messages.push({ role: 'user', content: text }); renderMessage('user', text); await getBluResponse(); } async function getBluResponse() { setThinking(true); try { if (!API_BASE) { throw new Error('API_BASE_URL not configured. Create frontend/config.js with your API Gateway URL.'); } const res = await fetch(`${API_BASE}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ messages }), }); if (!res.ok) { const errText = await res.text().catch(() => ''); throw new Error(`HTTP ${res.status}: ${errText}`); } const data = await res.json(); if (!data.message) throw new Error('Unexpected response format from API'); messages.push({ role: 'assistant', content: data.message }); renderMessage('assistant', data.message); if (data.collectLead && !collectLeadShown) { collectLeadShown = true; showConsentForm(); } } catch (err) { console.error('Chat error:', err); const isConfig = err.message.includes('API_BASE_URL'); renderMessage( 'assistant', isConfig ? 'Looks like I\u2019m not connected to a backend yet. See the README for local dev setup.' : 'I\u2019m sorry \u2014 I ran into an issue. Please try again in a moment.' ); } finally { setThinking(false); } } // ── Consent form ───────────────────────────────────────────── function showConsentForm() { consentFormWrapper.hidden = false; // Pre-fill name from first user message (typically just their name) const guessedName = extractNameFromConversation(); if (guessedName) leadNameInput.value = guessedName; setTimeout(() => { consentFormWrapper.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); if (!guessedName) { leadNameInput.focus(); } else { leadEmailInput.focus(); } }, 100); } function extractNameFromConversation() { // First user message after opening is usually just their name const userMessages = messages.filter(m => m.role === 'user'); if (userMessages.length > 0) { const first = userMessages[0].content.trim(); const words = first.split(/\s+/); // Accept if short enough to plausibly be a name if (words.length <= 3 && first.length <= 40 && !first.includes('?')) { return first; } } return ''; } // Lead form submission leadForm.addEventListener('submit', async (e) => { e.preventDefault(); const name = leadNameInput.value.trim(); const email = leadEmailInput.value.trim(); if (!name || !email) return; const submitBtn = leadForm.querySelector('.btn-primary'); submitBtn.disabled = true; submitBtn.textContent = 'Saving\u2026'; try { const res = await fetch(`${API_BASE}/leads`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, email, messages }), }); if (!res.ok) throw new Error(`HTTP ${res.status}`); consentFormWrapper.hidden = true; // Add consent as a user turn and get Blu's acknowledgment const consentText = `Yes \u2014 my name is ${name} and my email is ${email}.`; messages.push({ role: 'user', content: consentText }); renderMessage('user', `Yes, please save my info. (${email})`); await getBluResponse(); } catch (err) { console.error('Lead error:', err); submitBtn.disabled = false; submitBtn.textContent = 'Share my info'; renderMessage( 'assistant', 'Something went wrong saving your info \u2014 sorry about that. You can try again or reach out to Georg directly.' ); } }); // Decline consent declineBtn.addEventListener('click', () => { consentFormWrapper.hidden = true; sendUserMessage('No thank you \u2014 I\u2019d prefer not to share my details.'); }); // ── Rendering helpers ──────────────────────────────────────── function renderMessage(role, content) { const wrap = document.createElement('div'); wrap.className = `message ${role}`; if (role === 'assistant') { const avatar = document.createElement('span'); avatar.className = 'bear-avatar'; avatar.setAttribute('aria-hidden', 'true'); avatar.textContent = '\uD83D\uDC3B'; // 🐻 const bubble = document.createElement('div'); bubble.className = 'message-bubble'; bubble.innerHTML = escapeHtml(content).replace(/\n/g, '
'); wrap.appendChild(avatar); wrap.appendChild(bubble); } else { const bubble = document.createElement('div'); bubble.className = 'message-bubble'; bubble.textContent = content; wrap.appendChild(bubble); } messageList.appendChild(wrap); scrollToBottom(); } function escapeHtml(str) { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function setThinking(active) { isThinking = active; thinkingIndicator.hidden = !active; chatInput.disabled = active; if (!active) { chatInput.focus(); scrollToBottom(); } } function scrollToBottom() { const container = document.querySelector('.chat-container'); requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; }); }