Files
cv-site/templates/partials/widgets/chat-widget.html
T
2026-05-04 15:20:04 +01:00

417 lines
18 KiB
HTML

{{define "chat-widget"}}
{{if .ChatEnabled}}
<!-- AI Chat Widget — CV Assistant Mascot -->
<button
id="chat-toggle-btn"
class="chat-toggle-btn no-print has-tooltip tooltip-left"
aria-label="{{if eq .Lang "es"}}Asistente del CV{{else}}CV Assistant{{end}}"
data-tooltip="{{if eq .Lang "es"}}Asistente del CV{{else}}CV Assistant{{end}}"
onclick="toggleChatPanel()">
<iconify-icon icon="mdi:robot-happy-outline" class="chat-icon-open"></iconify-icon>
<iconify-icon icon="mdi:close" class="chat-icon-close"></iconify-icon>
</button>
<span id="chat-wave" class="chat-wave no-print">👋</span>
<div id="chat-panel" class="chat-panel no-print">
<div class="chat-header">
<iconify-icon icon="mdi:robot-happy-outline"></iconify-icon>
<span>{{if eq .Lang "es"}}Asistente del CV{{else}}CV Assistant{{end}}</span>
<div class="chat-header-actions">
<!-- Cog menu for layout modes -->
<div class="chat-cog-wrapper">
<button class="chat-mode-btn" title="{{if eq .Lang "es"}}Opciones{{else}}Options{{end}}" onclick="toggleChatCog()">
<iconify-icon icon="mdi:cog"></iconify-icon>
</button>
<div id="chat-cog-menu" class="chat-cog-menu">
<button class="chat-cog-item active" data-mode="" onclick="setChatSize(''); closeChatCog()">
<iconify-icon icon="mdi:message-outline"></iconify-icon>
{{if eq .Lang "es"}}Compacto{{else}}Compact{{end}}
</button>
<button class="chat-cog-item" data-mode="chat-split" onclick="setChatSize('chat-split'); closeChatCog()">
<iconify-icon icon="mdi:arrow-split-horizontal"></iconify-icon>
{{if eq .Lang "es"}}Mitad{{else}}Half screen{{end}}
</button>
<button class="chat-cog-item" data-mode="chat-half" onclick="setChatSize('chat-half'); closeChatCog()">
<iconify-icon icon="mdi:page-layout-sidebar-right"></iconify-icon>
{{if eq .Lang "es"}}Lateral{{else}}Side panel{{end}}
</button>
<button class="chat-cog-item" data-mode="chat-float" onclick="setChatSize('chat-float'); closeChatCog()">
<iconify-icon icon="mdi:cursor-move"></iconify-icon>
{{if eq .Lang "es"}}Flotante{{else}}Floating{{end}}
</button>
<button class="chat-cog-item" data-mode="chat-full" onclick="setChatSize('chat-full'); closeChatCog()">
<iconify-icon icon="mdi:arrow-expand-all"></iconify-icon>
{{if eq .Lang "es"}}Completo{{else}}Full screen{{end}}
</button>
</div>
</div>
<button class="chat-mode-btn"
title="{{if eq .Lang "es"}}Ayuda{{else}}Help{{end}}"
commandfor="chat-help-modal"
command="show-modal">
<iconify-icon icon="mdi:help-circle-outline"></iconify-icon>
</button>
<button class="chat-mode-btn" title="{{if eq .Lang "es"}}Cerrar{{else}}Close{{end}}" onclick="toggleChatPanel()">
<iconify-icon icon="mdi:close"></iconify-icon>
</button>
</div>
</div>
<div id="chat-messages" class="chat-messages">
<div class="chat-row chat-row-bot">
<div class="chat-avatar chat-avatar-juan"><img src="/static/images/profile/dni-thumb.jpeg" alt="Juan"></div>
<div class="chat-msg">{{if eq .Lang "es"}}¡Hola! Pregúntame lo que quieras sobre <span class="chat-disclaimer" data-tip="Soy el yo digital de Juan. Y sólo sé responder preguntas profesionales sobre mi currículum. 🤷🏽‍♂️" onmouseenter="showChatTip(this)" onmouseleave="hideChatTip()">mi currículum</span>.{{else}}Hi! Ask me anything about <span class="chat-disclaimer" data-tip="I'm Juan's digital self. And I only know how to answer professional questions about my CV. 🤷🏽‍♂️" onmouseenter="showChatTip(this)" onmouseleave="hideChatTip()">my CV</span>.{{end}}</div>
</div>
</div>
<!-- Typing / Status Indicator -->
<div id="chat-typing" class="chat-typing">
<span id="chat-status-text" class="chat-status-text" style="display:none"></span>
<span class="chat-typing-dots">
<span class="chat-typing-dot"></span>
<span class="chat-typing-dot"></span>
<span class="chat-typing-dot"></span>
</span>
</div>
<!-- Suggested Questions -->
<div class="chat-suggestions">
{{if eq .Lang "es"}}
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Qué proyectos en Go has hecho?')">¿Proyectos en Go?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Cuántos años de experiencia tienes?')">¿Años de experiencia?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿En qué empresas has trabajado?')">¿Empresas?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Conoces React?')">¿Conoces React?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('¿Qué proyectos open-source mantienes?')">¿Open source?</button>
{{else}}
<button type="button" class="chat-chip" onclick="sendChatQuestion('What Go projects have you built?')">Go projects?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('How many years of experience do you have?')">Years of experience?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('What companies have you worked at?')">Companies?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('Do you know React?')">Know React?</button>
<button type="button" class="chat-chip" onclick="sendChatQuestion('What open-source projects do you maintain?')">Open source?</button>
{{end}}
</div>
<form id="chat-form" class="chat-input-area"
hx-post="/api/chat"
hx-target="#chat-messages"
hx-swap="beforeend scroll:#chat-messages:bottom"
hx-indicator="#chat-typing"
hx-request='{"timeout":120000}'>
<input type="hidden" id="chat-session-id" name="session_id" value="">
<input type="hidden" name="lang" value="{{.Lang}}">
<input
type="text"
id="chat-input"
name="message"
class="chat-input"
placeholder="{{if eq .Lang "es"}}Pregunta algo sobre el CV...{{else}}Ask something about the CV...{{end}}"
autocomplete="off">
<button type="submit" class="chat-send-btn" aria-label="Send">
<iconify-icon icon="mdi:send"></iconify-icon>
</button>
</form>
</div>
<!-- Chat JavaScript — all interactions in plain JS, no Hyperscript -->
<script>
// Chat model readiness state
var chatModelReady = false;
var chatWarmedUp = false;
// Fun loading phrases — random per request
var chatLoadingPhrases = {{if eq .Lang "es"}}[
'Repasando mi curriculum…',
'Recordando mis proyectos…',
'Calentando neuronas…',
'Preparando mi mejor version…',
'Buscando en mis recuerdos…',
'Consultando mi experiencia…',
'Un momento, que me concentro…',
'Revisando 21 anos de codigo…',
'Encendiendo el cerebro digital…',
'Organizando ideas…',
'Dame un segundo…',
'Casi listo, paciencia…',
'Activando modo profesional…',
'Conectando con mi yo digital…'
]{{else}}[
'Reviewing my CV…',
'Remembering my projects…',
'Warming up neurons…',
'Preparing my best self…',
'Searching my memories…',
'Consulting my experience…',
'One moment, focusing…',
'Scanning 21 years of code…',
'Booting up the digital brain…',
'Organizing thoughts…',
'Give me a second…',
'Almost there, hang tight…',
'Activating professional mode…',
'Connecting to my digital self…'
]{{end}};
function chatLoadingPhrase() {
return chatLoadingPhrases[Math.floor(Math.random() * chatLoadingPhrases.length)];
}
// Poll model status until ready
function pollChatStatus() {
fetch('/api/chat/status').then(function(r) { return r.json(); }).then(function(data) {
if (data.ready) {
chatModelReady = true;
var statusEl = document.getElementById('chat-status-text');
if (statusEl) statusEl.style.display = 'none';
} else if (data.warming) {
setTimeout(pollChatStatus, 2000);
}
}).catch(function() {});
}
// Trigger warmup on page load and start polling
(function() {
if (!chatWarmedUp) {
chatWarmedUp = true;
fetch('/api/chat/warmup', { method: 'POST' }).catch(function() {});
pollChatStatus();
}
})();
// Toggle chat panel open/close
function toggleChatPanel() {
var panel = document.getElementById('chat-panel');
var btn = document.getElementById('chat-toggle-btn');
var wave = document.getElementById('chat-wave');
panel.classList.toggle('chat-open');
btn.classList.toggle('mascot-active');
if (wave) wave.classList.add('chat-wave-hidden');
if (panel.classList.contains('chat-open')) {
document.getElementById('chat-input').focus();
}
}
// Cog menu toggle
function toggleChatCog() {
document.getElementById('chat-cog-menu').classList.toggle('chat-cog-open');
}
function closeChatCog() {
document.getElementById('chat-cog-menu').classList.remove('chat-cog-open');
}
// Close cog when clicking outside
document.addEventListener('click', function(e) {
if (!e.target.closest('.chat-cog-wrapper')) closeChatCog();
});
// Show user message bubble immediately in chat
var chatBubblePending = false;
function appendUserBubble(text) {
var messages = document.getElementById('chat-messages');
var row = document.createElement('div');
row.className = 'chat-row chat-row-user';
row.innerHTML = '<div class="chat-msg">' + text.replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</div>'
+ '<div class="chat-avatar chat-avatar-user"><iconify-icon icon="mdi:account"></iconify-icon></div>';
messages.appendChild(row);
messages.scrollTop = messages.scrollHeight;
chatBubblePending = true;
}
// Send a question immediately (from chip click) — show bubble, clear input, send
function sendChatQuestion(question) {
appendUserBubble(question);
var input = document.getElementById('chat-input');
var form = document.getElementById('chat-form');
input.value = question;
htmx.trigger(form, 'submit');
input.value = '';
}
// Prefill input from help modal (user must click Send)
function closeChatHelpAndAsk(question) {
document.getElementById('chat-help-modal').close();
var panel = document.getElementById('chat-panel');
var btn = document.getElementById('chat-toggle-btn');
if (!panel.classList.contains('chat-open')) {
panel.classList.add('chat-open');
btn.classList.add('mascot-active');
}
var input = document.getElementById('chat-input');
input.value = question;
input.focus();
}
// Form submit — show user bubble for manual typing, clear input
document.addEventListener('htmx:beforeRequest', function(event) {
if (event.detail.elt && event.detail.elt.id === 'chat-form') {
var input = document.getElementById('chat-input');
var msg = input.value.trim();
// Chips already added their bubble; manual typing needs one
if (msg && !chatBubblePending) {
appendUserBubble(msg);
}
chatBubblePending = false;
input.value = '';
// Show initializing or typing indicator
var statusEl = document.getElementById('chat-status-text');
var dotsEl = document.querySelector('.chat-typing-dots');
if (!chatModelReady && statusEl && dotsEl) {
statusEl.textContent = chatLoadingPhrase();
statusEl.style.display = 'inline';
dotsEl.style.display = 'none';
} else if (statusEl && dotsEl) {
statusEl.style.display = 'none';
dotsEl.style.display = 'inline-flex';
}
}
});
// Reset indicator after HTMX request completes
document.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.elt && event.detail.elt.id === 'chat-form') {
document.getElementById('chat-input').value = '';
chatModelReady = true;
var statusEl = document.getElementById('chat-status-text');
if (statusEl) statusEl.style.display = 'none';
var dotsEl = document.querySelector('.chat-typing-dots');
if (dotsEl) dotsEl.style.display = 'inline-flex';
}
});
// Set chat panel size
function setChatSize(size) {
var panel = document.getElementById('chat-panel');
panel.classList.remove('chat-half', 'chat-full', 'chat-float', 'chat-split');
// Reset all inline styles from drag/resize
panel.style.top = '';
panel.style.left = '';
panel.style.right = '';
panel.style.bottom = '';
panel.style.width = '';
panel.style.height = '';
if (size) panel.classList.add(size);
// Update active item in cog menu
document.querySelectorAll('.chat-cog-item[data-mode]').forEach(function(item) {
item.classList.toggle('active', item.getAttribute('data-mode') === size);
});
// Enable/disable drag
if (size === 'chat-float') {
enableChatDrag(panel);
} else {
disableChatDrag(panel);
}
}
// Drag support for floating mode
var chatDragState = { dragging: false, offsetX: 0, offsetY: 0 };
function enableChatDrag(panel) {
var header = panel.querySelector('.chat-header');
header.style.cursor = 'grab';
header.addEventListener('mousedown', chatDragStart);
header.addEventListener('touchstart', chatDragTouchStart, { passive: false });
}
function disableChatDrag(panel) {
var header = panel.querySelector('.chat-header');
header.style.cursor = '';
header.removeEventListener('mousedown', chatDragStart);
header.removeEventListener('touchstart', chatDragTouchStart);
}
function chatDragStart(e) {
if (e.target.closest('button')) return;
var panel = document.getElementById('chat-panel');
chatDragState.dragging = true;
chatDragState.offsetX = e.clientX - panel.getBoundingClientRect().left;
chatDragState.offsetY = e.clientY - panel.getBoundingClientRect().top;
panel.classList.add('chat-dragging');
document.addEventListener('mousemove', chatDragMove);
document.addEventListener('mouseup', chatDragEnd);
panel.querySelector('.chat-header').style.cursor = 'grabbing';
e.preventDefault();
}
function chatDragTouchStart(e) {
if (e.target.closest('button')) return;
var panel = document.getElementById('chat-panel');
var touch = e.touches[0];
chatDragState.dragging = true;
chatDragState.offsetX = touch.clientX - panel.getBoundingClientRect().left;
chatDragState.offsetY = touch.clientY - panel.getBoundingClientRect().top;
panel.classList.add('chat-dragging');
document.addEventListener('touchmove', chatDragTouchMove, { passive: false });
document.addEventListener('touchend', chatDragEnd);
e.preventDefault();
}
function chatDragMove(e) {
if (!chatDragState.dragging) return;
var panel = document.getElementById('chat-panel');
panel.style.left = (e.clientX - chatDragState.offsetX) + 'px';
panel.style.top = (e.clientY - chatDragState.offsetY) + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
}
function chatDragTouchMove(e) {
if (!chatDragState.dragging) return;
var touch = e.touches[0];
var panel = document.getElementById('chat-panel');
panel.style.left = (touch.clientX - chatDragState.offsetX) + 'px';
panel.style.top = (touch.clientY - chatDragState.offsetY) + 'px';
panel.style.right = 'auto';
panel.style.bottom = 'auto';
e.preventDefault();
}
function chatDragEnd() {
chatDragState.dragging = false;
var panel = document.getElementById('chat-panel');
panel.classList.remove('chat-dragging');
panel.querySelector('.chat-header').style.cursor = 'grab';
document.removeEventListener('mousemove', chatDragMove);
document.removeEventListener('mouseup', chatDragEnd);
document.removeEventListener('touchmove', chatDragTouchMove);
document.removeEventListener('touchend', chatDragEnd);
}
// Navigate from chat link to CV section, then highlight
function scrollToCV(link) {
var anchor = link.getAttribute('href');
var target = document.querySelector(anchor);
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
target.classList.add('chat-highlight');
setTimeout(function() { target.classList.remove('chat-highlight'); }, 2000);
}
return false;
}
// Floating tooltip for chat disclaimer (escapes overflow:hidden)
function showChatTip(el) {
hideChatTip();
var tip = document.createElement('div');
tip.id = 'chat-tip';
tip.textContent = el.getAttribute('data-tip');
tip.style.cssText = 'position:fixed;background:rgba(0,0,0,0.85);color:#fff;font-size:11px;font-weight:600;padding:4px 8px;border-radius:6px;z-index:10000;pointer-events:none;box-shadow:0 2px 8px rgba(0,0,0,0.3);max-width:180px;line-height:1.3;opacity:0;transition:opacity .15s';
document.body.appendChild(tip);
var r = el.getBoundingClientRect();
var panel = el.closest('.chat-panel');
var panelR = panel ? panel.getBoundingClientRect() : { right: window.innerWidth };
var spaceRight = panelR.right - r.right;
// Show right if room, otherwise show below
if (spaceRight > tip.offsetWidth + 12) {
tip.style.left = (r.right + 8) + 'px';
tip.style.top = (r.top + r.height / 2 - tip.offsetHeight / 2) + 'px';
} else {
tip.style.left = r.left + 'px';
tip.style.top = (r.bottom + 6) + 'px';
}
tip.style.opacity = '1';
}
function hideChatTip() {
var t = document.getElementById('chat-tip');
if (t) t.remove();
}
</script>
{{end}}
{{end}}