feat: chat icons — image fallbacks, external links, compact sizing
This commit is contained in:
+55
-23
@@ -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}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user