3
This commit is contained in:
@@ -0,0 +1,380 @@
|
||||
{{-- 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>
|
||||
Reference in New Issue
Block a user