From 8e029d13631bd890b0c9c122a1ebeb825a899f62 Mon Sep 17 00:00:00 2001 From: juanatsap Date: Thu, 9 Apr 2026 10:54:23 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20chat=20UX=20overhaul=20=E2=80=94=20GLM?= =?UTF-8?q?=20local=20model,=20icons,=20layout=20modes,=20instant=20bubble?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 5 +- internal/chat/handler.go | 106 +++++++-- internal/routes/routes.go | 3 +- main.go | 5 + static/css/04-interactive/_chat.css | 117 ++++++++-- templates/partials/widgets/chat-widget.html | 238 ++++++++++++++++---- 6 files changed, 394 insertions(+), 80 deletions(-) diff --git a/.env.example b/.env.example index 11ecf0a..dc780ae 100644 --- a/.env.example +++ b/.env.example @@ -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: diff --git a/internal/chat/handler.go b/internal/chat/handler.go index c96df55..f21b6f3 100644 --- a/internal/chat/handler.go +++ b/internal/chat/handler.go @@ -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, `
%s
`, 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, `
%s
`, formatResponse(response)) + _, _ = fmt.Fprintf(w, `
%s
`, h.formatResponse(response)) // Session ID via OOB swap _, _ = fmt.Fprintf(w, ``, 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, "**", "", 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, `$1`) + // 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(`%s`, anchor, linkText) + if info, ok := h.icons[anchorID]; ok { + sprite := fmt.Sprintf(``, 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 +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index a6c59dc..5b8e979 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -23,7 +23,8 @@ func Setup(cvHandler *handlers.CVHandler, healthHandler *handlers.HealthHandler, // Chat endpoint with rate limiting (30 requests/hour per IP) 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/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) diff --git a/main.go b/main.go index 5b68b41..e2ccb2b 100644 --- a/main.go +++ b/main.go @@ -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) diff --git a/static/css/04-interactive/_chat.css b/static/css/04-interactive/_chat.css index 80ccc47..6d49ecd 100644 --- a/static/css/04-interactive/_chat.css +++ b/static/css/04-interactive/_chat.css @@ -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; diff --git a/templates/partials/widgets/chat-widget.html b/templates/partials/widgets/chat-widget.html index e136f5b..9fcf20c 100644 --- a/templates/partials/widgets/chat-widget.html +++ b/templates/partials/widgets/chat-widget.html @@ -15,23 +15,27 @@
{{if eq .Lang "es"}}Asistente del CV{{else}}CV Assistant{{end}} -
- - - + + +
-
@@ -41,11 +45,14 @@
- +
- - - + + + + + +
@@ -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}'> {{end}} {{end}}