2026-04-08 00:20:48 +01:00
{{define "chat-widget"}}
{{if .ChatEnabled}}
2026-04-08 10:49:19 +01:00
<!-- AI Chat Widget — CV Assistant Mascot -->
2026-04-08 00:20:48 +01:00
< button
id = "chat-toggle-btn"
2026-04-08 13:37:32 +01:00
class = "chat-toggle-btn no-print has-tooltip tooltip-left"
2026-04-08 10:49:19 +01:00
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 } } "
2026-04-08 14:11:11 +01:00
onclick = "toggleChatPanel()" >
2026-04-08 10:49:19 +01:00
< iconify-icon icon = "mdi:robot-happy-outline" class = "chat-icon-open" > < / iconify-icon >
< iconify-icon icon = "mdi:close" class = "chat-icon-close" > < / iconify-icon >
2026-04-08 00:20:48 +01:00
< / button >
2026-04-09 11:41:18 +01:00
< span id = "chat-wave" class = "chat-wave no-print" > 👋< / span >
2026-04-08 00:20:48 +01:00
< div id = "chat-panel" class = "chat-panel no-print" >
< div class = "chat-header" >
2026-04-08 10:49:19 +01:00
< iconify-icon icon = "mdi:robot-happy-outline" > < / iconify-icon >
< span > {{if eq .Lang "es"}}Asistente del CV{{else}}CV Assistant{{end}}< / span >
2026-04-09 10:54:23 +01:00
< div class = "chat-header-actions" >
2026-04-09 18:39:51 +01:00
<!-- 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 >
2026-04-09 10:54:23 +01:00
< 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 >
2026-04-08 17:51:14 +01:00
< / button >
2026-04-09 11:45:48 +01:00
< button class = "chat-mode-btn" title = "{{if eq .Lang " es " } } Cerrar { { else } } Close { { end } } " onclick = "toggleChatPanel()" >
< iconify-icon icon = "mdi:close" > < / iconify-icon >
< / button >
2026-04-08 17:51:14 +01:00
< / div >
2026-04-08 00:20:48 +01:00
< / div >
< div id = "chat-messages" class = "chat-messages" >
2026-04-08 17:51:14 +01:00
< div class = "chat-row chat-row-bot" >
2026-04-26 23:32:48 +01:00
< div class = "chat-avatar chat-avatar-juan" > < img src = "/static/images/profile/dni-thumb.jpeg" alt = "Juan" > < / div >
2026-04-27 00:00:52 +01:00
< 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 >
2026-04-08 00:20:48 +01:00
< / div >
< / div >
2026-04-09 10:54:23 +01:00
<!-- Typing / Status Indicator -->
2026-04-08 10:49:19 +01:00
< div id = "chat-typing" class = "chat-typing" >
2026-04-09 10:54:23 +01:00
< 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 >
2026-04-08 10:49:19 +01:00
< / div >
<!-- Suggested Questions -->
< div class = "chat-suggestions" >
{{if eq .Lang "es"}}
2026-04-26 23:39:38 +01:00
< 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 >
2026-04-08 10:49:19 +01:00
{{else}}
2026-04-26 23:39:38 +01:00
< 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 >
2026-04-08 10:49:19 +01:00
{{end}}
< / div >
2026-04-08 00:20:48 +01:00
< form id = "chat-form" class = "chat-input-area"
hx-post = "/api/chat"
hx-target = "#chat-messages"
hx-swap = "beforeend scroll:#chat-messages:bottom"
2026-04-09 10:54:23 +01:00
hx-indicator = "#chat-typing"
hx-request = '{"timeout":120000}' >
2026-04-08 11:31:09 +01:00
< input type = "hidden" id = "chat-session-id" name = "session_id" value = "" >
2026-04-08 00:20:48 +01:00
< input type = "hidden" name = "lang" value = "{{.Lang}}" >
< input
type = "text"
id = "chat-input"
name = "message"
class = "chat-input"
2026-04-08 10:49:19 +01:00
placeholder = "{{if eq .Lang " es " } } Pregunta algo sobre el CV . . . { { else } } Ask something about the CV . . . { { end } } "
2026-04-08 11:31:09 +01:00
autocomplete = "off" >
2026-04-08 00:20:48 +01:00
< button type = "submit" class = "chat-send-btn" aria-label = "Send" >
< iconify-icon icon = "mdi:send" > < / iconify-icon >
< / button >
< / form >
< / div >
2026-04-08 14:11:11 +01:00
<!-- Chat JavaScript — all interactions in plain JS, no Hyperscript -->
< script >
2026-04-09 10:54:23 +01:00
// Chat model readiness state
var chatModelReady = false ;
2026-04-08 14:57:38 +01:00
var chatWarmedUp = false ;
2026-04-09 10:54:23 +01:00
// 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
2026-04-08 14:11:11 +01:00
function toggleChatPanel ( ) {
var panel = document . getElementById ( 'chat-panel' ) ;
var btn = document . getElementById ( 'chat-toggle-btn' ) ;
2026-04-09 11:41:18 +01:00
var wave = document . getElementById ( 'chat-wave' ) ;
2026-04-08 14:11:11 +01:00
panel . classList . toggle ( 'chat-open' ) ;
btn . classList . toggle ( 'mascot-active' ) ;
2026-04-09 11:41:18 +01:00
if ( wave ) wave . classList . add ( 'chat-wave-hidden' ) ;
2026-04-08 14:11:11 +01:00
if ( panel . classList . contains ( 'chat-open' ) ) {
document . getElementById ( 'chat-input' ) . focus ( ) ;
}
}
2026-04-09 18:39:51 +01:00
// 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 ( ) ;
} ) ;
2026-04-09 10:54:23 +01:00
// 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
2026-04-08 14:11:11 +01:00
function sendChatQuestion ( question ) {
2026-04-09 10:54:23 +01:00
appendUserBubble ( question ) ;
2026-04-08 14:11:11 +01:00
var input = document . getElementById ( 'chat-input' ) ;
var form = document . getElementById ( 'chat-form' ) ;
input . value = question ;
htmx . trigger ( form , 'submit' ) ;
2026-04-09 10:54:23 +01:00
input . value = '' ;
2026-04-08 14:11:11 +01:00
}
2026-04-09 10:54:23 +01:00
// Prefill input from help modal (user must click Send)
2026-04-08 14:11:11 +01:00
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' ) ;
}
2026-04-09 10:54:23 +01:00
var input = document . getElementById ( 'chat-input' ) ;
input . value = question ;
input . focus ( ) ;
2026-04-08 14:11:11 +01:00
}
2026-04-09 10:54:23 +01:00
// 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
2026-04-08 17:51:14 +01:00
function setChatSize ( size ) {
2026-04-08 17:24:35 +01:00
var panel = document . getElementById ( 'chat-panel' ) ;
2026-04-09 12:21:28 +01:00
panel . classList . remove ( 'chat-half' , 'chat-full' , 'chat-float' , 'chat-split' ) ;
2026-04-09 11:09:30 +01:00
// Reset all inline styles from drag/resize
2026-04-09 10:54:23 +01:00
panel . style . top = '' ;
panel . style . left = '' ;
panel . style . right = '' ;
panel . style . bottom = '' ;
2026-04-09 11:09:30 +01:00
panel . style . width = '' ;
panel . style . height = '' ;
2026-04-08 17:51:14 +01:00
if ( size ) panel . classList . add ( size ) ;
2026-04-09 18:39:51 +01:00
// 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 ) ;
2026-04-08 17:51:14 +01:00
} ) ;
2026-04-09 10:54:23 +01:00
// 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 ) ;
2026-04-08 17:24:35 +01:00
}
2026-04-08 17:11:22 +01:00
// Navigate from chat link to CV section, then highlight
function scrollToCV ( link ) {
var anchor = link . getAttribute ( 'href' ) ;
var target = document . querySelector ( anchor ) ;
if ( target ) {
2026-04-09 12:56:56 +01:00
target . scrollIntoView ( { behavior : 'smooth' , block : 'start' } ) ;
2026-04-08 17:11:22 +01:00
target . classList . add ( 'chat-highlight' ) ;
setTimeout ( function ( ) { target . classList . remove ( 'chat-highlight' ) ; } , 2000 ) ;
}
2026-04-08 17:51:14 +01:00
return false ;
2026-04-08 17:11:22 +01:00
}
2026-04-26 23:32:48 +01:00
// 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 ( ) ;
}
2026-04-08 14:11:11 +01:00
< / script >
2026-04-08 00:20:48 +01:00
{{end}}
{{end}}