381 lines
12 KiB
PHP
381 lines
12 KiB
PHP
{{-- OnAPB Genius Agent — Chat Bubble --}}
|
|
<style>
|
|
#genius-btn {
|
|
position: fixed;
|
|
bottom: 1.5rem;
|
|
right: 1.5rem;
|
|
z-index: 1050;
|
|
width: 56px;
|
|
height: 56px;
|
|
border-radius: 50%;
|
|
background: linear-gradient(135deg, #0d6efd, #6610f2);
|
|
color: #fff;
|
|
border: none;
|
|
box-shadow: 0 4px 16px rgba(0,0,0,.25);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 1.4rem;
|
|
cursor: pointer;
|
|
transition: transform .15s;
|
|
}
|
|
#genius-btn:hover { transform: scale(1.08); }
|
|
|
|
#genius-panel {
|
|
position: fixed;
|
|
bottom: 5rem;
|
|
right: 1.5rem;
|
|
z-index: 1049;
|
|
width: 360px;
|
|
max-width: calc(100vw - 2rem);
|
|
background: #fff;
|
|
border-radius: 1rem;
|
|
box-shadow: 0 8px 32px rgba(0,0,0,.18);
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
transition: opacity .2s, transform .2s;
|
|
transform-origin: bottom right;
|
|
}
|
|
#genius-panel.genius-hidden {
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
transform: scale(.92);
|
|
}
|
|
|
|
/* Mobile: panel casi pantalla completa para que el teclado no tape el input. */
|
|
@media (max-width: 576px) {
|
|
#genius-panel {
|
|
left: .5rem;
|
|
right: .5rem;
|
|
bottom: .5rem;
|
|
top: auto;
|
|
width: auto;
|
|
max-width: none;
|
|
border-radius: .85rem;
|
|
max-height: calc(100dvh - 1rem);
|
|
}
|
|
#genius-messages { max-height: none; flex: 1 1 auto; }
|
|
}
|
|
|
|
#genius-header {
|
|
background: linear-gradient(135deg, #0d6efd, #6610f2);
|
|
color: #fff;
|
|
padding: .75rem 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .5rem;
|
|
}
|
|
#genius-header .genius-title { font-weight: 600; font-size: .95rem; flex: 1; }
|
|
#genius-header button { background: none; border: none; color: #fff; font-size: 1rem; line-height: 1; padding: 0; cursor: pointer; opacity: .8; }
|
|
#genius-header button:hover { opacity: 1; }
|
|
|
|
#genius-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: .75rem 1rem;
|
|
max-height: 340px;
|
|
min-height: 120px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: .5rem;
|
|
}
|
|
|
|
.genius-msg {
|
|
padding: .5rem .75rem;
|
|
border-radius: .75rem;
|
|
font-size: .875rem;
|
|
line-height: 1.45;
|
|
max-width: 88%;
|
|
word-break: break-word;
|
|
}
|
|
.genius-msg.user {
|
|
align-self: flex-end;
|
|
background: #0d6efd;
|
|
color: #fff;
|
|
border-bottom-right-radius: .2rem;
|
|
}
|
|
.genius-msg.assistant {
|
|
align-self: flex-start;
|
|
background: #f1f3f5;
|
|
color: #212529;
|
|
border-bottom-left-radius: .2rem;
|
|
}
|
|
.genius-msg.error { background: #fff3cd; color: #856404; }
|
|
.genius-msg strong { font-weight: 600; }
|
|
.genius-msg em { font-style: italic; }
|
|
.genius-msg code {
|
|
background: rgba(0,0,0,.08);
|
|
padding: .1rem .3rem;
|
|
border-radius: .25rem;
|
|
font-size: .85em;
|
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
|
}
|
|
.genius-msg.user code { background: rgba(255,255,255,.22); }
|
|
|
|
#genius-typing {
|
|
align-self: flex-start;
|
|
padding: .4rem .75rem;
|
|
background: #f1f3f5;
|
|
border-radius: .75rem;
|
|
font-size: .8rem;
|
|
color: #6c757d;
|
|
display: none;
|
|
}
|
|
|
|
#genius-form {
|
|
border-top: 1px solid #dee2e6;
|
|
padding: .6rem .75rem;
|
|
display: flex;
|
|
gap: .4rem;
|
|
background: #fff;
|
|
}
|
|
#genius-input {
|
|
flex: 1;
|
|
border: 1px solid #dee2e6;
|
|
border-radius: .5rem;
|
|
padding: .4rem .6rem;
|
|
font-size: .875rem;
|
|
outline: none;
|
|
resize: none;
|
|
max-height: 80px;
|
|
overflow-y: auto;
|
|
}
|
|
#genius-input:focus { border-color: #0d6efd; }
|
|
#genius-send {
|
|
background: #0d6efd;
|
|
color: #fff;
|
|
border: none;
|
|
border-radius: .5rem;
|
|
padding: .4rem .7rem;
|
|
font-size: 1rem;
|
|
cursor: pointer;
|
|
transition: background .15s;
|
|
align-self: flex-end;
|
|
}
|
|
#genius-send:hover { background: #0b5ed7; }
|
|
#genius-send:disabled { background: #adb5bd; cursor: not-allowed; }
|
|
</style>
|
|
|
|
<button id="genius-btn" title="OnAPB Genius" aria-label="Abrir asistente">
|
|
<i class="bi bi-stars"></i>
|
|
</button>
|
|
|
|
<div id="genius-panel" class="genius-hidden">
|
|
<div id="genius-header">
|
|
<i class="bi bi-stars"></i>
|
|
<span class="genius-title">OnAPB Genius</span>
|
|
<button id="genius-close" aria-label="Cerrar"><i class="bi bi-x-lg"></i></button>
|
|
</div>
|
|
<div id="genius-messages">
|
|
<div class="genius-msg assistant">
|
|
¡Hola! Soy OnAPB Genius. ¿En qué puedo ayudarte hoy?
|
|
</div>
|
|
<div id="genius-typing">Escribiendo<span id="genius-dots">...</span></div>
|
|
</div>
|
|
<form id="genius-form">
|
|
@csrf
|
|
<textarea id="genius-input" placeholder="Escribí tu consulta..." rows="1" maxlength="1000"></textarea>
|
|
<button type="submit" id="genius-send"><i class="bi bi-send-fill"></i></button>
|
|
</form>
|
|
</div>
|
|
|
|
<script>
|
|
(function () {
|
|
const btn = document.getElementById('genius-btn');
|
|
const panel = document.getElementById('genius-panel');
|
|
const closeBtn = document.getElementById('genius-close');
|
|
const form = document.getElementById('genius-form');
|
|
const input = document.getElementById('genius-input');
|
|
const sendBtn = document.getElementById('genius-send');
|
|
const msgs = document.getElementById('genius-messages');
|
|
const typing = document.getElementById('genius-typing');
|
|
|
|
const STORAGE_KEY = 'onapb_genius_chat_v1';
|
|
|
|
let threadId = null;
|
|
let isOpen = false;
|
|
|
|
function loadState() {
|
|
try {
|
|
const raw = sessionStorage.getItem(STORAGE_KEY);
|
|
return raw ? JSON.parse(raw) : null;
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function saveState() {
|
|
try {
|
|
const messages = Array.from(msgs.querySelectorAll('.genius-msg')).map(el => ({
|
|
role: el.classList.contains('user') ? 'user'
|
|
: el.classList.contains('error') ? 'error'
|
|
: 'assistant',
|
|
text: el.dataset.text ?? el.textContent
|
|
}));
|
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
|
|
messages, threadId, isOpen
|
|
}));
|
|
} catch (e) {}
|
|
}
|
|
|
|
function escapeHtml(s) {
|
|
return s.replace(/[&<>"']/g, c => ({
|
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
|
}[c]));
|
|
}
|
|
|
|
function renderMarkdown(text) {
|
|
let s = escapeHtml(text);
|
|
|
|
const codes = [];
|
|
s = s.replace(/`([^`\n]+?)`/g, (_, code) => {
|
|
codes.push(code);
|
|
return `C${codes.length - 1}`;
|
|
});
|
|
|
|
s = s.replace(/\*\*\*([^*\n]+?)\*\*\*/g, '<strong><em>$1</em></strong>');
|
|
s = s.replace(/\*\*([^*\n]+?)\*\*/g, '<strong>$1</strong>');
|
|
s = s.replace(/(^|[^*\w])\*([^*\n]+?)\*(?!\*)/g, '$1<em>$2</em>');
|
|
s = s.replace(/(^|[^_\w])_([^_\n]+?)_(?!\w)/g, '$1<em>$2</em>');
|
|
|
|
s = s.replace(/C(\d+)/g, (_, i) => `<code>${codes[i]}</code>`);
|
|
s = s.replace(/\n/g, '<br>');
|
|
|
|
return s;
|
|
}
|
|
|
|
function renderMessage(role, text) {
|
|
const div = document.createElement('div');
|
|
div.className = 'genius-msg ' + role;
|
|
div.dataset.text = text;
|
|
if (role === 'assistant') {
|
|
div.innerHTML = renderMarkdown(text);
|
|
} else {
|
|
div.textContent = text;
|
|
}
|
|
msgs.insertBefore(div, typing);
|
|
}
|
|
|
|
function rehydrate() {
|
|
const saved = loadState();
|
|
if (!saved) return;
|
|
|
|
if (Array.isArray(saved.messages) && saved.messages.length > 0) {
|
|
msgs.querySelectorAll('.genius-msg').forEach(el => el.remove());
|
|
saved.messages.forEach(m => renderMessage(m.role, m.text));
|
|
msgs.scrollTop = msgs.scrollHeight;
|
|
}
|
|
if (saved.threadId) threadId = saved.threadId;
|
|
if (saved.isOpen) {
|
|
isOpen = true;
|
|
panel.classList.remove('genius-hidden');
|
|
}
|
|
}
|
|
|
|
function togglePanel() {
|
|
isOpen = !isOpen;
|
|
panel.classList.toggle('genius-hidden', !isOpen);
|
|
if (isOpen) input.focus();
|
|
adjustForKeyboard();
|
|
saveState();
|
|
}
|
|
|
|
// En mobile el teclado virtual reduce el visualViewport pero el layout viewport
|
|
// queda igual, así que un fixed con bottom queda tapado. Compensamos manualmente.
|
|
function adjustForKeyboard() {
|
|
if (!window.visualViewport || window.innerWidth > 576) {
|
|
panel.style.bottom = '';
|
|
return;
|
|
}
|
|
if (!isOpen) { panel.style.bottom = ''; return; }
|
|
const vv = window.visualViewport;
|
|
const keyboardHeight = window.innerHeight - vv.height - vv.offsetTop;
|
|
panel.style.bottom = (keyboardHeight > 80 ? keyboardHeight + 8 : 8) + 'px';
|
|
}
|
|
|
|
if (window.visualViewport) {
|
|
window.visualViewport.addEventListener('resize', adjustForKeyboard);
|
|
window.visualViewport.addEventListener('scroll', adjustForKeyboard);
|
|
}
|
|
window.addEventListener('resize', adjustForKeyboard);
|
|
|
|
btn.addEventListener('click', togglePanel);
|
|
closeBtn.addEventListener('click', togglePanel);
|
|
|
|
// Auto-resize textarea
|
|
input.addEventListener('input', function () {
|
|
this.style.height = 'auto';
|
|
this.style.height = Math.min(this.scrollHeight, 80) + 'px';
|
|
});
|
|
|
|
// Submit on Enter (Shift+Enter = nueva línea)
|
|
input.addEventListener('keydown', function (e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
form.dispatchEvent(new Event('submit'));
|
|
}
|
|
});
|
|
|
|
function appendMsg(role, text) {
|
|
typing.style.display = 'none';
|
|
renderMessage(role, text);
|
|
msgs.scrollTop = msgs.scrollHeight;
|
|
saveState();
|
|
}
|
|
|
|
function setLoading(loading) {
|
|
sendBtn.disabled = loading;
|
|
input.disabled = loading;
|
|
typing.style.display = loading ? 'block' : 'none';
|
|
if (loading) msgs.scrollTop = msgs.scrollHeight;
|
|
}
|
|
|
|
form.addEventListener('submit', async function (e) {
|
|
e.preventDefault();
|
|
|
|
const message = input.value.trim();
|
|
if (!message) return;
|
|
|
|
appendMsg('user', message);
|
|
input.value = '';
|
|
input.style.height = 'auto';
|
|
setLoading(true);
|
|
|
|
const csrfToken = document.querySelector('input[name="_token"]')?.value
|
|
|| document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
|
|
const body = { message };
|
|
if (threadId) body.thread_id = threadId;
|
|
|
|
try {
|
|
const res = await fetch('{{ route("agent.chat") }}', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
'Accept': 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
const data = await res.json();
|
|
|
|
if (!res.ok) {
|
|
appendMsg('error', data.message || 'Ocurrió un error. Intentá de nuevo.');
|
|
} else {
|
|
if (data.thread_id) threadId = data.thread_id;
|
|
appendMsg('assistant', data.reply);
|
|
}
|
|
} catch (err) {
|
|
appendMsg('error', 'No se pudo conectar con el agente. Verificá tu conexión.');
|
|
} finally {
|
|
setLoading(false);
|
|
input.focus();
|
|
}
|
|
});
|
|
|
|
rehydrate();
|
|
})();
|
|
</script>
|