feat: chat UX overhaul — GLM local model, icons, layout modes, instant bubbles
- Add GLM-4.7-Flash as default Ollama model (replaces Mistral) - Fix WRITE_TIMEOUT (15s→120s) and HTMX timeout (5s→120s) for local LLM - Auto-warmup model on startup in development mode - Add /api/chat/status endpoint for model readiness polling - Show "Initializing AI model..." indicator while model loads - Add user avatar (mdi:account) on chat messages - Inject company/project/course sprite icons inline in chat responses - Replace cramped header icons with 4 icon buttons + tooltips (Compact, Side panel, Floating, Full screen) - Add floating/draggable chat mode with smooth drag support - Chip questions show user bubble instantly and clear input - Help modal prefills input instead of auto-sending - User bubble rendered client-side for immediate feedback
This commit is contained in:
+3
-2
@@ -15,8 +15,9 @@ TEMPLATE_HOT_RELOAD=true
|
||||
DATA_DIR=data
|
||||
|
||||
# Server Timeouts (seconds)
|
||||
# Write timeout must accommodate local LLM response times (Ollama ~60s for tool-calling queries)
|
||||
READ_TIMEOUT=15
|
||||
WRITE_TIMEOUT=15
|
||||
WRITE_TIMEOUT=120
|
||||
|
||||
# Security Configuration
|
||||
# Allowed origins for API access (comma-separated domains)
|
||||
@@ -91,7 +92,7 @@ CONTACT_EMAIL=recipient@example.com
|
||||
#
|
||||
# Ollama settings (when MODEL_PROVIDER=ollama):
|
||||
# OLLAMA_HOST=http://localhost:11434
|
||||
# OLLAMA_MODEL=mistral-small3.2
|
||||
# OLLAMA_MODEL=glm-4.7-flash
|
||||
|
||||
# Production Settings
|
||||
# Uncomment for production:
|
||||
|
||||
+91
-15
@@ -28,12 +28,21 @@ type chatRunner struct {
|
||||
label string
|
||||
}
|
||||
|
||||
// iconMap maps anchor IDs (e.g. "exp-sap", "proj-la-porraclub") to sprite info.
|
||||
type spriteInfo struct {
|
||||
index int
|
||||
category string // "company", "project", "course"
|
||||
}
|
||||
|
||||
// Handler serves the chat API endpoint with automatic fallback.
|
||||
// Primary runner (Gemini) is tried first; if it fails, fallback (Ollama) is used.
|
||||
type Handler struct {
|
||||
primary *chatRunner
|
||||
fallback *chatRunner
|
||||
enabled bool
|
||||
warming bool // true while warmup is in progress
|
||||
warm bool // true after warmup completes
|
||||
icons map[string]spriteInfo // anchor ID → sprite info
|
||||
}
|
||||
|
||||
// NewHandler creates a chat handler with primary + optional fallback provider.
|
||||
@@ -42,7 +51,7 @@ type Handler struct {
|
||||
// - If only one is available, it becomes the sole provider
|
||||
// - If neither is available, chat is disabled
|
||||
func NewHandler(dataCache *cache.DataCache) *Handler {
|
||||
h := &Handler{}
|
||||
h := &Handler{icons: buildIconMap(dataCache)}
|
||||
|
||||
// Try Gemini as primary
|
||||
geminiLLM, geminiLabel, geminiErr := initGeminiProvider()
|
||||
@@ -155,6 +164,17 @@ func (h *Handler) HandleWarmup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
h.startWarmup()
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// startWarmup triggers model warmup in the background (idempotent).
|
||||
func (h *Handler) startWarmup() {
|
||||
if h.warm || h.warming {
|
||||
return
|
||||
}
|
||||
h.warming = true
|
||||
|
||||
// Warm up fallback (Ollama) in background — Gemini doesn't need warmup
|
||||
target := h.fallback
|
||||
if target == nil {
|
||||
@@ -162,7 +182,7 @@ func (h *Handler) HandleWarmup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
|
||||
sess, err := target.session.Create(ctx, &session.CreateRequest{
|
||||
@@ -170,16 +190,33 @@ func (h *Handler) HandleWarmup(w http.ResponseWriter, r *http.Request) {
|
||||
UserID: "warmup",
|
||||
})
|
||||
if err != nil {
|
||||
h.warming = false
|
||||
return
|
||||
}
|
||||
|
||||
msg := genai.NewContentFromText("hi", genai.RoleUser)
|
||||
for range target.runner.Run(ctx, "warmup", sess.Session.ID(), msg, agent.RunConfig{}) {
|
||||
}
|
||||
h.warm = true
|
||||
h.warming = false
|
||||
log.Printf("💬 Model warmed up (%s)", target.label)
|
||||
}()
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
// HandleStatus returns the chat readiness state as JSON.
|
||||
// GET /api/chat/status → {"ready": true/false, "warming": true/false}
|
||||
func (h *Handler) HandleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprintf(w, `{"ready":%t,"warming":%t}`, h.warm, h.warming)
|
||||
}
|
||||
|
||||
// AutoWarmup starts model warmup immediately (call on startup in development).
|
||||
func (h *Handler) AutoWarmup() {
|
||||
if !h.enabled {
|
||||
return
|
||||
}
|
||||
log.Println("💬 Auto-warming up model (development mode)...")
|
||||
h.startWarmup()
|
||||
}
|
||||
|
||||
// HandleChat processes POST /api/chat requests.
|
||||
@@ -222,14 +259,11 @@ func (h *Handler) HandleChat(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// User message bubble (no avatar, right-aligned)
|
||||
_, _ = fmt.Fprintf(w, `<div class="chat-row chat-row-user"><div class="chat-msg">%s</div></div>`, html.EscapeString(message))
|
||||
|
||||
// Agent response bubble with avatar
|
||||
// Agent response bubble with avatar (user bubble is rendered client-side)
|
||||
if response == "" {
|
||||
response = "I couldn't find an answer to that. Try asking about experience, projects, skills, or education."
|
||||
}
|
||||
_, _ = fmt.Fprintf(w, `<div class="chat-row chat-row-bot"><div class="chat-avatar"><iconify-icon icon="mdi:robot-happy-outline"></iconify-icon></div><div class="chat-msg">%s</div></div>`, formatResponse(response))
|
||||
_, _ = fmt.Fprintf(w, `<div class="chat-row chat-row-bot"><div class="chat-avatar"><iconify-icon icon="mdi:robot-happy-outline"></iconify-icon></div><div class="chat-msg">%s</div></div>`, h.formatResponse(response))
|
||||
|
||||
// Session ID via OOB swap
|
||||
_, _ = fmt.Fprintf(w, `<input type="hidden" id="chat-session-id" name="session_id" value="%s" form="chat-form" hx-swap-oob="true"/>`, sessionID)
|
||||
@@ -237,7 +271,7 @@ func (h *Handler) HandleChat(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// runAgent executes the agent on the given runner and returns the response text.
|
||||
func (h *Handler) runAgent(cr *chatRunner, message string) (string, string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Create a new session for each request (stateless for fallback compatibility)
|
||||
@@ -274,8 +308,9 @@ func (h *Handler) runAgent(cr *chatRunner, message string) (string, string, erro
|
||||
// mdLinkRe matches markdown links like [text](#anchor)
|
||||
var mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\((#[a-zA-Z0-9_-]+)\)`)
|
||||
|
||||
// formatResponse converts basic markdown to HTML for the chat bubble.
|
||||
func formatResponse(text string) string {
|
||||
// formatResponse converts basic markdown to HTML for the chat bubble,
|
||||
// injecting sprite icons next to navigation links when available.
|
||||
func (h *Handler) formatResponse(text string) string {
|
||||
text = html.EscapeString(text)
|
||||
|
||||
for strings.Contains(text, "**") {
|
||||
@@ -283,10 +318,22 @@ func formatResponse(text string) string {
|
||||
text = strings.Replace(text, "**", "</strong>", 1)
|
||||
}
|
||||
|
||||
// Links: [text](#anchor) → clickable navigation link
|
||||
// After html.EscapeString, the parens and brackets are unchanged but # stays.
|
||||
// The regex matches the escaped form since []()# are not escaped by html.EscapeString.
|
||||
text = mdLinkRe.ReplaceAllString(text, `<a href="$2" class="chat-nav-link" onclick="return scrollToCV(this)">$1</a>`)
|
||||
// Links: [text](#anchor) → sprite icon + clickable navigation link
|
||||
text = mdLinkRe.ReplaceAllStringFunc(text, func(match string) string {
|
||||
parts := mdLinkRe.FindStringSubmatch(match)
|
||||
if len(parts) != 3 {
|
||||
return match
|
||||
}
|
||||
linkText, anchor := parts[1], parts[2]
|
||||
// anchor is like "#exp-sap" or "#proj-la-porraclub"
|
||||
anchorID := strings.TrimPrefix(anchor, "#")
|
||||
link := fmt.Sprintf(`<a href="%s" class="chat-nav-link" onclick="return scrollToCV(this)">%s</a>`, anchor, linkText)
|
||||
if info, ok := h.icons[anchorID]; ok {
|
||||
sprite := fmt.Sprintf(`<span class="icon-sprite icon-small icon-%s" style="--icon-index:%d" role="img"></span>`, info.category, info.index)
|
||||
return sprite + " " + link
|
||||
}
|
||||
return link
|
||||
})
|
||||
|
||||
lines := strings.Split(text, "\n")
|
||||
var result []string
|
||||
@@ -315,3 +362,32 @@ func formatResponse(text string) string {
|
||||
|
||||
return strings.Join(result, "")
|
||||
}
|
||||
|
||||
// buildIconMap creates a mapping from anchor IDs to sprite info from CV data.
|
||||
func buildIconMap(dataCache *cache.DataCache) map[string]spriteInfo {
|
||||
icons := make(map[string]spriteInfo)
|
||||
|
||||
for _, lang := range []string{"en", "es"} {
|
||||
cv := dataCache.GetCV(lang)
|
||||
if cv == nil {
|
||||
continue
|
||||
}
|
||||
for _, e := range cv.Experience {
|
||||
if e.LogoIndex != nil && e.CompanyID != "" {
|
||||
icons["exp-"+e.CompanyID] = spriteInfo{index: *e.LogoIndex, category: "company"}
|
||||
}
|
||||
}
|
||||
for _, p := range cv.Projects {
|
||||
if p.LogoIndex != nil && p.ProjectID != "" {
|
||||
icons["proj-"+p.ProjectID] = spriteInfo{index: *p.LogoIndex, category: "project"}
|
||||
}
|
||||
}
|
||||
for _, c := range cv.Courses {
|
||||
if c.LogoIndex != nil && c.CourseID != "" {
|
||||
icons["course-"+c.CourseID] = spriteInfo{index: *c.LogoIndex, category: "course"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return icons
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler,
|
||||
chatRateLimiter := middleware.NewRateLimiter(c.RateLimitChatRequests, c.RateLimitChatWindow)
|
||||
mux.Handle("/api/chat", chatRateLimiter.Middleware(http.HandlerFunc(chatHandler.HandleChat)))
|
||||
mux.HandleFunc("/api/chat/warmup", chatHandler.HandleWarmup) // Pre-load model on chat open
|
||||
mux.HandleFunc("/api/chat/status", chatHandler.HandleStatus) // Model readiness check
|
||||
|
||||
// Public routes
|
||||
mux.HandleFunc("/", cvHandler.Home)
|
||||
|
||||
@@ -66,6 +66,11 @@ func main() {
|
||||
// Initialize chat handler (gracefully disabled if no API key)
|
||||
chatHandler := chat.NewHandler(dataCache)
|
||||
|
||||
// In development, auto-warmup the local LLM model on startup
|
||||
if os.Getenv("GO_ENV") != "production" {
|
||||
chatHandler.AutoWarmup()
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
cvHandler := handlers.NewCVHandler(templateMgr, cfg.Address(), emailService, dataCache, chatHandler.Enabled())
|
||||
healthHandler := handlers.NewHealthHandler(version)
|
||||
|
||||
@@ -98,6 +98,32 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Size: Floating — draggable, resizable feel */
|
||||
.chat-panel.chat-float {
|
||||
top: 10vh;
|
||||
left: auto;
|
||||
right: 2rem;
|
||||
bottom: auto;
|
||||
width: 420px;
|
||||
max-height: 70vh;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
|
||||
resize: both;
|
||||
overflow: hidden;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* Disable transition during active drag for instant movement */
|
||||
.chat-panel.chat-dragging {
|
||||
transition: none !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chat-panel.chat-float .chat-messages {
|
||||
max-height: none;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Size: Full width */
|
||||
.chat-panel.chat-full {
|
||||
top: 0;
|
||||
@@ -135,46 +161,64 @@
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Size controls — 3 discrete toggle icons */
|
||||
.chat-size-controls {
|
||||
/* Header actions — icon buttons with tooltips */
|
||||
.chat-header-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.chat-size-opt {
|
||||
.chat-mode-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: rgba(255,255,255,0.6);
|
||||
color: rgba(255,255,255,0.5);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 2px;
|
||||
font-size: 0.95rem;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
border-radius: 3px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
transition: all 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-size-opt:hover,
|
||||
.chat-size-opt.active {
|
||||
.chat-mode-btn:hover {
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.chat-help-btn {
|
||||
margin-left: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--action-bar-text-muted, rgba(255, 255, 255, 0.85));
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
transition: color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
.chat-mode-btn.active {
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.chat-help-btn:hover {
|
||||
/* Native tooltip via title attr — enhanced with CSS for consistent look */
|
||||
.chat-mode-btn[title]:hover::after {
|
||||
content: attr(title);
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--black-bar, #2b2b2b);
|
||||
color: #fff;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.65rem;
|
||||
font-family: 'Source Sans Pro', sans-serif;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
z-index: 1001;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.chat-header-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgba(255,255,255,0.25);
|
||||
margin: 0 3px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -222,6 +266,10 @@
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.chat-avatar-user {
|
||||
background: var(--text-light, #999999);
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
padding: 10px 14px;
|
||||
border-radius: 16px;
|
||||
@@ -293,6 +341,13 @@
|
||||
Navigation Links in Chat Messages
|
||||
========================================================================== */
|
||||
|
||||
/* Inline sprite icons in chat messages */
|
||||
.chat-msg .icon-sprite {
|
||||
vertical-align: middle;
|
||||
margin-right: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-nav-link {
|
||||
color: var(--accent-green, #27ae60);
|
||||
text-decoration: none;
|
||||
@@ -331,6 +386,24 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.chat-typing-dots {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.chat-status-text {
|
||||
font-size: 0.72rem;
|
||||
color: var(--accent-green, #27ae60);
|
||||
font-style: italic;
|
||||
animation: statusPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes statusPulse {
|
||||
0%, 100% { opacity: 0.6; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.chat-typing-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
|
||||
@@ -15,24 +15,28 @@
|
||||
<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-size-controls">
|
||||
<button class="chat-size-opt active" onclick="setChatSize('')" title="{{if eq .Lang "es"}}Compacto{{else}}Compact{{end}}">
|
||||
<iconify-icon icon="mdi:dock-window"></iconify-icon>
|
||||
<div class="chat-header-actions">
|
||||
<button class="chat-mode-btn active" data-mode="" title="{{if eq .Lang "es"}}Compacto{{else}}Compact{{end}}" onclick="setChatSize('')">
|
||||
<iconify-icon icon="mdi:message-outline"></iconify-icon>
|
||||
</button>
|
||||
<button class="chat-size-opt" onclick="setChatSize('chat-half')" title="{{if eq .Lang "es"}}Media pantalla{{else}}Half screen{{end}}">
|
||||
<iconify-icon icon="mdi:dock-right"></iconify-icon>
|
||||
<button class="chat-mode-btn" data-mode="chat-half" title="{{if eq .Lang "es"}}Panel lateral{{else}}Side panel{{end}}" onclick="setChatSize('chat-half')">
|
||||
<iconify-icon icon="mdi:page-layout-sidebar-right"></iconify-icon>
|
||||
</button>
|
||||
<button class="chat-size-opt" onclick="setChatSize('chat-full')" title="{{if eq .Lang "es"}}Pantalla completa{{else}}Full screen{{end}}">
|
||||
<iconify-icon icon="mdi:fullscreen"></iconify-icon>
|
||||
<button class="chat-mode-btn" data-mode="chat-float" title="{{if eq .Lang "es"}}Flotante — arrastra para mover{{else}}Floating — drag to move{{end}}" onclick="setChatSize('chat-float')">
|
||||
<iconify-icon icon="mdi:cursor-move"></iconify-icon>
|
||||
</button>
|
||||
</div>
|
||||
<button class="chat-help-btn"
|
||||
aria-label="{{if eq .Lang "es"}}Ayuda{{else}}Help{{end}}"
|
||||
<button class="chat-mode-btn" data-mode="chat-full" title="{{if eq .Lang "es"}}Pantalla completa{{else}}Full screen{{end}}" onclick="setChatSize('chat-full')">
|
||||
<iconify-icon icon="mdi:arrow-expand-all"></iconify-icon>
|
||||
</button>
|
||||
<span class="chat-header-divider"></span>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chat-messages" class="chat-messages">
|
||||
<div class="chat-row chat-row-bot">
|
||||
@@ -41,11 +45,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Typing Indicator -->
|
||||
<!-- 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 -->
|
||||
@@ -69,7 +76,8 @@
|
||||
hx-post="/api/chat"
|
||||
hx-target="#chat-messages"
|
||||
hx-swap="beforeend scroll:#chat-messages:bottom"
|
||||
hx-indicator="#chat-typing">
|
||||
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
|
||||
@@ -87,8 +95,33 @@
|
||||
|
||||
<!-- Chat JavaScript — all interactions in plain JS, no Hyperscript -->
|
||||
<script>
|
||||
// Toggle chat panel open/close
|
||||
// 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');
|
||||
@@ -96,23 +129,33 @@ function toggleChatPanel() {
|
||||
btn.classList.toggle('mascot-active');
|
||||
if (panel.classList.contains('chat-open')) {
|
||||
document.getElementById('chat-input').focus();
|
||||
// Warm up the model on first open (silent background ping)
|
||||
if (!chatWarmedUp) {
|
||||
chatWarmedUp = true;
|
||||
fetch('/api/chat/warmup', { method: 'POST' }).catch(function() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send a question (from chip or help modal)
|
||||
// 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 = '';
|
||||
}
|
||||
|
||||
// Close help modal, open chat, and send question
|
||||
// Prefill input from help modal (user must click Send)
|
||||
function closeChatHelpAndAsk(question) {
|
||||
document.getElementById('chat-help-modal').close();
|
||||
var panel = document.getElementById('chat-panel');
|
||||
@@ -121,19 +164,141 @@ function closeChatHelpAndAsk(question) {
|
||||
panel.classList.add('chat-open');
|
||||
btn.classList.add('mascot-active');
|
||||
}
|
||||
sendChatQuestion(question);
|
||||
var input = document.getElementById('chat-input');
|
||||
input.value = question;
|
||||
input.focus();
|
||||
}
|
||||
|
||||
// Set chat panel size: '' (compact), 'chat-half', 'chat-full'
|
||||
// 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');
|
||||
panel.classList.remove('chat-half', 'chat-full', 'chat-float');
|
||||
panel.style.top = '';
|
||||
panel.style.left = '';
|
||||
panel.style.right = '';
|
||||
panel.style.bottom = '';
|
||||
if (size) panel.classList.add(size);
|
||||
// Highlight active button
|
||||
document.querySelectorAll('.chat-size-opt').forEach(function(btn) {
|
||||
btn.classList.remove('active');
|
||||
// Update active button
|
||||
document.querySelectorAll('.chat-mode-btn[data-mode]').forEach(function(btn) {
|
||||
btn.classList.toggle('active', btn.getAttribute('data-mode') === size);
|
||||
});
|
||||
event.currentTarget.classList.add('active');
|
||||
// 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
|
||||
@@ -147,13 +312,6 @@ function scrollToCV(link) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Clear input 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 = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user