From e440d52d1a755af17f41dfc281471891b5c82fab Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Wed, 16 Sep 2026 11:27:30 +0200 Subject: [PATCH 1/2] Add "Ask AI questions..." batch action for selected entries Asks a list of questions, separated by lines containing only ---, to every selected entry, one entry and one question at a time, as a background task. Each question is sent without earlier answers so small models fit; answers land in each entry's AI chat. Sections in the editor get alternating backgrounds. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DHBYKrUG4Lpf2f8waCg19c --- .../jabref/gui/actions/StandardActions.java | 1 + .../org/jabref/gui/ai/AskQuestionsAction.java | 238 ++++++++++++++++++ .../jabref/gui/maintable/RightClickMenu.java | 4 + .../main/resources/l10n/JabRef_en.properties | 5 + 4 files changed, 248 insertions(+) create mode 100644 jabgui/src/main/java/org/jabref/gui/ai/AskQuestionsAction.java diff --git a/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java b/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java index 555d4ec6ab27..dd6169cc9f7f 100644 --- a/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java +++ b/jabgui/src/main/java/org/jabref/gui/actions/StandardActions.java @@ -233,6 +233,7 @@ public enum StandardActions implements Action { GROUP_ENTRIES_CLEAR(Localization.lang("Clear group")), CLEAR_EMBEDDINGS_CACHE(Localization.lang("Clear embeddings cache")), + AI_ASK_QUESTIONS(Localization.lang("Ask AI questions...")), GIT(Localization.lang("Git"), IconTheme.JabRefIcons.GIT_SYNC), GIT_PULL(Localization.lang("Pull")), diff --git a/jabgui/src/main/java/org/jabref/gui/ai/AskQuestionsAction.java b/jabgui/src/main/java/org/jabref/gui/ai/AskQuestionsAction.java new file mode 100644 index 000000000000..f38ef8585d79 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/AskQuestionsAction.java @@ -0,0 +1,238 @@ +package org.jabref.gui.ai; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; +import java.util.regex.Pattern; + +import javafx.application.Platform; +import javafx.collections.ObservableList; +import javafx.scene.control.ButtonType; +import javafx.scene.paint.Color; + +import org.jabref.gui.DialogService; +import org.jabref.gui.StateManager; +import org.jabref.gui.actions.SimpleCommand; +import org.jabref.gui.util.BaseDialog; +import org.jabref.gui.util.UiTaskExecutor; +import org.jabref.logic.FilePreferences; +import org.jabref.logic.ai.AiService; +import org.jabref.logic.ai.chatting.ChatModel; +import org.jabref.logic.ai.chatting.tasks.GenerateRagResponseTask; +import org.jabref.logic.ai.ingestion.tasks.generateembeddings.GenerateEmbeddingsTaskRequest; +import org.jabref.logic.ai.preferences.AiPreferences; +import org.jabref.logic.ai.rag.logic.ResponseEngine; +import org.jabref.logic.ai.rag.util.ResponseEngineFactory; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.BackgroundTask; +import org.jabref.logic.util.TaskExecutor; +import org.jabref.model.ai.chatting.ChatMessage; +import org.jabref.model.ai.identifiers.FullBibEntry; +import org.jabref.model.ai.pipeline.ResponseEngineKind; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.LinkedFile; + +import jfx.incubator.scene.control.richtext.RichTextArea; +import jfx.incubator.scene.control.richtext.TextPos; +import jfx.incubator.scene.control.richtext.model.StyleAttributeMap; +import org.jspecify.annotations.NullMarked; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static org.jabref.gui.actions.ActionHelper.needsEntriesSelected; + +/// Asks a fixed list of questions to each selected entry, one entry after the other, one question after the other. +/// Questions and answers are appended to the entry's AI chat, so they show up in the entry editor's AI chat tab. +/// +/// Each question is sent without the previous questions and answers in the context, so the prompt stays small +/// (system message + retrieved excerpts + question) and works with small models. +@NullMarked +public class AskQuestionsAction extends SimpleCommand { + private static final Logger LOGGER = LoggerFactory.getLogger(AskQuestionsAction.class); + private static final Pattern QUESTION_SEPARATOR = Pattern.compile("(?m)^\\s*---\\s*$"); + private static final Pattern SEPARATOR_LINE = Pattern.compile("\\s*---\\s*"); + private static final StyleAttributeMap EVEN_SECTION = StyleAttributeMap.builder().setBackground(Color.TRANSPARENT).build(); + private static final StyleAttributeMap ODD_SECTION = StyleAttributeMap.builder().setBackground(Color.gray(0.5, 0.15)).build(); + + // ponytail: remembered per JabRef run only, add a preference if this survives the experiment + private static String lastQuestions = ""; + + private final StateManager stateManager; + private final DialogService dialogService; + private final AiService aiService; + private final AiPreferences aiPreferences; + private final FilePreferences filePreferences; + private final TaskExecutor taskExecutor; + + public AskQuestionsAction(StateManager stateManager, + DialogService dialogService, + AiService aiService, + AiPreferences aiPreferences, + FilePreferences filePreferences, + TaskExecutor taskExecutor) { + this.stateManager = stateManager; + this.dialogService = dialogService; + this.aiService = aiService; + this.aiPreferences = aiPreferences; + this.filePreferences = filePreferences; + this.taskExecutor = taskExecutor; + this.executable.bind(needsEntriesSelected(stateManager)); + } + + @Override + public void execute() { + Optional context = stateManager.getActiveDatabase(); + if (context.isEmpty() || stateManager.getSelectedEntries().isEmpty()) { + return; + } + if (!aiPreferences.getAiFeaturesEnabled()) { + dialogService.notify(Localization.lang("AI features are disabled. Enable them in the preferences.")); + return; + } + + List entries = new ArrayList<>(stateManager.getSelectedEntries()); + List questions = askForQuestions(); + if (questions.isEmpty()) { + return; + } + + BackgroundTask task = new BackgroundTask<>() { + @Override + public Void call() { + askAll(this, context.get(), entries, questions); + return null; + } + }; + task.showToUser(true); + task.titleProperty().set(Localization.lang("Asking AI questions")); + task.onFailure(ex -> LOGGER.error("Asking AI questions failed", ex)); + task.executeWith(taskExecutor); + } + + private List askForQuestions() { + BaseDialog dialog = new BaseDialog<>() { + }; + dialog.setTitle(Localization.lang("Ask AI questions")); + dialog.setHeaderText(Localization.lang("Each question is asked to every selected entry. Separate questions with a line containing only ---")); + RichTextArea textArea = new RichTextArea(); + textArea.setWrapText(true); + textArea.setPrefSize(600, 300); + textArea.appendText(lastQuestions); + textArea.getModel().addListener(change -> { + if (change.isEdit()) { + // Restyling inside the change callback would modify the model while it is being edited + Platform.runLater(() -> colorSections(textArea)); + } + }); + colorSections(textArea); + dialog.getDialogPane().setContent(textArea); + dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL); + dialog.setResultConverter(button -> button == ButtonType.OK ? plainText(textArea) : null); + + Optional text = dialogService.showCustomDialogAndWait(dialog); + if (text.isEmpty()) { + return List.of(); + } + lastQuestions = text.get(); + return QUESTION_SEPARATOR.splitAsStream(text.get()) + .map(String::strip) + .filter(question -> !question.isEmpty()) + .toList(); + } + + /// Alternates the paragraph background per `---` section, like the rows of the main table. + private static void colorSections(RichTextArea textArea) { + boolean odd = false; + for (int i = 0; i < textArea.getParagraphCount(); i++) { + if (SEPARATOR_LINE.matcher(textArea.getModel().getPlainText(i)).matches()) { + odd = !odd; + } + textArea.applyStyle(TextPos.ofLeading(i, 0), textArea.getParagraphEnd(i), odd ? ODD_SECTION : EVEN_SECTION); + } + } + + private static String plainText(RichTextArea textArea) { + StringBuilder text = new StringBuilder(); + for (int i = 0; i < textArea.getParagraphCount(); i++) { + text.append(textArea.getModel().getPlainText(i)).append('\n'); + } + return text.toString(); + } + + private void askAll(BackgroundTask task, BibDatabaseContext context, List entries, List questions) { + ChatModel chatModel = aiService.getCurrentChatModel(); + ResponseEngine responseEngine = ResponseEngineFactory.create( + aiPreferences, + filePreferences, + aiService.getCurrentEmbeddingModel(), + aiService.getEmbeddingsStore()); + + int total = entries.size() * questions.size(); + int done = 0; + task.updateProgress(done, total); + + for (BibEntry entry : entries) { + if (task.isCancelled()) { + return; + } + String entryName = entry.getCitationKey().orElse(entry.getId()); + FullBibEntry fullEntry = new FullBibEntry(context, entry); + ObservableList chatHistory = aiService.getChatHistoryCache().getForEntry(context, entry); + + if (responseEngine.getKind() == ResponseEngineKind.EMBEDDINGS_SEARCH) { + task.updateMessage(Localization.lang("Generating embeddings for %0", entryName)); + ingestFiles(context, entry); + } + + for (String question : questions) { + if (task.isCancelled()) { + return; + } + task.updateMessage(entryName + ": " + question); + + ChatMessage userMessage = ChatMessage.userMessage(question); + ChatMessage answer; + try { + answer = new GenerateRagResponseTask( + chatModel, + responseEngine, + List.of(userMessage), + List.of(fullEntry), + aiPreferences.getChattingSystemMessageTemplate(), + aiPreferences.getChattingUserMessageTemplate() + ).call(); + } catch (Exception e) { + LOGGER.warn("Question '{}' failed for entry {}", question, entryName, e); + answer = ChatMessage.errorMessage(e); + } + ChatMessage finalAnswer = answer; + UiTaskExecutor.runInJavaFXThread(() -> chatHistory.addAll(userMessage, finalAnswer)); + + task.updateProgress(++done, total); + } + } + } + + private void ingestFiles(BibDatabaseContext context, BibEntry entry) { + for (LinkedFile linkedFile : entry.getFiles()) { + try { + aiService.getIngestionTaskAggregator().startWithFuture(new GenerateEmbeddingsTaskRequest( + filePreferences, + aiService.getIngestedDocumentsRepository(), + aiService.getEmbeddingsStore(), + aiService.getCurrentEmbeddingModel(), + aiService.getCurrentDocumentSplitter(), + context, + linkedFile + ), false).getKey().get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } catch (ExecutionException e) { + LOGGER.warn("Embedding generation failed for {}", linkedFile.getLink(), e); + } + } + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/maintable/RightClickMenu.java b/jabgui/src/main/java/org/jabref/gui/maintable/RightClickMenu.java index 325594e0bf38..211be553cd42 100644 --- a/jabgui/src/main/java/org/jabref/gui/maintable/RightClickMenu.java +++ b/jabgui/src/main/java/org/jabref/gui/maintable/RightClickMenu.java @@ -11,6 +11,7 @@ import org.jabref.gui.actions.ActionFactory; import org.jabref.gui.actions.ActionHelper; import org.jabref.gui.actions.StandardActions; +import org.jabref.gui.ai.AskQuestionsAction; import org.jabref.gui.clipboard.ClipBoardManager; import org.jabref.gui.edit.CopyMoreAction; import org.jabref.gui.edit.CopyTo; @@ -32,6 +33,7 @@ import org.jabref.gui.relatedwork.RelatedWorkAction; import org.jabref.gui.specialfields.SpecialFieldMenuItemFactory; import org.jabref.gui.util.UiTaskExecutor; +import org.jabref.logic.ai.AiService; import org.jabref.logic.citationstyle.CitationStyleOutputFormat; import org.jabref.logic.importer.fetcher.CrossRef; import org.jabref.logic.journals.JournalAbbreviationRepository; @@ -44,6 +46,7 @@ import org.jabref.model.entry.BibEntryTypesManager; import org.jabref.model.entry.field.SpecialField; +import com.airhacks.afterburner.injection.Injector; import com.tobiasdiez.easybind.EasyBind; public class RightClickMenu { @@ -92,6 +95,7 @@ public static ContextMenu create(LibraryTab libraryTab, extractFileReferencesOnline, extractFileReferencesOffline, factory.createMenuItem(StandardActions.EXTRACT_RELATED_WORK_COMMENTS, new RelatedWorkAction(dialogService, stateManager, preferences)), + factory.createMenuItem(StandardActions.AI_ASK_QUESTIONS, new AskQuestionsAction(stateManager, dialogService, Injector.instantiateModelOrService(AiService.class), preferences.getAiPreferences(), preferences.getFilePreferences(), taskExecutor)), factory.createMenuItem(StandardActions.OPEN_URL, new OpenUrlAction(dialogService, stateManager, preferences)), diff --git a/jablib/src/main/resources/l10n/JabRef_en.properties b/jablib/src/main/resources/l10n/JabRef_en.properties index 3a765f49dfbb..9f8abe474a6b 100644 --- a/jablib/src/main/resources/l10n/JabRef_en.properties +++ b/jablib/src/main/resources/l10n/JabRef_en.properties @@ -3723,3 +3723,8 @@ Move\ group=Move group Sort\ subgroups=Sort subgroups Change\ string\ constants=Change string constants Founded\ by\ Morten\ O.\ Alver\ and\ Nizar\ Batada.=Founded by Morten O. Alver and Nizar Batada. +Ask\ AI\ questions...=Ask AI questions... +Ask\ AI\ questions=Ask AI questions +Asking\ AI\ questions=Asking AI questions +Each\ question\ is\ asked\ to\ every\ selected\ entry.\ Separate\ questions\ with\ a\ line\ containing\ only\ ---=Each question is asked to every selected entry. Separate questions with a line containing only --- +AI\ features\ are\ disabled.\ Enable\ them\ in\ the\ preferences.=AI features are disabled. Enable them in the preferences. From 532caf854289d5a78fce360dda58c8b1407ae7b7 Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Wed, 16 Sep 2026 11:42:38 +0200 Subject: [PATCH 2/2] Use unnamed variable for ignored InterruptedException Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01DHBYKrUG4Lpf2f8waCg19c --- jabgui/src/main/java/org/jabref/gui/ai/AskQuestionsAction.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jabgui/src/main/java/org/jabref/gui/ai/AskQuestionsAction.java b/jabgui/src/main/java/org/jabref/gui/ai/AskQuestionsAction.java index f38ef8585d79..6d8d3d49656a 100644 --- a/jabgui/src/main/java/org/jabref/gui/ai/AskQuestionsAction.java +++ b/jabgui/src/main/java/org/jabref/gui/ai/AskQuestionsAction.java @@ -227,7 +227,7 @@ private void ingestFiles(BibDatabaseContext context, BibEntry entry) { context, linkedFile ), false).getKey().get(); - } catch (InterruptedException e) { + } catch (InterruptedException _) { Thread.currentThread().interrupt(); return; } catch (ExecutionException e) {