diff --git a/internal/chat/handler.go b/internal/chat/handler.go index f21b6f3..4432cf7 100644 --- a/internal/chat/handler.go +++ b/internal/chat/handler.go @@ -28,10 +28,11 @@ 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" +// iconInfo maps anchor IDs to icon rendering info. +type iconInfo struct { + spriteIndex int // -1 means use image file instead + category string // "company", "project", "course" + imagePath string // fallback: "/static/images/projects/foo.png" } // Handler serves the chat API endpoint with automatic fallback. @@ -42,7 +43,7 @@ type Handler struct { enabled bool warming bool // true while warmup is in progress warm bool // true after warmup completes - icons map[string]spriteInfo // anchor ID → sprite info + icons map[string]iconInfo // anchor ID → icon info } // NewHandler creates a chat handler with primary + optional fallback provider. @@ -305,11 +306,11 @@ func (h *Handler) runAgent(cr *chatRunner, message string) (string, string, erro return response.String(), sessionID, nil } -// mdLinkRe matches markdown links like [text](#anchor) -var mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\((#[a-zA-Z0-9_-]+)\)`) +// mdLinkRe matches markdown links: [text](#anchor) and [text](https://...) +var mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(((?:#|https?://)[^\)]+)\)`) // formatResponse converts basic markdown to HTML for the chat bubble, -// injecting sprite icons next to navigation links when available. +// injecting icons next to navigation links when available. func (h *Handler) formatResponse(text string) string { text = html.EscapeString(text) @@ -318,19 +319,32 @@ func (h *Handler) formatResponse(text string) string { text = strings.Replace(text, "**", "", 1) } - // Links: [text](#anchor) → sprite icon + clickable navigation link + // Links: [text](#anchor) → icon + nav link, [text](https://...) → external 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) + linkText, href := parts[1], parts[2] + + // External link + if strings.HasPrefix(href, "http") { + return fmt.Sprintf(`%s`, href, linkText) + } + + // Internal CV navigation link + anchorID := strings.TrimPrefix(href, "#") + link := fmt.Sprintf(`%s`, href, linkText) if info, ok := h.icons[anchorID]; ok { - sprite := fmt.Sprintf(``, info.category, info.index) - return sprite + " " + link + var icon string + if info.spriteIndex >= 0 { + icon = fmt.Sprintf(``, info.category, info.spriteIndex) + } else if info.imagePath != "" { + icon = fmt.Sprintf(``, info.imagePath) + } + if icon != "" { + return icon + " " + link + } } return link }) @@ -363,9 +377,9 @@ func (h *Handler) 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) +// buildIconMap creates a mapping from anchor IDs to icon info from CV data. +func buildIconMap(dataCache *cache.DataCache) map[string]iconInfo { + icons := make(map[string]iconInfo) for _, lang := range []string{"en", "es"} { cv := dataCache.GetCV(lang) @@ -373,18 +387,36 @@ func buildIconMap(dataCache *cache.DataCache) map[string]spriteInfo { continue } for _, e := range cv.Experience { - if e.LogoIndex != nil && e.CompanyID != "" { - icons["exp-"+e.CompanyID] = spriteInfo{index: *e.LogoIndex, category: "company"} + if e.CompanyID == "" { + continue + } + key := "exp-" + e.CompanyID + if e.LogoIndex != nil { + icons[key] = iconInfo{spriteIndex: *e.LogoIndex, category: "company"} + } else if e.CompanyLogo != "" { + icons[key] = iconInfo{spriteIndex: -1, imagePath: "/static/images/companies/" + e.CompanyLogo} } } for _, p := range cv.Projects { - if p.LogoIndex != nil && p.ProjectID != "" { - icons["proj-"+p.ProjectID] = spriteInfo{index: *p.LogoIndex, category: "project"} + if p.ProjectID == "" { + continue + } + key := "proj-" + p.ProjectID + if p.LogoIndex != nil { + icons[key] = iconInfo{spriteIndex: *p.LogoIndex, category: "project"} + } else if p.ProjectLogo != "" { + icons[key] = iconInfo{spriteIndex: -1, imagePath: "/static/images/projects/" + p.ProjectLogo} } } for _, c := range cv.Courses { - if c.LogoIndex != nil && c.CourseID != "" { - icons["course-"+c.CourseID] = spriteInfo{index: *c.LogoIndex, category: "course"} + if c.CourseID == "" { + continue + } + key := "course-" + c.CourseID + if c.LogoIndex != nil { + icons[key] = iconInfo{spriteIndex: *c.LogoIndex, category: "course"} + } else if c.CourseLogo != "" { + icons[key] = iconInfo{spriteIndex: -1, imagePath: "/static/images/courses/" + c.CourseLogo} } } } diff --git a/static/css/04-interactive/_chat.css b/static/css/04-interactive/_chat.css index 6aa9ec0..fb97832 100644 --- a/static/css/04-interactive/_chat.css +++ b/static/css/04-interactive/_chat.css @@ -346,13 +346,37 @@ Navigation Links in Chat Messages ========================================================================== */ -/* Inline sprite icons in chat messages */ -.chat-msg .icon-sprite { +/* Inline icons in chat messages — compact size to fit bubbles */ +.chat-msg .icon-sprite.icon-chat { + width: 20px; + height: 20px; + background-size: auto 20px; vertical-align: middle; margin-right: 2px; border-radius: 3px; } +.chat-msg .icon-chat.icon-company { + background-position-x: calc(var(--icon-index, 0) * -20px); +} + +.chat-msg .icon-chat.icon-project { + background-position-x: calc(var(--icon-index, 0) * -20px); +} + +.chat-msg .icon-chat.icon-course { + background-position-x: calc(var(--icon-index, 0) * -20px); +} + +.chat-inline-icon { + width: 20px; + height: 20px; + vertical-align: middle; + margin-right: 2px; + border-radius: 3px; + object-fit: contain; +} + .chat-nav-link { color: var(--accent-green, #27ae60); text-decoration: none;