Files
sap-erp/dist-1/public/chatbot.js
T
John 69b4e68baf
SAP-ERP Portal CI/CD / build (push) Failing after 5m20s
first commit
2026-09-23 17:31:02 +05:30

435 lines
17 KiB
JavaScript

(function () {
'use strict';
// ── inject styles ─────────────────────────────────────────────────────────
const style = document.createElement('style');
style.textContent = `
#cb-fab{
position:fixed;bottom:24px;right:24px;z-index:9999;
width:52px;height:52px;border-radius:50%;border:none;cursor:pointer;
background:linear-gradient(135deg,#047e7e,#065f46);
box-shadow:0 4px 16px rgba(4,126,126,.45);
display:flex;align-items:center;justify-content:center;
transition:transform .2s,box-shadow .2s;
}
#cb-fab:hover{transform:scale(1.08);box-shadow:0 6px 20px rgba(4,126,126,.6)}
#cb-fab svg{width:26px;height:26px;fill:#fff}
#cb-fab .cb-badge{
position:absolute;top:-3px;right:-3px;
background:#ef4444;color:#fff;font-size:10px;font-weight:700;
border-radius:50%;width:18px;height:18px;
display:none;align-items:center;justify-content:center;
}
#cb-panel{
position:fixed;bottom:84px;right:24px;z-index:9998;
width:420px;height:540px;border-radius:16px;
background:#fff;box-shadow:0 8px 40px rgba(0,0,0,.18);
display:flex;flex-direction:column;overflow:hidden;
transform:scale(.85) translateY(20px);opacity:0;pointer-events:none;
transition:transform .22s cubic-bezier(.34,1.56,.64,1),opacity .18s;
}
#cb-panel.cb-open{transform:scale(1) translateY(0);opacity:1;pointer-events:all}
#cb-header{
background:linear-gradient(135deg,#047e7e,#065f46);
padding:14px 16px;display:flex;align-items:center;gap:10px;flex-shrink:0;
}
#cb-header .cb-avatar{
width:36px;height:36px;border-radius:50%;background:rgba(255,255,255,.2);
display:flex;align-items:center;justify-content:center;flex-shrink:0;
}
#cb-header .cb-avatar svg{width:20px;height:20px;fill:#fff}
#cb-header .cb-info{flex:1;min-width:0}
#cb-header .cb-name{font-size:14px;font-weight:700;color:#fff;letter-spacing:.2px}
#cb-header .cb-status{font-size:11px;color:rgba(255,255,255,.75);display:flex;align-items:center;gap:4px}
#cb-header .cb-dot{width:7px;height:7px;border-radius:50%;background:#4ade80}
#cb-close{
background:none;border:none;cursor:pointer;padding:4px;
color:rgba(255,255,255,.8);border-radius:6px;line-height:0;
transition:background .15s;
}
#cb-close:hover{background:rgba(255,255,255,.15);color:#fff}
#cb-close svg{width:18px;height:18px;fill:currentColor}
#cb-msgs{
flex:1;overflow-y:auto;padding:14px 14px 6px;
display:flex;flex-direction:column;gap:8px;
scroll-behavior:smooth;
}
#cb-msgs::-webkit-scrollbar{width:4px}
#cb-msgs::-webkit-scrollbar-thumb{background:#d1dae3;border-radius:4px}
.cb-msg{display:flex;gap:8px;align-items:flex-end;max-width:100%}
.cb-msg.cb-user{flex-direction:row-reverse}
.cb-bubble{
padding:8px 12px;border-radius:14px;font-size:13px;line-height:1.5;
max-width:82%;word-break:break-word;white-space:pre-wrap;
}
.cb-msg.cb-bot .cb-bubble{background:#f0f4f8;color:#1a1a2e;border-bottom-left-radius:4px}
.cb-msg.cb-user .cb-bubble{background:linear-gradient(135deg,#047e7e,#065f46);color:#fff;border-bottom-right-radius:4px}
.cb-msg-avatar{
width:28px;height:28px;border-radius:50%;flex-shrink:0;
display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:700;
}
.cb-msg.cb-bot .cb-msg-avatar{background:#e0f4f4;color:#047e7e}
.cb-msg.cb-user .cb-msg-avatar{background:#065f46;color:#fff}
.cb-typing{display:flex;gap:5px;padding:10px 14px;align-items:center}
.cb-typing span{
width:7px;height:7px;border-radius:50%;background:#9ca3af;
animation:cb-bounce .9s infinite;
}
.cb-typing span:nth-child(2){animation-delay:.15s}
.cb-typing span:nth-child(3){animation-delay:.3s}
@keyframes cb-bounce{0%,60%,100%{transform:translateY(0)}30%{transform:translateY(-6px)}}
#cb-footer{
padding:10px 12px;border-top:1px solid #f0f2f4;flex-shrink:0;
display:flex;gap:8px;align-items:flex-end;background:#fff;
}
#cb-input{
flex:1;border:1.5px solid #e8eaed;border-radius:12px;
padding:9px 12px;font-size:13px;font-family:inherit;color:#1a1a2e;
resize:none;max-height:100px;overflow-y:auto;outline:none;
transition:border-color .15s;line-height:1.4;
}
#cb-input:focus{border-color:#047e7e}
#cb-input::placeholder{color:#9ca3af}
#cb-send{
width:38px;height:38px;border-radius:10px;border:none;cursor:pointer;
background:linear-gradient(135deg,#047e7e,#065f46);
display:flex;align-items:center;justify-content:center;flex-shrink:0;
transition:opacity .15s,transform .1s;
}
#cb-send:hover{opacity:.88;transform:scale(1.05)}
#cb-send:disabled{opacity:.4;cursor:not-allowed;transform:none}
#cb-send svg{width:18px;height:18px;fill:#fff}
#cb-suggestions{
padding:0 12px 10px;display:flex;flex-wrap:wrap;gap:6px;flex-shrink:0;
}
.cb-chip{
padding:5px 10px;border-radius:20px;font-size:11.5px;cursor:pointer;
background:#f0f4f8;color:#047e7e;border:1px solid #d1e8e8;
transition:background .15s;white-space:nowrap;
}
.cb-chip:hover{background:#e0f4f4}
/* ── markdown rendering inside bot bubbles ── */
.cb-bubble.cb-md{white-space:normal}
.cb-bubble.cb-md p{margin:0 0 6px}
.cb-bubble.cb-md p:last-child{margin-bottom:0}
.cb-bubble.cb-md strong{font-weight:700}
.cb-bubble.cb-md em{font-style:italic}
.cb-bubble.cb-md code{background:rgba(0,0,0,.07);padding:1px 5px;border-radius:4px;font-family:monospace;font-size:11.5px}
.cb-bubble.cb-md h3{font-size:13px;font-weight:700;margin:8px 0 4px;color:#004d40}
.cb-bubble.cb-md h4{font-size:12px;font-weight:700;margin:6px 0 3px;color:#047e7e}
.cb-bubble.cb-md ul,.cb-bubble.cb-md ol{padding-left:18px;margin:4px 0}
.cb-bubble.cb-md li{margin:2px 0}
.cb-bubble.cb-md table{border-collapse:collapse;width:100%;margin:8px 0;font-size:11.5px}
.cb-bubble.cb-md th{background:#047e7e;color:#fff;padding:5px 9px;text-align:left;font-weight:700;white-space:nowrap}
.cb-bubble.cb-md td{padding:4px 9px;border-bottom:1px solid #e8eaed;white-space:nowrap}
.cb-bubble.cb-md tr:nth-child(even) td{background:rgba(4,126,126,.05)}
.cb-bubble.cb-md .cb-tbl-wrap{overflow-x:auto;border-radius:6px;border:1px solid #d1e8e8}
.cb-bubble.cb-md hr{border:none;border-top:1px solid #e8eaed;margin:8px 0}
`;
document.head.appendChild(style);
// ── inject HTML ───────────────────────────────────────────────────────────
const fab = document.createElement('button');
fab.id = 'cb-fab';
fab.title = 'AI Assistant';
fab.innerHTML = `
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>
<span class="cb-badge" id="cb-badge"></span>`;
const panel = document.createElement('div');
panel.id = 'cb-panel';
panel.innerHTML = `
<div id="cb-header">
<div class="cb-avatar">
<svg viewBox="0 0 24 24"><path d="M12 2a2 2 0 012 2 2 2 0 01-2 2 2 2 0 01-2-2 2 2 0 012-2m0 14c-4.42 0-8 1.79-8 4v2h16v-2c0-2.21-3.58-4-8-4m7-9h-2v2h-2V7H9v2H7V7H5v9h14V7h-2V5z"/></svg>
</div>
<div class="cb-info">
<div class="cb-name">Portal Assistant</div>
<div class="cb-status"><span class="cb-dot"></span>Gemini AI · Mitra Industries</div>
</div>
<button id="cb-close" title="Close">
<svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</div>
<div id="cb-msgs"></div>
<div id="cb-suggestions">
<span class="cb-chip">Show today's sales</span>
<span class="cb-chip">Blood Bag BOM prices</span>
<span class="cb-chip">Monthly revenue</span>
<span class="cb-chip">CAPD BOM costs</span>
<span class="cb-chip">How to create a BOM?</span>
<span class="cb-chip">Explain P&L Summary</span>
</div>
<div id="cb-footer">
<textarea id="cb-input" rows="1" placeholder="Ask anything about the portal…"></textarea>
<button id="cb-send" title="Send">
<svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>`;
document.body.appendChild(fab);
document.body.appendChild(panel);
// ── state ─────────────────────────────────────────────────────────────────
let isOpen = false;
let loading = false;
const history = []; // [{role:'user'|'assistant', content:'...'}]
let unread = 0;
const msgsEl = document.getElementById('cb-msgs');
const inputEl = document.getElementById('cb-input');
const sendBtn = document.getElementById('cb-send');
const badge = document.getElementById('cb-badge');
// ── helpers ───────────────────────────────────────────────────────────────
function getToken() {
return sessionStorage.getItem('portal_token') || '';
}
// ── markdown renderer ─────────────────────────────────────────────────────
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function inlineMd(text) {
return escHtml(text)
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code>$1</code>');
}
function renderMd(raw) {
const lines = raw.split('\n');
let html = '', i = 0;
while (i < lines.length) {
const line = lines[i];
const trim = line.trim();
// Blank line
if (!trim) { html += '<br>'; i++; continue; }
// Heading
if (trim.startsWith('### ')) { html += `<h4>${inlineMd(trim.slice(4))}</h4>`; i++; continue; }
if (trim.startsWith('## ')) { html += `<h3>${inlineMd(trim.slice(3))}</h3>`; i++; continue; }
if (trim.startsWith('# ')) { html += `<h3>${inlineMd(trim.slice(2))}</h3>`; i++; continue; }
// Horizontal rule
if (/^[-*_]{3,}$/.test(trim)) { html += '<hr>'; i++; continue; }
// Table: line starts with | and next non-empty line is a separator
if (trim.startsWith('|')) {
const nextTrim = (lines[i + 1] || '').trim();
if (nextTrim.startsWith('|') && /^[\|:\- ]+$/.test(nextTrim)) {
const parseRow = l => l.trim().replace(/^\||\|$/g,'').split('|').map(c => c.trim());
const headers = parseRow(lines[i]);
const aligns = parseRow(lines[i + 1]).map(c => c.startsWith(':') && c.endsWith(':') ? 'center' : c.endsWith(':') ? 'right' : 'left');
i += 2;
let tbl = '<div class="cb-tbl-wrap"><table><thead><tr>';
headers.forEach((h, ci) => { tbl += `<th style="text-align:${aligns[ci]||'left'}">${inlineMd(h)}</th>`; });
tbl += '</tr></thead><tbody>';
while (i < lines.length && lines[i].trim().startsWith('|')) {
const cells = parseRow(lines[i]);
tbl += '<tr>';
cells.forEach((c, ci) => { tbl += `<td style="text-align:${aligns[ci]||'left'}">${inlineMd(c)}</td>`; });
tbl += '</tr>';
i++;
}
tbl += '</tbody></table></div>';
html += tbl;
continue;
}
}
// Unordered list
if (/^[-*+]\s/.test(trim)) {
let ul = '<ul>';
while (i < lines.length && /^[-*+]\s/.test(lines[i].trim())) {
ul += `<li>${inlineMd(lines[i].trim().slice(2))}</li>`;
i++;
}
html += ul + '</ul>';
continue;
}
// Ordered list
if (/^\d+\.\s/.test(trim)) {
let ol = '<ol>';
while (i < lines.length && /^\d+\.\s/.test(lines[i].trim())) {
ol += `<li>${inlineMd(lines[i].trim().replace(/^\d+\.\s/,''))}</li>`;
i++;
}
html += ol + '</ol>';
continue;
}
// Plain paragraph
html += `<p>${inlineMd(line)}</p>`;
i++;
}
// Clean up excess <br> tags
return html.replace(/(<br>){3,}/g,'<br><br>').replace(/^<br>+|<br>+$/g,'');
}
function setUnread(n) {
unread = n;
if (n > 0 && !isOpen) {
badge.textContent = n > 9 ? '9+' : n;
badge.style.display = 'flex';
} else {
badge.style.display = 'none';
}
}
function appendMsg(role, text) {
// remove suggestions after first real message
const sug = document.getElementById('cb-suggestions');
if (sug) sug.remove();
const wrap = document.createElement('div');
wrap.className = `cb-msg cb-${role}`;
const av = document.createElement('div');
av.className = 'cb-msg-avatar';
av.textContent = role === 'bot' ? 'AI' : '👤';
const bub = document.createElement('div');
if (role === 'bot') {
bub.className = 'cb-bubble cb-md';
bub.innerHTML = renderMd(text);
} else {
bub.className = 'cb-bubble';
bub.textContent = text;
}
if (role === 'user') { wrap.appendChild(bub); wrap.appendChild(av); }
else { wrap.appendChild(av); wrap.appendChild(bub); }
msgsEl.appendChild(wrap);
msgsEl.scrollTop = msgsEl.scrollHeight;
return bub;
}
function showTyping() {
const wrap = document.createElement('div');
wrap.className = 'cb-msg cb-bot';
wrap.id = 'cb-typing-row';
wrap.innerHTML = `
<div class="cb-msg-avatar">AI</div>
<div class="cb-bubble cb-typing">
<span></span><span></span><span></span>
</div>`;
msgsEl.appendChild(wrap);
msgsEl.scrollTop = msgsEl.scrollHeight;
}
function removeTyping() {
const t = document.getElementById('cb-typing-row');
if (t) t.remove();
}
// ── welcome message ───────────────────────────────────────────────────────
function showWelcome() {
appendMsg('bot', 'Hi! I\'m your Portal Assistant. I can help you navigate the portal, understand P&L data, cost sheets, BOM workflows, and more.\n\nWhat would you like to know?');
}
// ── send message ──────────────────────────────────────────────────────────
async function sendMessage() {
const text = inputEl.value.trim();
if (!text || loading) return;
inputEl.value = '';
inputEl.style.height = 'auto';
loading = true;
sendBtn.disabled = true;
appendMsg('user', text);
history.push({ role: 'user', content: text });
showTyping();
try {
const token = getToken();
const res = await fetch('/api/chat/message', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ message: text, history: history.slice(0, -1) }),
});
const data = await res.json();
removeTyping();
const reply = data.success
? data.reply
: (data.message || 'Something went wrong. Please try again.');
appendMsg('bot', reply);
history.push({ role: 'assistant', content: reply });
if (!isOpen) setUnread(unread + 1);
} catch {
removeTyping();
appendMsg('bot', 'Network error. Please check your connection and try again.');
} finally {
loading = false;
sendBtn.disabled = false;
inputEl.focus();
}
}
// ── toggle panel ──────────────────────────────────────────────────────────
function openPanel() {
isOpen = true;
panel.classList.add('cb-open');
setUnread(0);
if (!msgsEl.children.length) showWelcome();
setTimeout(() => inputEl.focus(), 250);
}
function closePanel() {
isOpen = false;
panel.classList.remove('cb-open');
}
// ── events ────────────────────────────────────────────────────────────────
fab.addEventListener('click', () => isOpen ? closePanel() : openPanel());
document.getElementById('cb-close').addEventListener('click', closePanel);
// quick chips
panel.querySelectorAll('.cb-chip').forEach(chip => {
chip.addEventListener('click', () => {
inputEl.value = chip.textContent;
sendMessage();
});
});
// send on Enter (Shift+Enter = newline)
inputEl.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// auto-resize textarea
inputEl.addEventListener('input', () => {
inputEl.style.height = 'auto';
inputEl.style.height = Math.min(inputEl.scrollHeight, 100) + 'px';
});
sendBtn.addEventListener('click', sendMessage);
})();