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, `
`, 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, ``, formatResponse(response))
+ _, _ = fmt.Fprintf(w, ``, 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 @@
-
+
-
-
-
+
+
+
+
+
+
@@ -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}}