/** * AI Help Widget * Floating chat assistant for Powder Coating Logix. * * Persistence: panel open/closed state, conversation history, rendered messages, * and panel height are all stored in sessionStorage so they survive page navigation. * Everything resets when the browser tab/session ends. */ (function () { 'use strict'; const CHAT_ENDPOINT = '/AiHelp/Chat'; const STORAGE_KEY = 'aiHelpState'; const MIN_HEIGHT = 300; const MAX_HEIGHT_VH = 0.85; // 85vh // Conversation history for API: [{role:'user'|'assistant', content:'...'}] let history = []; // Rendered message store for restoring across navigation: [{role, content, html}] let renderedMessages = []; let isOpen = false; let isSending = false; let startersShown = true; let panelHeight = 520; // DOM refs let btn, panel, messagesEl, inputEl, sendBtn, clearBtn, closeBtn, typingEl, startersEl, tokenEl, resizeHandle; // ── Init ───────────────────────────────────────────────────────────────── function init() { btn = document.getElementById('ai-help-btn'); panel = document.getElementById('ai-help-panel'); messagesEl = document.getElementById('ai-help-messages'); inputEl = document.getElementById('ai-help-input'); sendBtn = document.getElementById('ai-help-send'); clearBtn = document.getElementById('ai-help-clear'); closeBtn = document.getElementById('ai-help-close'); typingEl = document.getElementById('ai-help-typing'); startersEl = document.getElementById('ai-help-starters'); tokenEl = document.getElementById('ai-help-token'); resizeHandle = document.getElementById('ai-help-resize'); if (!btn || !panel) return; btn.addEventListener('click', togglePanel); closeBtn.addEventListener('click', closePanel); clearBtn.addEventListener('click', clearChat); sendBtn.addEventListener('click', sendMessage); inputEl.addEventListener('keydown', function (e) { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }); document.querySelectorAll('.ai-help-starter-btn').forEach(function (b) { b.addEventListener('click', function () { inputEl.value = b.dataset.q; sendMessage(); }); }); // Intercept help links inside the widget — navigate without closing messagesEl.addEventListener('click', function (e) { const link = e.target.closest('[data-help-link]'); if (link) { e.preventDefault(); const href = link.getAttribute('href'); if (href) window.location.href = href; } }); // Save accurate open state right before any navigation so the new page // can restore correctly. This runs after any click handlers that might // have changed isOpen, giving us the true final state. window.addEventListener('beforeunload', saveState); initResize(); restoreState(); } // ── Resize ─────────────────────────────────────────────────────────────── function initResize() { if (!resizeHandle) return; let startY = 0; let startH = 0; let dragging = false; resizeHandle.addEventListener('mousedown', function (e) { e.preventDefault(); dragging = true; startY = e.clientY; startH = panel.offsetHeight; resizeHandle.classList.add('dragging'); document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', function (e) { if (!dragging) return; // Panel grows upward: dragging mouse UP increases height const delta = startY - e.clientY; const maxH = Math.floor(window.innerHeight * MAX_HEIGHT_VH); panelHeight = Math.min(maxH, Math.max(MIN_HEIGHT, startH + delta)); panel.style.height = panelHeight + 'px'; }); document.addEventListener('mouseup', function () { if (!dragging) return; dragging = false; resizeHandle.classList.remove('dragging'); document.body.style.userSelect = ''; saveState(); }); // Touch support resizeHandle.addEventListener('touchstart', function (e) { const touch = e.touches[0]; startY = touch.clientY; startH = panel.offsetHeight; dragging = true; }, { passive: true }); document.addEventListener('touchmove', function (e) { if (!dragging) return; const touch = e.touches[0]; const delta = startY - touch.clientY; const maxH = Math.floor(window.innerHeight * MAX_HEIGHT_VH); panelHeight = Math.min(maxH, Math.max(MIN_HEIGHT, startH + delta)); panel.style.height = panelHeight + 'px'; }, { passive: true }); document.addEventListener('touchend', function () { if (!dragging) return; dragging = false; saveState(); }); } // ── Panel open/close ───────────────────────────────────────────────────── function togglePanel() { if (isOpen) { closePanel(); } else { openPanel(); } } function openPanel() { panel.hidden = false; panel.style.height = panelHeight + 'px'; isOpen = true; btn.setAttribute('aria-expanded', 'true'); inputEl.focus(); scrollToBottom(); saveState(); } function closePanel() { panel.hidden = true; isOpen = false; btn.setAttribute('aria-expanded', 'false'); saveState(); } // ── Clear chat ─────────────────────────────────────────────────────────── function clearChat() { history = []; renderedMessages = []; startersShown = true; // Remove all messages except the static welcome message (first child) const msgs = Array.from(messagesEl.children); msgs.slice(1).forEach(function (el) { el.remove(); }); startersEl.hidden = false; inputEl.value = ''; inputEl.focus(); saveState(); } // ── Send message ───────────────────────────────────────────────────────── async function sendMessage() { if (isSending) return; const text = inputEl.value.trim(); if (!text) return; startersEl.hidden = true; startersShown = false; inputEl.value = ''; appendMessage('user', text, escapeHtml(text)); scrollToBottom(); saveState(); isSending = true; sendBtn.disabled = true; typingEl.classList.remove('d-none'); scrollToBottom(); try { const token = tokenEl ? tokenEl.value : ''; // Send history BEFORE appending the current user message // (appendMessage already pushed it, so slice off the last entry) const payload = { message: text, history: history.slice(0, -1) }; const res = await fetch(CHAT_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', 'RequestVerificationToken': token }, body: JSON.stringify(payload) }); typingEl.classList.add('d-none'); if (!res.ok) { const err = await res.json().catch(() => ({})); const msg = err.error || 'Something went wrong. Please try again.'; appendMessage('assistant', msg, renderMarkdown(msg)); } else { const data = await res.json(); const reply = data.response || 'No response received.'; appendMessage('assistant', reply, renderMarkdown(reply)); } } catch (err) { typingEl.classList.add('d-none'); const msg = 'Unable to connect. Please check your connection and try again.'; appendMessage('assistant', msg, renderMarkdown(msg)); } finally { isSending = false; sendBtn.disabled = false; scrollToBottom(); saveState(); inputEl.focus(); } } // ── Append a message bubble ─────────────────────────────────────────────── function appendMessage(role, content, html) { history.push({ role: role, content: content }); renderedMessages.push({ role: role, content: content, html: html }); const wrapper = document.createElement('div'); wrapper.className = 'ai-help-msg ai-help-msg--' + role; const bubble = document.createElement('div'); bubble.className = 'ai-help-msg-bubble'; bubble.innerHTML = html; wrapper.appendChild(bubble); messagesEl.appendChild(wrapper); } // ── Restore bubble from stored HTML (safe — came from our own renderer) ── function restoreMessage(role, html) { const wrapper = document.createElement('div'); wrapper.className = 'ai-help-msg ai-help-msg--' + role; const bubble = document.createElement('div'); bubble.className = 'ai-help-msg-bubble'; bubble.innerHTML = html; wrapper.appendChild(bubble); messagesEl.appendChild(wrapper); } // ── sessionStorage persistence ──────────────────────────────────────────── function saveState() { try { sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ open: isOpen, history: history, renderedMessages: renderedMessages, startersShown: startersShown, panelHeight: panelHeight })); } catch (_) { /* sessionStorage full or unavailable — ignore */ } } function restoreState() { let saved; try { const raw = sessionStorage.getItem(STORAGE_KEY); if (!raw) return; saved = JSON.parse(raw); } catch (_) { return; } if (!saved) return; // Restore height first (before showing panel to avoid flicker) if (saved.panelHeight && saved.panelHeight >= MIN_HEIGHT) { panelHeight = saved.panelHeight; panel.style.height = panelHeight + 'px'; } // Restore messages (skip if only the welcome message was ever shown) if (saved.renderedMessages && saved.renderedMessages.length > 0) { history = saved.history || []; renderedMessages = saved.renderedMessages || []; saved.renderedMessages.forEach(function (m) { restoreMessage(m.role, m.html); }); if (!saved.startersShown) { startersEl.hidden = true; startersShown = false; } } // Restore open state last if (saved.open) { openPanel(); } } // ── Minimal markdown renderer ───────────────────────────────────────────── function renderMarkdown(text) { let html = escapeHtml(text); // Fenced code blocks html = html.replace(/```[\s\S]*?```/g, function (match) { const code = match.slice(3, -3).replace(/^\n/, ''); return '
' + code + '
'; }); // Inline code html = html.replace(/`([^`]+)`/g, '$1'); // Bold html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); // Italic html = html.replace(/\*([^*]+)\*/g, '$1'); // Markdown links — only /relative or https:// allowed html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, linkText, url) { const safe = url.startsWith('/') || url.startsWith('https://') || url.startsWith('http://'); if (!safe) return escapeHtml(linkText); return '' + linkText + ''; }); // Build line-by-line for lists const lines = html.split('\n'); const result = []; let inUl = false, inOl = false; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const ulMatch = line.match(/^(\s*)[-*]\s+(.+)/); const olMatch = line.match(/^(\s*)\d+\.\s+(.+)/); if (ulMatch) { if (!inUl) { if (inOl) { result.push(''); inOl = false; } result.push(''); inUl = false; } result.push('
    '); inOl = true; } result.push('
  1. ' + olMatch[2] + '
  2. '); } else { if (inUl) { result.push(''); inUl = false; } if (inOl) { result.push('
'); inOl = false; } result.push(line.trim() === '' ? '
' : '

' + line + '

'); } } if (inUl) result.push(''); if (inOl) result.push(''); return result.join('\n') .replace(/(
\s*){2,}/g, '
') .replace(/

\s*<\/p>/g, ''); } function escapeHtml(str) { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function escapeAttr(str) { return str.replace(/"/g, '"').replace(/'/g, '''); } function scrollToBottom() { requestAnimationFrame(function () { messagesEl.scrollTop = messagesEl.scrollHeight; }); } // ── Bootstrap ───────────────────────────────────────────────────────────── if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();