Skip to content

Add Markdown rendering to AI Chat #13194

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 24 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e04f0de
feat: markdown in AI messages
Yubo-Cao May 30, 2025
ebcd900
docs: update CHANGELOG
Yubo-Cao May 30, 2025
ae2e2fa
Merge branch 'JabRef:main' into markdown-ai
Yubo-Cao May 30, 2025
2b2fa66
fix: note that Markdown is always valid, it never throw.
Yubo-Cao May 30, 2025
be60b42
Merge branch 'markdown-ai' of https://github.com/Yubo-Cao/jabref into…
Yubo-Cao May 30, 2025
3336504
chore: move Flexmark dependency up
Yubo-Cao May 31, 2025
17becfb
fix: remove null check for scnee
Yubo-Cao May 31, 2025
abd52c0
docs: remove trivial documentation
Yubo-Cao May 31, 2025
99a6c3d
Improve whitespace and MarkdownTextflow's coping experience
Jun 1, 2025
9e4ccfc
Merge remote-tracking branch 'upstream/main' into markdown-ai
Jun 2, 2025
9c31517
Use Deque and StringJoiner per code review.
Jun 2, 2025
4e2c604
Remove == null check and fix getScene()'s NPE
Jun 4, 2025
abdc1d3
Use @NonNull, rather than Objects.requireNonNull
Jun 4, 2025
22ae09c
Get rid of width binding.
Jun 6, 2025
d9eaa0b
Merge branch 'main' into markdown-ai
Yubo-Cao Jun 6, 2025
2017cc4
Merge branch 'main' into markdown-ai
subhramit Jun 28, 2025
51e63d3
Apply Subhramit's suggestions
Jun 28, 2025
8348525
Fix drag event's problem as ThiloteE suggests.
Jun 29, 2025
aed3c58
Add Link support to the Markdown rendering
Jun 29, 2025
8edce1c
Fix according to Trag
Jun 29, 2025
fda54f7
Fix according to Trag
Jun 29, 2025
699d3eb
Fix according to Trag
Jun 29, 2025
e3fa14f
Fix according to Ruslan
Jun 29, 2025
2eca1a3
Fix according to Trag
Jun 29, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv
- We added an "Open example library" button to Welcome Tab. [#13014](https://github.com/JabRef/jabref/issues/13014)
- We added automatic detection and selection of the identifier type (e.g., DOI, ISBN, arXiv) based on clipboard content when opening the "New Entry" dialog [#13111](https://github.com/JabRef/jabref/pull/13111)
- We added support for import of a Refer/BibIX file format. [#13069](https://github.com/JabRef/jabref/issues/13069)
- We added markdown rendering and copy-paste capabilities to AI chat responses. [#12234](https://github.com/JabRef/jabref/issues/12234)

### Changed

Expand Down
6 changes: 3 additions & 3 deletions jabgui/src/main/java/module-info.java
Original file line number Diff line number Diff line change
Expand Up @@ -129,10 +129,10 @@
// requires org.apache.xmpbox;
// requires com.ibm.icu;

// requires flexmark;
requires flexmark;
requires flexmark.html2md.converter;
// requires flexmark.util.ast;
// requires flexmark.util.data;
requires flexmark.util.data;
requires flexmark.util.ast;

// requires com.h2database.mvstore;

Expand Down
9 changes: 5 additions & 4 deletions jabgui/src/main/java/org/jabref/gui/JabRefGUI.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import org.jabref.gui.help.VersionWorker;
import org.jabref.gui.icon.IconTheme;
import org.jabref.gui.keyboard.KeyBindingRepository;
import org.jabref.gui.keyboard.SelectableTextFlowKeyBindings;
import org.jabref.gui.keyboard.TextInputKeyBindings;
import org.jabref.gui.openoffice.OOBibBaseConnect;
import org.jabref.gui.preferences.GuiPreferences;
Expand Down Expand Up @@ -256,10 +257,10 @@ private void openWindow() {
themeManager.installCss(scene);

LOGGER.debug("Handle TextEditor key bindings");
scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> TextInputKeyBindings.call(
scene,
event,
preferences.getKeyBindingRepository()));
scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> {
TextInputKeyBindings.call(scene, event, preferences.getKeyBindingRepository());
SelectableTextFlowKeyBindings.call(scene, event, preferences.getKeyBindingRepository());
});

mainStage.setTitle(JabRefFrame.FRAME_TITLE);
mainStage.getIcons().addAll(IconTheme.getLogoSetFX());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,24 @@

import java.util.function.Consumer;

import javafx.beans.binding.Bindings;
import javafx.beans.binding.NumberBinding;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.fxml.FXML;
import javafx.geometry.NodeOrientation;
import javafx.geometry.Pos;
import javafx.scene.control.Label;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Pane;
import javafx.scene.layout.Priority;
import javafx.scene.layout.VBox;

import org.jabref.gui.util.MarkdownTextFlow;
import org.jabref.logic.ai.util.ErrorMessage;
import org.jabref.logic.l10n.Localization;

import com.airhacks.afterburner.views.ViewLoader;
import com.dlsc.gemsfx.ExpandingTextArea;
import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.UserMessage;
Expand All @@ -30,15 +35,17 @@ public class ChatMessageComponent extends HBox {
@FXML private HBox wrapperHBox;
@FXML private VBox vBox;
@FXML private Label sourceLabel;
@FXML private ExpandingTextArea contentTextArea;
@FXML private Pane markdownContentPane;
@FXML private VBox buttonsVBox;

private MarkdownTextFlow markdownTextFlow;

public ChatMessageComponent() {
ViewLoader.view(this)
.root(this)
.load();

chatMessage.addListener((observable, oldValue, newValue) -> {
chatMessage.addListener((_, _, newValue) -> {
if (newValue != null) {
loadChatMessage();
}
Expand Down Expand Up @@ -68,32 +75,43 @@ private void loadChatMessage() {
case UserMessage userMessage -> {
setColor("-jr-ai-message-user", "-jr-ai-message-user-border");
setNodeOrientation(NodeOrientation.RIGHT_TO_LEFT);
wrapperHBox.setAlignment(Pos.TOP_RIGHT);
sourceLabel.setText(Localization.lang("User"));
contentTextArea.setText(userMessage.singleText());
markdownTextFlow.setMarkdown(userMessage.singleText());
}

case AiMessage aiMessage -> {
setColor("-jr-ai-message-ai", "-jr-ai-message-ai-border");
setNodeOrientation(NodeOrientation.LEFT_TO_RIGHT);
wrapperHBox.setAlignment(Pos.TOP_LEFT);
sourceLabel.setText(Localization.lang("AI"));
contentTextArea.setText(aiMessage.text());
markdownTextFlow.setMarkdown(aiMessage.text());
}

case ErrorMessage errorMessage -> {
setColor("-jr-ai-message-error", "-jr-ai-message-error-border");
setNodeOrientation(NodeOrientation.LEFT_TO_RIGHT);
sourceLabel.setText(Localization.lang("Error"));
contentTextArea.setText(errorMessage.getText());
markdownTextFlow.setMarkdown(errorMessage.getText());
}

default ->
LOGGER.error("ChatMessageComponent supports only user, AI, or error messages, but other type was passed: {}", chatMessage.get().type().name());
LOGGER.error("ChatMessageComponent supports only user, AI, or error messages, but other type was passed: {}", chatMessage.get().type().name());
}
}

@FXML
private void initialize() {
buttonsVBox.visibleProperty().bind(wrapperHBox.hoverProperty());
markdownTextFlow = new MarkdownTextFlow(markdownContentPane);
markdownContentPane.getChildren().add(markdownTextFlow);
markdownContentPane.minHeightProperty().bind(markdownTextFlow.heightProperty());
markdownContentPane.prefHeightProperty().bind(markdownTextFlow.heightProperty());
NumberBinding maxUsableWidth = Bindings.createDoubleBinding(() -> getScene().getWidth() - 20, sceneProperty(), getScene().widthProperty());
markdownTextFlow.maxWidthProperty().bind(maxUsableWidth);
wrapperHBox.maxWidthProperty().bind(maxUsableWidth);
setMaxWidth(Double.MAX_VALUE);
HBox.setHgrow(this, Priority.ALWAYS);
}

@FXML
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package org.jabref.gui.keyboard;

import javafx.scene.Scene;
import javafx.scene.input.KeyEvent;

import org.jabref.gui.util.SelectableTextFlow;

public class SelectableTextFlowKeyBindings {
public static void call(Scene scene, KeyEvent event, KeyBindingRepository keyBindingRepository) {
if (scene.focusOwnerProperty().get() instanceof SelectableTextFlow selectableTextFlow) {
keyBindingRepository.mapToKeyBinding(event).ifPresent(binding -> {
switch (binding) {
case COPY -> {
selectableTextFlow.copySelectedText();
event.consume();
}
case SELECT_ALL -> {
selectableTextFlow.selectAll();
event.consume();
}
}
});
}
}
}
176 changes: 176 additions & 0 deletions jabgui/src/main/java/org/jabref/gui/util/MarkdownTextFlow.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
package org.jabref.gui.util;

import javafx.geometry.NodeOrientation;
import javafx.scene.layout.Pane;
import javafx.scene.text.Text;

import com.vladsch.flexmark.ast.BulletList;
import com.vladsch.flexmark.ast.BulletListItem;
import com.vladsch.flexmark.ast.Code;
import com.vladsch.flexmark.ast.Emphasis;
import com.vladsch.flexmark.ast.Heading;
import com.vladsch.flexmark.ast.OrderedList;
import com.vladsch.flexmark.ast.OrderedListItem;
import com.vladsch.flexmark.ast.Paragraph;
import com.vladsch.flexmark.ast.StrongEmphasis;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.ast.Node;
import com.vladsch.flexmark.util.ast.NodeVisitor;
import com.vladsch.flexmark.util.ast.VisitHandler;
import com.vladsch.flexmark.util.data.MutableDataSet;

/**
* A TextFlow that renders Markdown text with support for headings, paragraphs, emphasis, strong emphasis, code blocks, bullet lists, and ordered lists.
*/
public class MarkdownTextFlow extends SelectableTextFlow {
private final Parser parser;
private boolean needsLineBreak = false;
private int currentOrderedListIndex = 0;

/**
* Creates a MarkdownTextFlow instance.
*
* @param parent The parent Pane to which this TextFlow will be added.
*/
public MarkdownTextFlow(Pane parent) {
super(parent);
this.setNodeOrientation(NodeOrientation.LEFT_TO_RIGHT);
this.getStyleClass().add("markdown-textflow");

MutableDataSet options = new MutableDataSet();
this.parser = Parser.builder(options).build();
}

/**
* Sets the Markdown text to be rendered in this TextFlow.
*
* @param markdownText The Markdown text to render. If null or empty, the TextFlow will be cleared.
*/
public void setMarkdown(String markdownText) {
this.clearSelection();
needsLineBreak = false;

if (markdownText == null || markdownText.trim().isEmpty()) {
return;
}

Node document = parser.parse(markdownText);
MarkdownRenderer renderer = new MarkdownRenderer();
renderer.visit(document);
}

private void addLineBreak() {
if (needsLineBreak && !this.getChildren().isEmpty()) {
Text lineBreak = new Text("\n");
this.getChildren().add(lineBreak);
}
needsLineBreak = false;
}

private class MarkdownRenderer {
private final NodeVisitor visitor = new NodeVisitor(
new VisitHandler<>(Heading.class, this::visit),
new VisitHandler<>(Paragraph.class, this::visit),
new VisitHandler<>(com.vladsch.flexmark.ast.Text.class, this::visit),
new VisitHandler<>(Emphasis.class, this::visit),
new VisitHandler<>(StrongEmphasis.class, this::visit),
new VisitHandler<>(Code.class, this::visit),
new VisitHandler<>(BulletList.class, this::visit),
new VisitHandler<>(OrderedList.class, this::visit),
new VisitHandler<>(BulletListItem.class, this::visit),
new VisitHandler<>(OrderedListItem.class, this::visit)
);

public void visit(Node node) {
visitor.visit(node);
}

private void visit(Heading heading) {
addLineBreak();

String content = heading.getText().toString();
int level = heading.getLevel();

Text headingNode = new Text(content);
headingNode.getStyleClass().add("markdown-h" + level);

getChildren().add(headingNode);
needsLineBreak = true;
}

private void visit(Paragraph paragraph) {
addLineBreak();

Node parent = paragraph.getParent();
boolean isInList = parent instanceof BulletListItem || parent instanceof OrderedListItem;

if (!isInList && !getChildren().isEmpty()) {
addLineBreak();
}

visitor.visitChildren(paragraph);
needsLineBreak = true;
}

private void visit(com.vladsch.flexmark.ast.Text text) {
String content = text.getChars().toString();
Text textNode = new Text(content);
getChildren().add(textNode);
}

private void visit(Emphasis emphasis) {
String content = emphasis.getText().toString();
Text italicText = new Text(content);
italicText.getStyleClass().add("markdown-italic");
getChildren().add(italicText);
}

private void visit(StrongEmphasis strongEmphasis) {
String content = strongEmphasis.getText().toString();
Text boldText = new Text(content);
boldText.getStyleClass().add("markdown-bold");
getChildren().add(boldText);
}

private void visit(Code code) {
String content = code.getText().toString();
Text codeText = new Text(content);
codeText.getStyleClass().add("markdown-code");
getChildren().add(codeText);
}

private void visit(BulletList bulletList) {
addLineBreak();
visitor.visitChildren(bulletList);
needsLineBreak = true;
}

private void visit(OrderedList orderedList) {
addLineBreak();
currentOrderedListIndex = 0;
visitor.visitChildren(orderedList);
needsLineBreak = true;
}

private void visit(BulletListItem listItem) {
addLineBreak();

Text bulletText = new Text("• ");
bulletText.getStyleClass().add("markdown-list-bullet");
getChildren().add(bulletText);

visitor.visitChildren(listItem);
}

private void visit(OrderedListItem listItem) {
addLineBreak();
currentOrderedListIndex++;

Text numberText = new Text(currentOrderedListIndex + ". ");
numberText.getStyleClass().add("markdown-list-number");
getChildren().add(numberText);

visitor.visitChildren(listItem);
}
}
}
Loading