Files
PowderCoatingLogix/publish-output/wwwroot/js/ai-help-widget.js
T
2026-04-23 21:38:24 -04:00

413 lines
15 KiB
JavaScript

/**
* 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 '<pre style="font-size:0.8rem;overflow-x:auto;"><code>' + code + '</code></pre>';
});
// Inline code
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// 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 '<a href="' + escapeAttr(url) + '" data-help-link="true">' + linkText + '</a>';
});
// 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('</ol>'); inOl = false; } result.push('<ul>'); inUl = true; }
result.push('<li>' + ulMatch[2] + '</li>');
} else if (olMatch) {
if (!inOl) { if (inUl) { result.push('</ul>'); inUl = false; } result.push('<ol>'); inOl = true; }
result.push('<li>' + olMatch[2] + '</li>');
} else {
if (inUl) { result.push('</ul>'); inUl = false; }
if (inOl) { result.push('</ol>'); inOl = false; }
result.push(line.trim() === '' ? '<br>' : '<p>' + line + '</p>');
}
}
if (inUl) result.push('</ul>');
if (inOl) result.push('</ol>');
return result.join('\n')
.replace(/(<br>\s*){2,}/g, '<br>')
.replace(/<p>\s*<\/p>/g, '');
}
function escapeHtml(str) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeAttr(str) {
return str.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function scrollToBottom() {
requestAnimationFrame(function () {
messagesEl.scrollTop = messagesEl.scrollHeight;
});
}
// ── Bootstrap ─────────────────────────────────────────────────────────────
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();