Skip to content

Commit 0e2eebd

Browse files
committed
feat(demo): add clickable source references with PDF page navigation
- Extract page references from retrieved content metadata in RAGService - Add Reference record to store page, type, and preview text - Update ChatMessage to include list of references - MessageBubble displays clickable "Sources: p.1 p.2..." links - Clicking a page link navigates PDF viewer to that page - Hover tooltips show content type and preview - Add CSS styles for reference pill-shaped badges
1 parent c807672 commit 0e2eebd

File tree

6 files changed

+171
-10
lines changed

6 files changed

+171
-10
lines changed

demos/rag-multimodal/src/main/java/com/redis/vl/demo/rag/model/ChatMessage.java

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
package com.redis.vl.demo.rag.model;
22

33
import java.time.Instant;
4+
import java.util.List;
45

56
/**
6-
* Represents a chat message with cost tracking.
7+
* Represents a chat message with cost tracking and source references.
78
*
89
* @param id Unique message identifier
910
* @param role Message role (USER or ASSISTANT)
@@ -13,6 +14,7 @@
1314
* @param costUsd Cost in USD for this message (for AI responses)
1415
* @param model LLM model used (for AI responses)
1516
* @param fromCache Whether the response came from cache
17+
* @param references Source references from retrieved content
1618
*/
1719
public record ChatMessage(
1820
String id,
@@ -22,7 +24,8 @@ public record ChatMessage(
2224
int tokenCount,
2325
double costUsd,
2426
String model,
25-
boolean fromCache) {
27+
boolean fromCache,
28+
List<Reference> references) {
2629

2730
public enum Role {
2831
USER,
@@ -37,7 +40,7 @@ public enum Role {
3740
*/
3841
public static ChatMessage user(String content) {
3942
return new ChatMessage(
40-
generateId(), Role.USER, content, Instant.now(), 0, 0.0, null, false);
43+
generateId(), Role.USER, content, Instant.now(), 0, 0.0, null, false, List.of());
4144
}
4245

4346
/**
@@ -53,7 +56,24 @@ public static ChatMessage user(String content) {
5356
public static ChatMessage assistant(
5457
String content, int tokenCount, double costUsd, String model, boolean fromCache) {
5558
return new ChatMessage(
56-
generateId(), Role.ASSISTANT, content, Instant.now(), tokenCount, costUsd, model, fromCache);
59+
generateId(), Role.ASSISTANT, content, Instant.now(), tokenCount, costUsd, model, fromCache, List.of());
60+
}
61+
62+
/**
63+
* Creates an assistant message with references.
64+
*
65+
* @param content Message content
66+
* @param tokenCount Token count
67+
* @param costUsd Cost in USD
68+
* @param model Model name
69+
* @param fromCache Whether from cache
70+
* @param references Source references
71+
* @return Assistant chat message with references
72+
*/
73+
public static ChatMessage assistant(
74+
String content, int tokenCount, double costUsd, String model, boolean fromCache, List<Reference> references) {
75+
return new ChatMessage(
76+
generateId(), Role.ASSISTANT, content, Instant.now(), tokenCount, costUsd, model, fromCache, references);
5777
}
5878

5979
private static String generateId() {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.redis.vl.demo.rag.model;
2+
3+
/**
4+
* A reference to a source location in the PDF document.
5+
*
6+
* @param page Page number (1-indexed)
7+
* @param type Content type (TEXT or IMAGE)
8+
* @param preview Short preview of the content
9+
*/
10+
public record Reference(int page, String type, String preview) {
11+
12+
/**
13+
* Creates a reference with a truncated preview.
14+
*
15+
* @param page Page number
16+
* @param type Content type
17+
* @param content Full content text
18+
* @param maxLength Maximum preview length
19+
* @return Reference with truncated preview
20+
*/
21+
public static Reference of(int page, String type, String content, int maxLength) {
22+
String preview = content;
23+
if (preview != null && preview.length() > maxLength) {
24+
preview = preview.substring(0, maxLength) + "...";
25+
}
26+
return new Reference(page, type, preview);
27+
}
28+
}

demos/rag-multimodal/src/main/java/com/redis/vl/demo/rag/service/RAGService.java

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.redis.vl.demo.rag.model.CacheType;
44
import com.redis.vl.demo.rag.model.ChatMessage;
55
import com.redis.vl.demo.rag.model.LLMConfig;
6+
import com.redis.vl.demo.rag.model.Reference;
67
import com.redis.vl.extensions.cache.CacheHit;
78
import com.redis.vl.extensions.cache.LangCacheSemanticCache;
89
import com.redis.vl.extensions.cache.SemanticCache;
@@ -145,13 +146,22 @@ public ChatMessage query(String userQuery, CacheType cacheType) {
145146
System.out.println(" [" + i + "] " + preview);
146147
}
147148

148-
// 2. Separate text and image content
149+
// 2. Separate text and image content + extract references
149150
List<Content> textContent = new ArrayList<>();
150151
List<Content> imageContent = new ArrayList<>();
152+
List<Reference> references = new ArrayList<>();
151153

152154
for (Content content : retrievedContent) {
153155
if (content.textSegment() != null && content.textSegment().metadata() != null) {
154-
String type = content.textSegment().metadata().getString("type");
156+
var metadata = content.textSegment().metadata();
157+
String type = metadata.getString("type");
158+
159+
// Extract reference info
160+
Integer page = metadata.getInteger("page");
161+
if (page != null) {
162+
references.add(Reference.of(page, type != null ? type : "TEXT", content.textSegment().text(), 80));
163+
}
164+
155165
if ("IMAGE".equals(type)) {
156166
imageContent.add(content);
157167
} else {
@@ -162,6 +172,16 @@ public ChatMessage query(String userQuery, CacheType cacheType) {
162172
}
163173
}
164174

175+
// Deduplicate references by page (keep first occurrence of each page)
176+
List<Reference> uniqueRefs = new ArrayList<>();
177+
java.util.Set<Integer> seenPages = new java.util.HashSet<>();
178+
for (Reference ref : references) {
179+
if (seenPages.add(ref.page())) {
180+
uniqueRefs.add(ref);
181+
}
182+
}
183+
references = uniqueRefs;
184+
165185
// 3. Build multimodal prompt with context and images
166186
String contextText = buildContext(textContent);
167187
List<dev.langchain4j.data.message.ChatMessage> messages = new ArrayList<>();
@@ -241,7 +261,7 @@ public ChatMessage query(String userQuery, CacheType cacheType) {
241261
int tokenCount = costTracker.countTokens(responseText);
242262
double cost = costTracker.calculateCost(config.provider(), config.model(), tokenCount);
243263

244-
return ChatMessage.assistant(responseText, tokenCount, cost, config.model(), false);
264+
return ChatMessage.assistant(responseText, tokenCount, cost, config.model(), false, references);
245265
}
246266

247267
/**

demos/rag-multimodal/src/main/java/com/redis/vl/demo/rag/ui/ChatController.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,8 @@ protected void updateItem(ChatMessage item, boolean empty) {
6767
if (empty || item == null) {
6868
setGraphic(null);
6969
} else {
70-
setGraphic(new MessageBubble(item));
70+
// Pass page navigator to enable click-to-page references
71+
setGraphic(new MessageBubble(item, pdfViewer::goToPage));
7172
}
7273
}
7374
});
@@ -323,7 +324,8 @@ private void addSystemMessage(String content) {
323324
0,
324325
0.0,
325326
"System",
326-
false);
327+
false,
328+
java.util.List.of());
327329
messages.add(systemMessage);
328330
scrollToBottom();
329331
}

demos/rag-multimodal/src/main/java/com/redis/vl/demo/rag/ui/MessageBubble.java

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
package com.redis.vl.demo.rag.ui;
22

33
import com.redis.vl.demo.rag.model.ChatMessage;
4+
import com.redis.vl.demo.rag.model.Reference;
45
import com.redis.vl.demo.rag.service.JTokKitCostTracker;
6+
import java.util.function.Consumer;
57
import javafx.geometry.Insets;
68
import javafx.geometry.Pos;
9+
import javafx.scene.control.Hyperlink;
710
import javafx.scene.control.Label;
11+
import javafx.scene.control.Tooltip;
12+
import javafx.scene.layout.FlowPane;
813
import javafx.scene.layout.HBox;
914
import javafx.scene.layout.VBox;
1015
import javafx.scene.text.Text;
@@ -18,6 +23,7 @@
1823
public class MessageBubble extends HBox {
1924

2025
private final ChatMessage message;
26+
private final Consumer<Integer> pageNavigator;
2127

2228
/**
2329
* Creates a new message bubble.
@@ -26,7 +32,19 @@ public class MessageBubble extends HBox {
2632
*/
2733
@SuppressWarnings("this-escape")
2834
public MessageBubble(ChatMessage message) {
35+
this(message, null);
36+
}
37+
38+
/**
39+
* Creates a new message bubble with page navigation support.
40+
*
41+
* @param message Chat message to display
42+
* @param pageNavigator Callback for navigating to a page (receives 0-based page number)
43+
*/
44+
@SuppressWarnings("this-escape")
45+
public MessageBubble(ChatMessage message, Consumer<Integer> pageNavigator) {
2946
this.message = message;
47+
this.pageNavigator = pageNavigator;
3048
build();
3149
}
3250

@@ -57,7 +75,7 @@ private void build() {
5775
message.model());
5876

5977
if (message.fromCache()) {
60-
costText += " • From Cache";
78+
costText += " • From Cache";
6179
costLabel.getStyleClass().add("cached");
6280
}
6381

@@ -66,6 +84,41 @@ private void build() {
6684
contentBox.getChildren().add(costLabel);
6785
}
6886

87+
// Add references section if available
88+
if (message.references() != null && !message.references().isEmpty()) {
89+
FlowPane refsPane = new FlowPane();
90+
refsPane.setHgap(5);
91+
refsPane.setVgap(3);
92+
refsPane.getStyleClass().add("references-pane");
93+
94+
Label refsLabel = new Label("Sources:");
95+
refsLabel.getStyleClass().add("refs-label");
96+
refsPane.getChildren().add(refsLabel);
97+
98+
for (Reference ref : message.references()) {
99+
Hyperlink pageLink = new Hyperlink("p." + ref.page());
100+
pageLink.getStyleClass().add("page-link");
101+
102+
// Set tooltip with preview
103+
String tooltipText = ref.type() + ": " + ref.preview();
104+
Tooltip tooltip = new Tooltip(tooltipText);
105+
tooltip.setWrapText(true);
106+
tooltip.setMaxWidth(300);
107+
pageLink.setTooltip(tooltip);
108+
109+
// Navigate to page on click (convert 1-indexed to 0-indexed)
110+
if (pageNavigator != null) {
111+
pageLink.setOnAction(e -> pageNavigator.accept(ref.page() - 1));
112+
} else {
113+
pageLink.setDisable(true);
114+
}
115+
116+
refsPane.getChildren().add(pageLink);
117+
}
118+
119+
contentBox.getChildren().add(refsPane);
120+
}
121+
69122
// Style the bubble based on role
70123
contentBox.getStyleClass().add("message-bubble");
71124
if (message.role() == ChatMessage.Role.USER) {

demos/rag-multimodal/src/main/resources/styles/app.css

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,3 +370,41 @@
370370
-fx-background-color: #E7F3FF;
371371
-fx-text-fill: #1877F2;
372372
}
373+
374+
/* References Section in Message Bubbles */
375+
.references-pane {
376+
-fx-padding: 8px 0 0 0;
377+
-fx-border-color: #E4E6EB;
378+
-fx-border-width: 1px 0 0 0;
379+
-fx-border-insets: 8px 0 0 0;
380+
}
381+
382+
.refs-label {
383+
-fx-font-size: 11px;
384+
-fx-text-fill: #65676B;
385+
-fx-font-weight: 600;
386+
-fx-padding: 2px 4px;
387+
}
388+
389+
.page-link {
390+
-fx-font-size: 11px;
391+
-fx-text-fill: #1877F2;
392+
-fx-padding: 2px 6px;
393+
-fx-background-color: #E7F3FF;
394+
-fx-background-radius: 10px;
395+
-fx-border-color: transparent;
396+
}
397+
398+
.page-link:hover {
399+
-fx-background-color: #CCE4FF;
400+
-fx-underline: true;
401+
}
402+
403+
.page-link:pressed {
404+
-fx-background-color: #B3D7FF;
405+
}
406+
407+
.page-link:disabled {
408+
-fx-text-fill: #BCC0C4;
409+
-fx-background-color: #F0F2F5;
410+
}

0 commit comments

Comments
 (0)