diff --git a/layouts/partials/chatbot.html b/layouts/partials/chatbot.html index 4ccb09be895..03e4be19751 100644 --- a/layouts/partials/chatbot.html +++ b/layouts/partials/chatbot.html @@ -309,13 +309,58 @@ text-decoration: underline; } - /* Inline citation links rendered as plain text */ + /* Inline citation links open the supporting retrieved chunk */ .just-os-msg.bot .just-os-bubble-content a.reference-link { - color: inherit; + color: #0d3c74; text-decoration: none; - pointer-events: none; - cursor: default; + cursor: pointer; font-size: 0.85em; + font-weight: 600; + } + + .just-os-msg.bot .just-os-bubble-content a.reference-link:hover { + text-decoration: underline; + } + + .just-os-reference-tooltip { + position: fixed; + width: min(500px, calc(100vw - 24px)); + max-height: min(420px, calc(100vh - 24px)); + overflow-y: auto; + padding: 14px; + background: white; + border: 1px solid #d9dde3; + border-radius: 8px; + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.18); + color: #1a1a1a; + font-size: 0.85rem; + line-height: 1.45; + z-index: 10001; + } + + .just-os-reference-tooltip .title { + font-weight: 700; + margin-bottom: 4px; + } + + .just-os-reference-tooltip .metadata { + color: #666; + font-size: 0.8rem; + margin-bottom: 10px; + } + + .just-os-reference-tooltip .content { + padding: 10px; + background: #f8f9fb; + border-radius: 6px; + white-space: pre-wrap; + } + + .just-os-reference-tooltip .source-link { + display: inline-block; + margin-top: 10px; + color: #0d3c74; + font-weight: 600; } .just-os-copy-btn { diff --git a/static/js/just-os-chat.js b/static/js/just-os-chat.js index 4a996b09f1b..78af079b54b 100644 --- a/static/js/just-os-chat.js +++ b/static/js/just-os-chat.js @@ -142,11 +142,12 @@ bubble.innerHTML = msg.content; wrapper.appendChild(bubble); - // Capture the answer text before references are merged into the box. + // Renumber citations by source before capturing the answer for copying. + const refs = extractReferences(bubble); + setupCitationLinks(bubble); const answerText = bubble.innerText.trim(); // Merge references into the answer box as a collapsible list. - const refs = extractReferences(bubble); if (refs.length) bubble.appendChild(buildReferenceDetails(refs)); // Copy button below the box — copies the answer together with its references. @@ -520,17 +521,105 @@ /* References */ /* -------------------------------------------------- */ - // Pull the (deduplicated) references embedded in a bot message. + function setupCitationLinks(bubble) { + bubble.querySelectorAll('a[data-reference]').forEach(function (link) { + link.addEventListener('click', function (event) { + event.preventDefault(); + event.stopPropagation(); + + document.querySelectorAll('.just-os-reference-tooltip').forEach(function (tooltip) { + tooltip.remove(); + }); + + let ref; + try { + ref = JSON.parse(link.getAttribute('data-reference')); + } catch (_) { + return; + } + + const tooltip = document.createElement('div'); + tooltip.className = 'just-os-reference-tooltip'; + + const title = document.createElement('div'); + title.className = 'title'; + title.textContent = ref.title || 'Unknown title'; + tooltip.appendChild(title); + + const metadata = document.createElement('div'); + metadata.className = 'metadata'; + metadata.textContent = [ref.authors, ref.year ? '(' + ref.year + ')' : ''] + .filter(Boolean) + .join(' '); + tooltip.appendChild(metadata); + + const content = document.createElement('div'); + content.className = 'content'; + const decodedText = document.createElement('textarea'); + decodedText.innerHTML = ref.text || 'No supporting chunk available.'; + content.textContent = decodedText.value; + tooltip.appendChild(content); + + if (ref.url && ref.url !== '#') { + const sourceLink = document.createElement('a'); + sourceLink.className = 'source-link'; + sourceLink.href = ref.url; + sourceLink.target = '_blank'; + sourceLink.rel = 'noopener'; + sourceLink.textContent = 'View source'; + tooltip.appendChild(sourceLink); + } + + document.body.appendChild(tooltip); + + const linkRect = link.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const left = Math.max( + 12, + Math.min(linkRect.left, window.innerWidth - tooltipRect.width - 12) + ); + const spaceBelow = window.innerHeight - linkRect.bottom; + const top = spaceBelow >= tooltipRect.height + 12 + ? linkRect.bottom + 6 + : Math.max(12, linkRect.top - tooltipRect.height - 6); + + tooltip.style.left = left + 'px'; + tooltip.style.top = top + 'px'; + + function closeTooltip(closeEvent) { + if (!tooltip.contains(closeEvent.target) && closeEvent.target !== link) { + tooltip.remove(); + document.removeEventListener('click', closeTooltip); + } + } + + setTimeout(function () { + document.addEventListener('click', closeTooltip); + }, 0); + }); + }); + } + + // Pull the references embedded in a bot message and renumber citations by source. function extractReferences(bubble) { const links = bubble.querySelectorAll('a[data-reference]'); - const seen = new Set(); + const sourceNumbers = new Map(); const refs = []; links.forEach(function (link) { try { const ref = JSON.parse(link.getAttribute('data-reference')); - if (!ref || !ref.url || seen.has(ref.url)) return; - seen.add(ref.url); - refs.push(ref); + if (!ref) return; + + const sourceKey = ref.url && ref.url !== '#' + ? ref.url + : [ref.authors, ref.year, ref.title].join('|'); + + if (!sourceNumbers.has(sourceKey)) { + refs.push(ref); + sourceNumbers.set(sourceKey, refs.length); + } + + link.textContent = '[' + sourceNumbers.get(sourceKey) + ']'; } catch (_) { /* skip bad JSON */ } }); return refs;