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;