From c44e9e8c67e5a495b91b31bfff0be8830f7a4ffd Mon Sep 17 00:00:00 2001 From: juanatsap Date: Wed, 8 Apr 2026 17:11:22 +0100 Subject: [PATCH] feat: CV navigation links in chat responses (GPS for the CV) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent instruction now requires markdown links to CV anchors: - Companies: [Olympic Broadcasting](#exp-olympic-broadcasting) - Projects: [Immich Photo Manager](#proj-immich-photo-manager) - Sections: [Skills](#skills), [Experience](#experience) formatResponse converts [text](#anchor) → clickable green links that close the chat panel, smooth-scroll to the target, and pulse a green highlight for 2 seconds. All existing CV anchor IDs used: exp-{companyID}, proj-{projectID}, course-{courseID}, plus section IDs (experience, projects, skills, etc.) --- internal/chat/agent.go | 9 ++++ internal/chat/handler.go | 9 ++++ static/css/04-interactive/_chat.css | 46 +++++++++++++++++++++ templates/partials/widgets/chat-widget.html | 17 ++++++++ 4 files changed, 81 insertions(+) diff --git a/internal/chat/agent.go b/internal/chat/agent.go index 8a900cc..e20b5e9 100644 --- a/internal/chat/agent.go +++ b/internal/chat/agent.go @@ -39,6 +39,15 @@ CORE RULES: - If the query_cv tool returns no results, say so honestly and suggest the visitor check a related section. - Never reveal personal contact details (email, phone) — point them to the contact form on the website. - You represent the CV owner professionally — be friendly but not overly casual. +- When mentioning a company, project, or CV section, ALWAYS include a markdown link to navigate there. + Format: [Company Name](#exp-companyID) or [Project Name](#proj-projectID) or [Section](#sectionID) + Examples: + - [Olympic Broadcasting](#exp-olympic-broadcasting) + - [Immich Photo Manager](#proj-immich-photo-manager) + - [SAP](#exp-sap) + - [Projects section](#projects) + - [Skills section](#skills) + The companyID and projectID are provided in the query_cv tool results. Always use them. QUERY STRATEGY BY QUESTION TYPE: diff --git a/internal/chat/handler.go b/internal/chat/handler.go index 67de3f6..9e3af8b 100644 --- a/internal/chat/handler.go +++ b/internal/chat/handler.go @@ -7,6 +7,7 @@ import ( "log" "net/http" "os" + "regexp" "strings" "time" @@ -270,6 +271,9 @@ 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_-]+)\)`) + // formatResponse converts basic markdown to HTML for the chat bubble. func formatResponse(text string) string { text = html.EscapeString(text) @@ -279,6 +283,11 @@ func formatResponse(text string) string { text = strings.Replace(text, "**", "", 1) } + // Links: [text](#anchor) → clickable navigation link + // After html.EscapeString, the parens and brackets are unchanged but # stays. + // The regex matches the escaped form since []()# are not escaped by html.EscapeString. + text = mdLinkRe.ReplaceAllString(text, `$1`) + lines := strings.Split(text, "\n") var result []string inList := false diff --git a/static/css/04-interactive/_chat.css b/static/css/04-interactive/_chat.css index 8492c82..d40a276 100644 --- a/static/css/04-interactive/_chat.css +++ b/static/css/04-interactive/_chat.css @@ -182,6 +182,33 @@ font-size: 0.75rem; } +/* ========================================================================== + Navigation Links in Chat Messages + ========================================================================== */ + +.chat-nav-link { + color: var(--accent-green, #27ae60); + text-decoration: none; + font-weight: 600; + cursor: pointer; + border-bottom: 1px dotted var(--accent-green, #27ae60); +} + +.chat-nav-link:hover { + color: #1e8c4c; + border-bottom-style: solid; +} + +/* Highlight animation when scrolled to from chat */ +.chat-highlight { + animation: chatHighlight 2s ease; +} + +@keyframes chatHighlight { + 0%, 100% { box-shadow: none; } + 20%, 80% { box-shadow: 0 0 0 3px var(--accent-green, #27ae60); border-radius: 4px; } +} + /* ========================================================================== Typing Indicator ========================================================================== */ @@ -532,3 +559,22 @@ .theme-clean .chat-typing-dot { background: #555555; } + +.theme-clean .chat-nav-link { + color: #2ecc71; + border-bottom-color: #2ecc71; +} + +.theme-clean .chat-nav-link:hover { + color: #27ae60; + border-bottom-color: #27ae60; +} + +.theme-clean .chat-highlight { + animation: chatHighlightDark 2s ease; +} + +@keyframes chatHighlightDark { + 0%, 100% { box-shadow: none; } + 20%, 80% { box-shadow: 0 0 0 3px #2ecc71; border-radius: 4px; } +} diff --git a/templates/partials/widgets/chat-widget.html b/templates/partials/widgets/chat-widget.html index 5b7771c..b8e5dc7 100644 --- a/templates/partials/widgets/chat-widget.html +++ b/templates/partials/widgets/chat-widget.html @@ -112,6 +112,23 @@ function closeChatHelpAndAsk(question) { sendChatQuestion(question); } +// Navigate from chat link to CV section, then highlight +function scrollToCV(link) { + var anchor = link.getAttribute('href'); + var target = document.querySelector(anchor); + if (target) { + // Close chat panel + document.getElementById('chat-panel').classList.remove('chat-open'); + document.getElementById('chat-toggle-btn').classList.remove('mascot-active'); + // Scroll to target + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + // Highlight briefly + target.classList.add('chat-highlight'); + setTimeout(function() { target.classList.remove('chat-highlight'); }, 2000); + } + return false; // prevent default anchor navigation +} + // Clear input after HTMX request completes document.addEventListener('htmx:afterRequest', function(event) { if (event.detail.elt && event.detail.elt.id === 'chat-form') {