381 lines
17 KiB
HTML
381 lines
17 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. Solo respondo preguntas profesionales sobre el 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. I only answer professional questions about the 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é certificaciones tienes?')">¿Certificaciones?</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 certifications do you have?')">Certifications?</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;
|
|
|
|
// 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, '<').replace(/>/g, '>') + '</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 = '{{if eq .Lang "es"}}Inicializando modelo IA…{{else}}Initializing AI model…{{end}}';
|
|
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}}
|