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:
juanatsap
2026-04-09 10:54:23 +01:00
parent d5c90248cc
commit 8e029d1363
6 changed files with 394 additions and 80 deletions
+91 -15
View File
@@ -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
}