feat: chat icons — image fallbacks, external links, smaller inline size

- Support image file fallback when no sprite index exists
  (Immich Photo Manager, Cmux Resurrect now show their logos)
- Render external links [text](https://...) as clickable links
  (fixes Third Party Contributions raw markdown)
- Smaller inline icons (20px) to fit chat bubble aesthetic
- Separate icon-chat CSS class for chat-specific sizing
This commit is contained in:
juanatsap
2026-04-09 11:34:14 +01:00
parent c3f4134daa
commit 21c33d2833
2 changed files with 82 additions and 26 deletions
+56 -24
View File
@@ -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, "**", "</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 {
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)
linkText, href := parts[1], parts[2]
// External link
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 {
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
var icon string
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
})
@@ -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}
}
}
}
+26 -2
View File
@@ -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;