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:
+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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user