435 lines
17 KiB
JavaScript
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
}
|
|
|
|
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);
|
|
|
|
})();
|