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;