413 lines
15 KiB
JavaScript
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, '&')
|
|
.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();
|
|
}
|
|
})();
|