feat: chat icons — image fallbacks, external links, compact sizing

This commit is contained in:
juanatsap
2026-04-09 11:34:26 +01:00
2 changed files with 82 additions and 26 deletions
+56 -24
View File
@@ -28,10 +28,11 @@ type chatRunner struct {
label string label string
} }
// iconMap maps anchor IDs (e.g. "exp-sap", "proj-la-porraclub") to sprite info. // iconInfo maps anchor IDs to icon rendering info.
type spriteInfo struct { type iconInfo struct {
index int spriteIndex int // -1 means use image file instead
category string // "company", "project", "course" category string // "company", "project", "course"
imagePath string // fallback: "/static/images/projects/foo.png"
} }
// Handler serves the chat API endpoint with automatic fallback. // Handler serves the chat API endpoint with automatic fallback.
@@ -42,7 +43,7 @@ type Handler struct {
enabled bool enabled bool
warming bool // true while warmup is in progress warming bool // true while warmup is in progress
warm bool // true after warmup completes 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. // 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 return response.String(), sessionID, nil
} }
// mdLinkRe matches markdown links like [text](#anchor) // mdLinkRe matches markdown links: [text](#anchor) and [text](https://...)
var mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\((#[a-zA-Z0-9_-]+)\)`) var mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(((?:#|https?://)[^\)]+)\)`)
// formatResponse converts basic markdown to HTML for the chat bubble, // 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 { func (h *Handler) formatResponse(text string) string {
text = html.EscapeString(text) text = html.EscapeString(text)
@@ -318,19 +319,32 @@ func (h *Handler) formatResponse(text string) string {
text = strings.Replace(text, "**", "</strong>", 1) text = strings.Replace(text, "**", "</strong>", 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 { text = mdLinkRe.ReplaceAllStringFunc(text, func(match string) string {
parts := mdLinkRe.FindStringSubmatch(match) parts := mdLinkRe.FindStringSubmatch(match)
if len(parts) != 3 { if len(parts) != 3 {
return match return match
} }
linkText, anchor := parts[1], parts[2] linkText, href := parts[1], parts[2]
// anchor is like "#exp-sap" or "#proj-la-porraclub"
anchorID := strings.TrimPrefix(anchor, "#") // External link
link := fmt.Sprintf(`<a href="%s" class="chat-nav-link" onclick="return scrollToCV(this)">%s</a>`, anchor, linkText) if strings.HasPrefix(href, "http") {
return fmt.Sprintf(`<a href="%s" class="chat-nav-link" target="_blank" rel="noopener">%s</a>`, href, linkText)
}
// Internal CV navigation link
anchorID := strings.TrimPrefix(href, "#")
link := fmt.Sprintf(`<a href="%s" class="chat-nav-link" onclick="return scrollToCV(this)">%s</a>`, href, linkText)
if info, ok := h.icons[anchorID]; ok { 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) var icon string
return sprite + " " + link if info.spriteIndex >= 0 {
icon = fmt.Sprintf(`<span class="icon-sprite icon-chat icon-%s" style="--icon-index:%d" role="img"></span>`, info.category, info.spriteIndex)
} else if info.imagePath != "" {
icon = fmt.Sprintf(`<img src="%s" class="chat-inline-icon" alt="">`, info.imagePath)
}
if icon != "" {
return icon + " " + link
}
} }
return link return link
}) })
@@ -363,9 +377,9 @@ func (h *Handler) formatResponse(text string) string {
return strings.Join(result, "") return strings.Join(result, "")
} }
// buildIconMap creates a mapping from anchor IDs to sprite info from CV data. // buildIconMap creates a mapping from anchor IDs to icon info from CV data.
func buildIconMap(dataCache *cache.DataCache) map[string]spriteInfo { func buildIconMap(dataCache *cache.DataCache) map[string]iconInfo {
icons := make(map[string]spriteInfo) icons := make(map[string]iconInfo)
for _, lang := range []string{"en", "es"} { for _, lang := range []string{"en", "es"} {
cv := dataCache.GetCV(lang) cv := dataCache.GetCV(lang)
@@ -373,18 +387,36 @@ func buildIconMap(dataCache *cache.DataCache) map[string]spriteInfo {
continue continue
} }
for _, e := range cv.Experience { for _, e := range cv.Experience {
if e.LogoIndex != nil && e.CompanyID != "" { if e.CompanyID == "" {
icons["exp-"+e.CompanyID] = spriteInfo{index: *e.LogoIndex, category: "company"} 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 { for _, p := range cv.Projects {
if p.LogoIndex != nil && p.ProjectID != "" { if p.ProjectID == "" {
icons["proj-"+p.ProjectID] = spriteInfo{index: *p.LogoIndex, category: "project"} 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 { for _, c := range cv.Courses {
if c.LogoIndex != nil && c.CourseID != "" { if c.CourseID == "" {
icons["course-"+c.CourseID] = spriteInfo{index: *c.LogoIndex, category: "course"} 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}
} }
} }
} }
+26 -2
View File
@@ -346,13 +346,37 @@
Navigation Links in Chat Messages Navigation Links in Chat Messages
========================================================================== */ ========================================================================== */
/* Inline sprite icons in chat messages */ /* Inline icons in chat messages — compact size to fit bubbles */
.chat-msg .icon-sprite { .chat-msg .icon-sprite.icon-chat {
width: 20px;
height: 20px;
background-size: auto 20px;
vertical-align: middle; vertical-align: middle;
margin-right: 2px; margin-right: 2px;
border-radius: 3px; 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 { .chat-nav-link {
color: var(--accent-green, #27ae60); color: var(--accent-green, #27ae60);
text-decoration: none; text-decoration: none;