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