From d9d58f6b88d01c3f6186308f66d03e9521a5d771 Mon Sep 17 00:00:00 2001 From: inanyan Date: Wed, 8 May 2024 12:51:50 +0300 Subject: [PATCH 01/30] First AI steps --- build.gradle | 5 + .../org/jabref/gui/entryeditor/AiChatTab.java | 122 ++++++++++++++++++ .../org/jabref/preferences/AiPreferences.java | 26 ++++ 3 files changed, 153 insertions(+) create mode 100644 src/main/java/org/jabref/gui/entryeditor/AiChatTab.java create mode 100644 src/main/java/org/jabref/preferences/AiPreferences.java diff --git a/build.gradle b/build.gradle index 6466119ed31..a8bfc7b354e 100644 --- a/build.gradle +++ b/build.gradle @@ -296,6 +296,11 @@ dependencies { // YAML formatting implementation 'org.yaml:snakeyaml:2.2' + // AI + implementation 'dev.langchain4j:langchain4j-open-ai:0.28.0' + implementation 'dev.langchain4j:langchain4j-embeddings-all-minilm-l6-v2:0.28.0' + implementation 'dev.langchain4j:langchain4j-document-parser-apache-pdfbox:0.28.0' + implementation 'commons-io:commons-io:2.16.1' testImplementation 'io.github.classgraph:classgraph:4.8.172' diff --git a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java new file mode 100644 index 00000000000..14eb1e43288 --- /dev/null +++ b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java @@ -0,0 +1,122 @@ +package org.jabref.gui.entryeditor; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.TextField; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; + +import org.jabref.logic.l10n.Localization; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.LinkedFile; +import org.jabref.preferences.PreferencesService; + +import dev.langchain4j.data.document.Document; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.store.embedding.EmbeddingStore; +import dev.langchain4j.store.embedding.EmbeddingStoreIngestor; +import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; + +public class AiChatTab extends EntryEditorTab { + public static final String NAME = "AI chat"; + + private final PreferencesService preferencesService; + + /* + Classes from langchain: + - Global: + - EmbeddingsModel - put into preferences. + - Per entry: + - EmbeddingsStore - stores embeddings of full-text article. + + - Per situation: + - EmbeddingsIngestor - ingests embeddings of full-text article (an algorithm part, + we don't need to store it somewhere). + */ + + private EmbeddingStore embeddingStore = null; + + private EmbeddingStoreIngestor ingestor = null; + + public AiChatTab(PreferencesService preferencesService) { + this.preferencesService = preferencesService; + + setText(Localization.lang(NAME)); + setTooltip(new Tooltip(Localization.lang("AI chat with full-text article"))); + } + + @Override + public boolean shouldShow(BibEntry entry) { + return true; + } + + @Override + protected void bindToEntry(BibEntry entry) { + if (entry.getFiles().isEmpty()) { + setContent(new Label(Localization.lang("No files attached"))); + } else if (entry.getFiles().stream().allMatch(file -> file.getFileType().equals("PDF"))) { + /* + QUESTION: What is the type of file.getFileType()???? + I thought it is the part after the dot, but it turns out not. + I got the "PDF" string by looking at tests. + */ + setContent(new Label(Localization.lang("Only PDF files are supported"))); + } else { + try { + bindToEntryRaw(entry); + } catch (IOException e) { + setContent(new Label(e.getMessage())); + } + } + } + + /* + An idea how to implement this: + what if we just start building this content, and then when the method sees "there is no files" it will raise an exception. + or it will raise an exception when it does not support a filetype? + And then the exception is caught in "bindToEntry" and a label of error is given to user. + */ + private void bindToEntryRaw(BibEntry entry) throws IOException { + configureAI(entry); + makeContent(); + } + + private void makeContent() { + Label askLabel = new Label(Localization.lang("Ask AI") + ": "); + + TextField promptField = new TextField(); + + Button submitButton = new Button(Localization.lang("Submit")); + + HBox promptBox = new HBox(askLabel, promptField, submitButton); + + Label answerLabel = new Label(Localization.lang("Answer") + ": "); + + Label realAnswerLabel = new Label(); + + HBox answerBox = new HBox(answerLabel, realAnswerLabel); + + VBox vbox = new VBox(promptBox, answerBox); + + setContent(vbox); + } + + private void configureAI(BibEntry entry) throws IOException { + this.embeddingStore = new InMemoryEmbeddingStore<>(); + this.ingestor = EmbeddingStoreIngestor.builder() + .embeddingStore(this.embeddingStore) + .embeddingModel(preferencesService.getAiPreferences().getEmbeddingModel()) + .build(); + + for (LinkedFile linkedFile : entry.getFiles()) { + String fileContents = Files.readString(Path.of(linkedFile.getLink())); + Document document = new Document(fileContents); + ingestor.ingest(document); + } + } +} diff --git a/src/main/java/org/jabref/preferences/AiPreferences.java b/src/main/java/org/jabref/preferences/AiPreferences.java new file mode 100644 index 00000000000..8c2434600a4 --- /dev/null +++ b/src/main/java/org/jabref/preferences/AiPreferences.java @@ -0,0 +1,26 @@ +package org.jabref.preferences; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleObjectProperty; + +import dev.langchain4j.model.embedding.EmbeddingModel; + +public class AiPreferences { + private final ObjectProperty embeddingModel; + + public AiPreferences(EmbeddingModel embeddingModel) { + this.embeddingModel = new SimpleObjectProperty<>(embeddingModel); + } + + public EmbeddingModel getEmbeddingModel() { + return embeddingModel.get(); + } + + public ObjectProperty embeddingModelProperty() { + return embeddingModel; + } + + public void setEmbeddingModel(EmbeddingModel embeddingModel) { + this.embeddingModel.set(embeddingModel); + } +} From 7c358c9306b3b3f77ee1197267463ab30358b714 Mon Sep 17 00:00:00 2001 From: inanyan Date: Wed, 8 May 2024 13:06:51 +0300 Subject: [PATCH 02/30] Some changes to AI code --- src/main/java/module-info.java | 4 ++ .../org/jabref/gui/entryeditor/AiChatTab.java | 41 ++++++++++++++++--- .../org/jabref/preferences/AiPreferences.java | 18 +++++++- .../preferences/PreferencesService.java | 2 + 4 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 352ef69c95b..593fb652efe 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -124,6 +124,10 @@ requires org.jooq.jool; + // AI + requires langchain4j.core; + requires langchain4j; + // fulltext search requires org.apache.lucene.core; // In case the version is updated, please also adapt SearchFieldConstants#VERSION to the newly used version diff --git a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java index 14eb1e43288..cb3d37e5ab7 100644 --- a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java @@ -16,8 +16,11 @@ import org.jabref.model.entry.LinkedFile; import org.jabref.preferences.PreferencesService; +import dev.langchain4j.chain.ConversationalRetrievalChain; import dev.langchain4j.data.document.Document; import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.rag.content.retriever.ContentRetriever; +import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.EmbeddingStoreIngestor; import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; @@ -27,22 +30,33 @@ public class AiChatTab extends EntryEditorTab { private final PreferencesService preferencesService; + // Stores embeddings generated from full-text articles. + // Depends on the embedding model. + private EmbeddingStore embeddingStore = null; + + // An object that augments the user prompt with relevant information from full-text articles. + // Depends on the embedding model and the embedding store. + private ContentRetriever contentRetriever = null; + + // Holds and performs the conversation with user. Stores the message history and manages API calls. + // Depends on the chat language model and content retriever. + private ConversationalRetrievalChain chain = null; + /* Classes from langchain: - Global: - EmbeddingsModel - put into preferences. - Per entry: - EmbeddingsStore - stores embeddings of full-text article. + - Per chat: + - ContentRetriever - a thing that augments the user prompt with relevant information. + - ConversationalRetrievalChain - main wrapper between the user and AI. Chat history, API calls. - Per situation: - EmbeddingsIngestor - ingests embeddings of full-text article (an algorithm part, we don't need to store it somewhere). */ - private EmbeddingStore embeddingStore = null; - - private EmbeddingStoreIngestor ingestor = null; - public AiChatTab(PreferencesService preferencesService) { this.preferencesService = preferencesService; @@ -103,12 +117,17 @@ private void makeContent() { VBox vbox = new VBox(promptBox, answerBox); + submitButton.setOnAction(e -> { + // TODO: Check if the prompt is empty. + realAnswerLabel.setText(chain.execute(promptField.getText())); + }); + setContent(vbox); } private void configureAI(BibEntry entry) throws IOException { this.embeddingStore = new InMemoryEmbeddingStore<>(); - this.ingestor = EmbeddingStoreIngestor.builder() + EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder() .embeddingStore(this.embeddingStore) .embeddingModel(preferencesService.getAiPreferences().getEmbeddingModel()) .build(); @@ -118,5 +137,17 @@ private void configureAI(BibEntry entry) throws IOException { Document document = new Document(fileContents); ingestor.ingest(document); } + + this.contentRetriever = EmbeddingStoreContentRetriever + .builder() + .embeddingStore(this.embeddingStore) + .embeddingModel(preferencesService.getAiPreferences().getEmbeddingModel()) + .build(); + + this.chain = ConversationalRetrievalChain + .builder() + .chatLanguageModel(preferencesService.getAiPreferences().getChatModel()) + .contentRetriever(this.contentRetriever) + .build(); } } diff --git a/src/main/java/org/jabref/preferences/AiPreferences.java b/src/main/java/org/jabref/preferences/AiPreferences.java index 8c2434600a4..b4b2e868daa 100644 --- a/src/main/java/org/jabref/preferences/AiPreferences.java +++ b/src/main/java/org/jabref/preferences/AiPreferences.java @@ -3,13 +3,15 @@ import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; +import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.embedding.EmbeddingModel; public class AiPreferences { private final ObjectProperty embeddingModel; - public AiPreferences(EmbeddingModel embeddingModel) { + public AiPreferences(EmbeddingModel embeddingModel, ChatLanguageModel chatModel) { this.embeddingModel = new SimpleObjectProperty<>(embeddingModel); + this.chatModel = new SimpleObjectProperty<>(chatModel); } public EmbeddingModel getEmbeddingModel() { @@ -23,4 +25,18 @@ public ObjectProperty embeddingModelProperty() { public void setEmbeddingModel(EmbeddingModel embeddingModel) { this.embeddingModel.set(embeddingModel); } + + private final ObjectProperty chatModel; + + public ChatLanguageModel getChatModel() { + return chatModel.get(); + } + + public ObjectProperty chatModelProperty() { + return chatModel; + } + + public void setChatModel(ChatLanguageModel chatModel) { + this.chatModel.set(chatModel); + } } diff --git a/src/main/java/org/jabref/preferences/PreferencesService.java b/src/main/java/org/jabref/preferences/PreferencesService.java index 5c6f14b95f4..4083d826f8a 100644 --- a/src/main/java/org/jabref/preferences/PreferencesService.java +++ b/src/main/java/org/jabref/preferences/PreferencesService.java @@ -152,4 +152,6 @@ public interface PreferencesService { ProtectedTermsPreferences getProtectedTermsPreferences(); MergeDialogPreferences getMergeDialogPreferences(); + + AiPreferences getAiPreferences(); } From 0a05213d1f62ff8e9636dc7c5f3631132aa89bca Mon Sep 17 00:00:00 2001 From: inanyan Date: Wed, 8 May 2024 15:02:50 +0300 Subject: [PATCH 03/30] Add AI preferences --- src/main/java/module-info.java | 2 ++ .../jabref/preferences/JabRefPreferences.java | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 593fb652efe..3525346fdad 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -127,6 +127,8 @@ // AI requires langchain4j.core; requires langchain4j; + requires langchain4j.embeddings.all.minilm.l6.v2; + requires langchain4j.open.ai; // fulltext search requires org.apache.lucene.core; diff --git a/src/main/java/org/jabref/preferences/JabRefPreferences.java b/src/main/java/org/jabref/preferences/JabRefPreferences.java index d9c747b3a1e..641bf568c90 100644 --- a/src/main/java/org/jabref/preferences/JabRefPreferences.java +++ b/src/main/java/org/jabref/preferences/JabRefPreferences.java @@ -128,6 +128,9 @@ import com.github.javakeyring.Keyring; import com.github.javakeyring.PasswordAccessException; import com.tobiasdiez.easybind.EasyBind; +import dev.langchain4j.model.embedding.AllMiniLmL6V2EmbeddingModel; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.openai.OpenAiChatModel; import jakarta.inject.Singleton; import org.jvnet.hk2.annotations.Service; import org.slf4j.Logger; @@ -2701,6 +2704,25 @@ public MergeDialogPreferences getMergeDialogPreferences() { return mergeDialogPreferences; } + @Override + public AiPreferences getAiPreferences() { + String token = System.getenv("OPENAI_API_TOKEN"); + + if (token == null) { + // Not good. + throw new RuntimeException(Localization.lang("No OpenAI token found")); + } + + EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel(); + + OpenAiChatModel chatModel = OpenAiChatModel + .builder() + .apiKey(token) + .build(); + + return new AiPreferences(embeddingModel, chatModel); + } + //************************************************************************************************************* // Misc preferences //************************************************************************************************************* From 812820aa409803857572f6a5d7b6f594c044b952 Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Wed, 8 May 2024 14:04:55 +0200 Subject: [PATCH 04/30] First part of fix --- build.gradle | 5 +++++ src/main/java/module-info.java | 1 - 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index a8bfc7b354e..d1e33343de6 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,7 @@ version = project.findProperty('projVersion') ?: '100.0.0' java { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 + // Workaround needed for Eclipse, probably because of https://github.com/gradle/gradle/issues/16922 // Should be removed as soon as Gradle 7.0.1 is released ( https://github.com/gradle/gradle/issues/16922#issuecomment-828217060 ) modularity.inferModulePath.set(false) @@ -59,6 +60,10 @@ java { } } +modularity.patchModule("langchain4j", "langchain4j-core-0.28.0.jar") +modularity.patchModule("langchain4j", "langchain4j-embeddings-0.28.0.jar") +modularity.patchModule("langchain4j", "langchain4j-embeddings-all-minilm-l6-v2-0.28.0.jar") + application { mainClass.set('org.jabref.Launcher') mainModule.set('org.jabref') diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 3525346fdad..669b0631071 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -125,7 +125,6 @@ requires org.jooq.jool; // AI - requires langchain4j.core; requires langchain4j; requires langchain4j.embeddings.all.minilm.l6.v2; requires langchain4j.open.ai; From 7f92e0d99f55516930eaed4f1c8fabeaa971468a Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Wed, 8 May 2024 14:17:28 +0200 Subject: [PATCH 05/30] Fix: second part --- build.gradle | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/build.gradle b/build.gradle index d1e33343de6..dbd34af4043 100644 --- a/build.gradle +++ b/build.gradle @@ -60,10 +60,6 @@ java { } } -modularity.patchModule("langchain4j", "langchain4j-core-0.28.0.jar") -modularity.patchModule("langchain4j", "langchain4j-embeddings-0.28.0.jar") -modularity.patchModule("langchain4j", "langchain4j-embeddings-all-minilm-l6-v2-0.28.0.jar") - application { mainClass.set('org.jabref.Launcher') mainModule.set('org.jabref') @@ -76,6 +72,9 @@ application { // Note that the arguments are cleared for the "run" task to avoid messages like "WARNING: Unknown module: org.jabref.merged.module specified to --add-exports" + '--add-exports=langchain4j/dev.langchain4j.model.chat=org.jabref', + '--add-exports=langchain4j/dev.langchain4j.model.chat=org.jabref.merged.module', + // Fix for https://github.com/JabRef/jabref/issues/11188 '--add-exports=javafx.base/com.sun.javafx.event=org.jabref.merged.module', '--add-exports=javafx.controls/com.sun.javafx.scene.control=org.jabref.merged.module', @@ -99,6 +98,11 @@ application { // See also https://github.com/java9-modularity/gradle-modules-plugin/issues/165 modularity.disableEffectiveArgumentsAdjustment() +// Required as workaround for https://github.com/langchain4j/langchain4j/issues/1066 +modularity.patchModule("langchain4j", "langchain4j-core-0.28.0.jar") +modularity.patchModule("langchain4j", "langchain4j-embeddings-0.28.0.jar") +modularity.patchModule("langchain4j", "langchain4j-embeddings-all-minilm-l6-v2-0.28.0.jar") + sourceSets { main { java { @@ -446,7 +450,14 @@ compileJava { // TODO: Remove access to internal api addExports = [ 'javafx.controls/com.sun.javafx.scene.control' : 'org.jabref', - 'org.controlsfx.controls/impl.org.controlsfx.skin' : 'org.jabref' + 'org.controlsfx.controls/impl.org.controlsfx.skin' : 'org.jabref', + + 'langchain4j/dev.langchain4j.data.document' : 'org.jabref', + 'langchain4j/dev.langchain4j.data.segment' : 'org.jabref', + 'langchain4j/dev.langchain4j.model.chat' : 'org.jabref', + 'langchain4j/dev.langchain4j.model.embedding' : 'org.jabref', + 'langchain4j/dev.langchain4j.rag.content.retriever' : 'org.jabref', + 'langchain4j/dev.langchain4j.store.embedding' : 'org.jabref' ] } } @@ -465,6 +476,9 @@ run { 'javafx.base/com.sun.javafx.event' : 'org.jabref.merged.module', 'javafx.controls/com.sun.javafx.scene.control' : 'org.jabref', + 'langchain4j/dev.langchain4j.model.chat' : 'org.jabref', + 'langchain4j/dev.langchain4j.model.chat' : 'org.jabref.merged.module', + // We need to restate the ControlsFX exports, because we get following error otherwise: // java.lang.IllegalAccessError: // class org.controlsfx.control.textfield.AutoCompletionBinding (in module org.controlsfx.controls) From cee227801e229ea0f9bb7a34b400b4b2837f402a Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Wed, 8 May 2024 14:56:37 +0200 Subject: [PATCH 06/30] More fixes --- build.gradle | 9 +++++++-- src/main/java/module-info.java | 2 -- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index dbd34af4043..d165f3cbc5b 100644 --- a/build.gradle +++ b/build.gradle @@ -72,8 +72,10 @@ application { // Note that the arguments are cleared for the "run" task to avoid messages like "WARNING: Unknown module: org.jabref.merged.module specified to --add-exports" - '--add-exports=langchain4j/dev.langchain4j.model.chat=org.jabref', - '--add-exports=langchain4j/dev.langchain4j.model.chat=org.jabref.merged.module', + // '--add-exports=langchain4j/dev.langchain4j.model.chat=org.jabref', + // '--add-exports=langchain4j/dev.langchain4j.model.chat=org.jabref.merged.module', + // '--add-exports=langchain4j/dev.langchain4j.model.openai=org.jabref', + // '--add-exports=langchain4j/dev.langchain4j.model.openai=org.jabref.merged.module', // Fix for https://github.com/JabRef/jabref/issues/11188 '--add-exports=javafx.base/com.sun.javafx.event=org.jabref.merged.module', @@ -99,9 +101,11 @@ application { modularity.disableEffectiveArgumentsAdjustment() // Required as workaround for https://github.com/langchain4j/langchain4j/issues/1066 +// modularity.patchModule("langchain4j", "langchain4j-0.28.0.jar") modularity.patchModule("langchain4j", "langchain4j-core-0.28.0.jar") modularity.patchModule("langchain4j", "langchain4j-embeddings-0.28.0.jar") modularity.patchModule("langchain4j", "langchain4j-embeddings-all-minilm-l6-v2-0.28.0.jar") +modularity.patchModule("langchain4j", "langchain4j-open-ai-0.28.0.jar") sourceSets { main { @@ -456,6 +460,7 @@ compileJava { 'langchain4j/dev.langchain4j.data.segment' : 'org.jabref', 'langchain4j/dev.langchain4j.model.chat' : 'org.jabref', 'langchain4j/dev.langchain4j.model.embedding' : 'org.jabref', + 'langchain4j/dev.langchain4j.model.openai' : 'org.jabref', 'langchain4j/dev.langchain4j.rag.content.retriever' : 'org.jabref', 'langchain4j/dev.langchain4j.store.embedding' : 'org.jabref' ] diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 669b0631071..fdc03853756 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -126,8 +126,6 @@ // AI requires langchain4j; - requires langchain4j.embeddings.all.minilm.l6.v2; - requires langchain4j.open.ai; // fulltext search requires org.apache.lucene.core; From 37d0534058b07da030521d468d4583535449e0fd Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Wed, 8 May 2024 14:59:34 +0200 Subject: [PATCH 07/30] Remove unneded Apache PDFBox --- build.gradle | 1 - 1 file changed, 1 deletion(-) diff --git a/build.gradle b/build.gradle index d165f3cbc5b..383c6e25615 100644 --- a/build.gradle +++ b/build.gradle @@ -312,7 +312,6 @@ dependencies { // AI implementation 'dev.langchain4j:langchain4j-open-ai:0.28.0' implementation 'dev.langchain4j:langchain4j-embeddings-all-minilm-l6-v2:0.28.0' - implementation 'dev.langchain4j:langchain4j-document-parser-apache-pdfbox:0.28.0' implementation 'commons-io:commons-io:2.16.1' From d9b6c3e4a1137f1d6812d59d161bfc1f30f1d32a Mon Sep 17 00:00:00 2001 From: inanyan Date: Wed, 8 May 2024 16:10:07 +0300 Subject: [PATCH 08/30] Added AiChatTab to EntryEditor tabs --- src/main/java/org/jabref/gui/entryeditor/AiChatTab.java | 3 ++- src/main/java/org/jabref/gui/entryeditor/EntryEditor.java | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java index cb3d37e5ab7..13227000a91 100644 --- a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java @@ -3,6 +3,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.logging.Logger; import javafx.scene.control.Button; import javafx.scene.control.Label; @@ -73,7 +74,7 @@ public boolean shouldShow(BibEntry entry) { protected void bindToEntry(BibEntry entry) { if (entry.getFiles().isEmpty()) { setContent(new Label(Localization.lang("No files attached"))); - } else if (entry.getFiles().stream().allMatch(file -> file.getFileType().equals("PDF"))) { + } else if (!entry.getFiles().stream().allMatch(file -> file.getFileType().equals("PDF"))) { /* QUESTION: What is the type of file.getFileType()???? I thought it is the part after the dot, but it turns out not. diff --git a/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java b/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java index 759c46fc0ad..fdb14eadb37 100644 --- a/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java +++ b/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java @@ -314,6 +314,7 @@ private List createTabs() { entryEditorTabs.add(sourceTab); entryEditorTabs.add(new LatexCitationsTab(databaseContext, preferencesService, dialogService, directoryMonitorManager)); entryEditorTabs.add(new FulltextSearchResultsTab(stateManager, preferencesService, dialogService, taskExecutor)); + entryEditorTabs.add(new AiChatTab(preferencesService)); return entryEditorTabs; } From 19d8aa3b76f7d3254f4e428faf25335d205f6171 Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Wed, 8 May 2024 15:30:42 +0200 Subject: [PATCH 09/30] Fix dependency --- build.gradle | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 383c6e25615..2bae6b51b7f 100644 --- a/build.gradle +++ b/build.gradle @@ -310,8 +310,14 @@ dependencies { implementation 'org.yaml:snakeyaml:2.2' // AI - implementation 'dev.langchain4j:langchain4j-open-ai:0.28.0' implementation 'dev.langchain4j:langchain4j-embeddings-all-minilm-l6-v2:0.28.0' + implementation('dev.langchain4j:langchain4j-open-ai:0.28.0') { + exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8' + } + // openai depends on okhttp, which needs kotlin - see https://github.com/square/okhttp/issues/5299 for details + implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24' + + implementation 'com.squareup.okhttp3:okhttp:4.9.3' implementation 'commons-io:commons-io:2.16.1' From 2dcaa3abfcb482755c783b92d6140c9ef6f01a2a Mon Sep 17 00:00:00 2001 From: Oliver Kopp Date: Wed, 8 May 2024 16:30:25 +0200 Subject: [PATCH 10/30] Add require on kotlin.stdlib --- build.gradle | 14 +++++++++++--- src/main/java/module-info.java | 1 + 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/build.gradle b/build.gradle index 2bae6b51b7f..5a14c40e322 100644 --- a/build.gradle +++ b/build.gradle @@ -232,14 +232,19 @@ dependencies { implementation 'org.fxmisc.flowless:flowless:0.7.2' implementation 'org.fxmisc.richtext:richtextfx:0.11.2' implementation (group: 'com.dlsc.gemsfx', name: 'gemsfx', version: '2.12.0') { - exclude module: 'javax.inject' // Split package, use only jakarta.inject exclude module: 'commons-lang3' + exclude module: 'javax.inject' // Split package, use only jakarta.inject + exclude module: 'kotlin-stdlib-jdk8' + exclude group: 'com.squareup.retrofit2' exclude group: 'org.openjfx' exclude group: 'org.apache.logging.log4j' exclude group: 'tech.units' } // Required by gemsfx implementation 'tech.units:indriya:2.2' + implementation ('com.squareup.retrofit2:retrofit:2.11.0') { + exclude group: 'com.squareup.okhttp3' + } implementation 'org.controlsfx:controlsfx:11.2.1' @@ -315,9 +320,12 @@ dependencies { exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8' } // openai depends on okhttp, which needs kotlin - see https://github.com/square/okhttp/issues/5299 for details - implementation 'org.jetbrains.kotlin:kotlin-stdlib:1.9.24' + // GemxFX also (transitively) depends on kotlin + implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24' - implementation 'com.squareup.okhttp3:okhttp:4.9.3' + implementation ('com.squareup.okhttp3:okhttp:4.12.0') { + exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8' + } implementation 'commons-io:commons-io:2.16.1' diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index fdc03853756..5a0eba3059f 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -126,6 +126,7 @@ // AI requires langchain4j; + requires kotlin.stdlib; // fulltext search requires org.apache.lucene.core; From f389bad1d9767ac406d90e7e8249faa3cce430e4 Mon Sep 17 00:00:00 2001 From: inanyan Date: Wed, 8 May 2024 18:32:27 +0300 Subject: [PATCH 11/30] Some changes --- .../org/jabref/gui/entryeditor/AiChatTab.java | 50 +++++++++++++++++-- .../jabref/gui/entryeditor/EntryEditor.java | 2 +- .../org/jabref/preferences/AiPreferences.java | 2 +- src/main/resources/tinylog.properties | 2 + 4 files changed, 50 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java index 13227000a91..6298eeff3c0 100644 --- a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java @@ -1,9 +1,10 @@ package org.jabref.gui.entryeditor; import java.io.IOException; +import java.io.StringWriter; import java.nio.file.Files; import java.nio.file.Path; -import java.util.logging.Logger; +import java.util.Optional; import javafx.scene.control.Button; import javafx.scene.control.Label; @@ -12,24 +13,37 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; +import org.jabref.logic.importer.ParserResult; import org.jabref.logic.l10n.Localization; +import org.jabref.logic.xmp.EncryptedPdfsNotSupportedException; +import org.jabref.logic.xmp.XmpUtilReader; +import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.LinkedFile; +import org.jabref.preferences.FilePreferences; import org.jabref.preferences.PreferencesService; import dev.langchain4j.chain.ConversationalRetrievalChain; import dev.langchain4j.data.document.Document; +import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.rag.content.retriever.ContentRetriever; import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.EmbeddingStoreIngestor; import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; +import dev.langchain4j.data.document.splitter.DocumentSplitters; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class AiChatTab extends EntryEditorTab { public static final String NAME = "AI chat"; private final PreferencesService preferencesService; + private final BibDatabaseContext bibDatabaseContext; + private final FilePreferences filePreferences; // Stores embeddings generated from full-text articles. // Depends on the embedding model. @@ -43,6 +57,8 @@ public class AiChatTab extends EntryEditorTab { // Depends on the chat language model and content retriever. private ConversationalRetrievalChain chain = null; + private static final Logger LOGGER = LoggerFactory.getLogger(AiChatTab.class.getName()); + /* Classes from langchain: - Global: @@ -58,8 +74,10 @@ public class AiChatTab extends EntryEditorTab { we don't need to store it somewhere). */ - public AiChatTab(PreferencesService preferencesService) { + public AiChatTab(PreferencesService preferencesService, BibDatabaseContext bibDatabaseContext) { this.preferencesService = preferencesService; + this.bibDatabaseContext = bibDatabaseContext; + this.filePreferences = preferencesService.getFilePreferences(); setText(Localization.lang(NAME)); setTooltip(new Tooltip(Localization.lang("AI chat with full-text article"))); @@ -85,7 +103,8 @@ protected void bindToEntry(BibEntry entry) { try { bindToEntryRaw(entry); } catch (IOException e) { - setContent(new Label(e.getMessage())); + setContent(new Label("ERROR")); + LOGGER.error(e.getMessage(), e); } } } @@ -131,10 +150,16 @@ private void configureAI(BibEntry entry) throws IOException { EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder() .embeddingStore(this.embeddingStore) .embeddingModel(preferencesService.getAiPreferences().getEmbeddingModel()) + .documentSplitter(DocumentSplitters.recursive(300, 0)) .build(); for (LinkedFile linkedFile : entry.getFiles()) { - String fileContents = Files.readString(Path.of(linkedFile.getLink())); + Optional path = linkedFile.findIn(bibDatabaseContext, filePreferences); + if (path.isEmpty()) { + LOGGER.warn("Could not find file {}", linkedFile.getLink()); + continue; + } + String fileContents = readPDFFile(path.get()); Document document = new Document(fileContents); ingestor.ingest(document); } @@ -151,4 +176,21 @@ private void configureAI(BibEntry entry) throws IOException { .contentRetriever(this.contentRetriever) .build(); } + + private String readPDFFile(Path path) { + try (PDDocument document = new XmpUtilReader().loadWithAutomaticDecryption(path)) { + PDFTextStripper stripper = new PDFTextStripper(); + + int lastPage = document.getNumberOfPages(); + stripper.setStartPage(1); + stripper.setEndPage(lastPage); + StringWriter writer = new StringWriter(); + stripper.writeText(document, writer); + + String result = writer.toString();LOGGER.trace("PDF content", result); return result; + } catch (Exception e) { + LOGGER.error(e.getMessage(), e); + return ""; + } + } } diff --git a/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java b/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java index fdb14eadb37..361b557b3cb 100644 --- a/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java +++ b/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java @@ -314,7 +314,7 @@ private List createTabs() { entryEditorTabs.add(sourceTab); entryEditorTabs.add(new LatexCitationsTab(databaseContext, preferencesService, dialogService, directoryMonitorManager)); entryEditorTabs.add(new FulltextSearchResultsTab(stateManager, preferencesService, dialogService, taskExecutor)); - entryEditorTabs.add(new AiChatTab(preferencesService)); + entryEditorTabs.add(new AiChatTab(preferencesService, databaseContext)); return entryEditorTabs; } diff --git a/src/main/java/org/jabref/preferences/AiPreferences.java b/src/main/java/org/jabref/preferences/AiPreferences.java index b4b2e868daa..c2bfb1c8249 100644 --- a/src/main/java/org/jabref/preferences/AiPreferences.java +++ b/src/main/java/org/jabref/preferences/AiPreferences.java @@ -8,6 +8,7 @@ public class AiPreferences { private final ObjectProperty embeddingModel; + private final ObjectProperty chatModel; public AiPreferences(EmbeddingModel embeddingModel, ChatLanguageModel chatModel) { this.embeddingModel = new SimpleObjectProperty<>(embeddingModel); @@ -26,7 +27,6 @@ public void setEmbeddingModel(EmbeddingModel embeddingModel) { this.embeddingModel.set(embeddingModel); } - private final ObjectProperty chatModel; public ChatLanguageModel getChatModel() { return chatModel.get(); diff --git a/src/main/resources/tinylog.properties b/src/main/resources/tinylog.properties index db3d8db17fb..eef491a64fc 100644 --- a/src/main/resources/tinylog.properties +++ b/src/main/resources/tinylog.properties @@ -11,3 +11,5 @@ exception = strip: jdk.internal level@org.jabref.gui.maintable.PersistenceVisualStateTable = debug level@org.jabref.http.server.Server = debug + +level@org.jabref.gui.entryeditor.AiChatTab = trace From 971fdce8f4eb4570db482f2c1f9246c790a8885d Mon Sep 17 00:00:00 2001 From: inanyan Date: Fri, 10 May 2024 17:43:12 +0300 Subject: [PATCH 12/30] AI preferences tab --- build.gradle | 4 +- .../org/jabref/gui/entryeditor/AiChatTab.java | 143 +++++++++++------- .../PreferencesDialogViewModel.java | 4 +- .../org/jabref/gui/preferences/ai/AiTab.fxml | 16 ++ .../org/jabref/gui/preferences/ai/AiTab.java | 43 ++++++ .../gui/preferences/ai/AiTabViewModel.java | 77 ++++++++++ .../org/jabref/preferences/AiPreferences.java | 44 +++--- .../jabref/preferences/JabRefPreferences.java | 50 ++++-- 8 files changed, 294 insertions(+), 87 deletions(-) create mode 100644 src/main/java/org/jabref/gui/preferences/ai/AiTab.fxml create mode 100644 src/main/java/org/jabref/gui/preferences/ai/AiTab.java create mode 100644 src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java diff --git a/build.gradle b/build.gradle index 5a14c40e322..784a900cde9 100644 --- a/build.gradle +++ b/build.gradle @@ -475,7 +475,9 @@ compileJava { 'langchain4j/dev.langchain4j.model.embedding' : 'org.jabref', 'langchain4j/dev.langchain4j.model.openai' : 'org.jabref', 'langchain4j/dev.langchain4j.rag.content.retriever' : 'org.jabref', - 'langchain4j/dev.langchain4j.store.embedding' : 'org.jabref' + 'langchain4j/dev.langchain4j.store.embedding' : 'org.jabref', + 'langchain4j/dev.langchain4j.memory' : 'org.jabref', + 'langchain4j/dev.langchain4j.store.memory.chat' : 'org.jabref' ] } } diff --git a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java index 6298eeff3c0..618d5a96522 100644 --- a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java @@ -1,8 +1,6 @@ package org.jabref.gui.entryeditor; -import java.io.IOException; import java.io.StringWriter; -import java.nio.file.Files; import java.nio.file.Path; import java.util.Optional; @@ -13,26 +11,34 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; -import org.jabref.logic.importer.ParserResult; import org.jabref.logic.l10n.Localization; -import org.jabref.logic.xmp.EncryptedPdfsNotSupportedException; import org.jabref.logic.xmp.XmpUtilReader; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.LinkedFile; +import org.jabref.preferences.AiPreferences; import org.jabref.preferences.FilePreferences; import org.jabref.preferences.PreferencesService; +import com.tobiasdiez.easybind.EasyBind; import dev.langchain4j.chain.ConversationalRetrievalChain; import dev.langchain4j.data.document.Document; +import dev.langchain4j.data.document.DocumentSplitter; import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.memory.ChatMemory; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.embedding.AllMiniLmL6V2EmbeddingModel; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.rag.content.retriever.ContentRetriever; import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.EmbeddingStoreIngestor; import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; -import dev.langchain4j.data.document.splitter.DocumentSplitters; +import dev.langchain4j.store.memory.chat.ChatMemoryStore; +import dev.langchain4j.store.memory.chat.InMemoryChatMemoryStore; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.text.PDFTextStripper; import org.slf4j.Logger; @@ -40,52 +46,74 @@ public class AiChatTab extends EntryEditorTab { public static final String NAME = "AI chat"; - + private static final Logger LOGGER = LoggerFactory.getLogger(AiChatTab.class.getName()); private final PreferencesService preferencesService; private final BibDatabaseContext bibDatabaseContext; private final FilePreferences filePreferences; - + private final AiPreferences aiPreferences; + private ChatLanguageModel chatModel = null; + private final EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel(); // Stores embeddings generated from full-text articles. // Depends on the embedding model. private EmbeddingStore embeddingStore = null; - // An object that augments the user prompt with relevant information from full-text articles. // Depends on the embedding model and the embedding store. private ContentRetriever contentRetriever = null; - + // The actual chat memory. + private ChatMemoryStore chatMemoryStore = null; + // An algorithm for manipulating chat memory. + // Depends on chat memory store. + private ChatMemory chatMemory = null; // Holds and performs the conversation with user. Stores the message history and manages API calls. // Depends on the chat language model and content retriever. private ConversationalRetrievalChain chain = null; - private static final Logger LOGGER = LoggerFactory.getLogger(AiChatTab.class.getName()); - /* Classes from langchain: - - Global: - - EmbeddingsModel - put into preferences. - - Per entry: + - Global (depends on preferences changes): + - ChatModel. + - EmbeddingsModel + - Per entry (depends on BibEntry): - EmbeddingsStore - stores embeddings of full-text article. - - Per chat: - ContentRetriever - a thing that augments the user prompt with relevant information. + - ChatMemoryStore - really stores chat history. + - ChatMemory - algorithm for manipulating chat memory. - ConversationalRetrievalChain - main wrapper between the user and AI. Chat history, API calls. - - Per situation: - - EmbeddingsIngestor - ingests embeddings of full-text article (an algorithm part, - we don't need to store it somewhere). + We can store only embeddings and chat memory in bib entries, and then reconstruct this classes. */ public AiChatTab(PreferencesService preferencesService, BibDatabaseContext bibDatabaseContext) { this.preferencesService = preferencesService; this.bibDatabaseContext = bibDatabaseContext; this.filePreferences = preferencesService.getFilePreferences(); + this.aiPreferences = preferencesService.getAiPreferences(); setText(Localization.lang(NAME)); setTooltip(new Tooltip(Localization.lang("AI chat with full-text article"))); + + EasyBind.listen(aiPreferences.useAiProperty(), (obs, oldValue, newValue) -> { + if (newValue) { + makeChatModel(aiPreferences.getOpenAiToken()); + } + }); + EasyBind.listen(aiPreferences.openAiTokenProperty(), (obs, oldValue, newValue) -> makeChatModel(newValue)); + + if (aiPreferences.isUseAi()) { + makeChatModel(aiPreferences.getOpenAiToken()); + } + } + + private void makeChatModel(String apiKey) { + chatModel = OpenAiChatModel + .builder() + .apiKey(apiKey) + .build(); } @Override public boolean shouldShow(BibEntry entry) { - return true; + return aiPreferences.isUseAi(); } @Override @@ -100,22 +128,11 @@ protected void bindToEntry(BibEntry entry) { */ setContent(new Label(Localization.lang("Only PDF files are supported"))); } else { - try { - bindToEntryRaw(entry); - } catch (IOException e) { - setContent(new Label("ERROR")); - LOGGER.error(e.getMessage(), e); - } + bindToEntryRaw(entry); } } - /* - An idea how to implement this: - what if we just start building this content, and then when the method sees "there is no files" it will raise an exception. - or it will raise an exception when it does not support a filetype? - And then the exception is caught in "bindToEntry" and a label of error is given to user. - */ - private void bindToEntryRaw(BibEntry entry) throws IOException { + private void bindToEntryRaw(BibEntry entry) { configureAI(entry); makeContent(); } @@ -145,13 +162,45 @@ private void makeContent() { setContent(vbox); } - private void configureAI(BibEntry entry) throws IOException { + private void configureAI(BibEntry entry) { + makeAiObjects(); + ingestFiles(entry); + } + + private void makeAiObjects() { this.embeddingStore = new InMemoryEmbeddingStore<>(); + + this.contentRetriever = EmbeddingStoreContentRetriever + .builder() + .embeddingStore(embeddingStore) + .embeddingModel(embeddingModel) + .build(); + + this.chatMemoryStore = new InMemoryChatMemoryStore(); + + this.chatMemory = MessageWindowChatMemory + .builder() + .chatMemoryStore(chatMemoryStore) + .maxMessages(10) // This was the default value in the original implementation. + .build(); + + this.chain = ConversationalRetrievalChain + .builder() + .chatLanguageModel(chatModel) + .contentRetriever(contentRetriever) + .chatMemory(chatMemory) + .build(); + } + + // TODO: Proper error handling. + + private void ingestFiles(BibEntry entry) { + DocumentSplitter documentSplitter = DocumentSplitters.recursive(300, 0); EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder() - .embeddingStore(this.embeddingStore) - .embeddingModel(preferencesService.getAiPreferences().getEmbeddingModel()) - .documentSplitter(DocumentSplitters.recursive(300, 0)) - .build(); + .embeddingStore(embeddingStore) + .embeddingModel(embeddingModel) // What if null? + .documentSplitter(documentSplitter) + .build(); for (LinkedFile linkedFile : entry.getFiles()) { Optional path = linkedFile.findIn(bibDatabaseContext, filePreferences); @@ -163,18 +212,6 @@ private void configureAI(BibEntry entry) throws IOException { Document document = new Document(fileContents); ingestor.ingest(document); } - - this.contentRetriever = EmbeddingStoreContentRetriever - .builder() - .embeddingStore(this.embeddingStore) - .embeddingModel(preferencesService.getAiPreferences().getEmbeddingModel()) - .build(); - - this.chain = ConversationalRetrievalChain - .builder() - .chatLanguageModel(preferencesService.getAiPreferences().getChatModel()) - .contentRetriever(this.contentRetriever) - .build(); } private String readPDFFile(Path path) { @@ -187,8 +224,12 @@ private String readPDFFile(Path path) { StringWriter writer = new StringWriter(); stripper.writeText(document, writer); - String result = writer.toString();LOGGER.trace("PDF content", result); return result; - } catch (Exception e) { + String result = writer.toString(); + LOGGER.trace("PDF content: {}", result); + + return result; + } catch ( + Exception e) { LOGGER.error(e.getMessage(), e); return ""; } diff --git a/src/main/java/org/jabref/gui/preferences/PreferencesDialogViewModel.java b/src/main/java/org/jabref/gui/preferences/PreferencesDialogViewModel.java index 3647a845a61..6e93b09cc4d 100644 --- a/src/main/java/org/jabref/gui/preferences/PreferencesDialogViewModel.java +++ b/src/main/java/org/jabref/gui/preferences/PreferencesDialogViewModel.java @@ -13,6 +13,7 @@ import org.jabref.gui.AbstractViewModel; import org.jabref.gui.DialogService; import org.jabref.gui.Globals; +import org.jabref.gui.preferences.ai.AiTab; import org.jabref.gui.preferences.autocompletion.AutoCompletionTab; import org.jabref.gui.preferences.citationkeypattern.CitationKeyPatternTab; import org.jabref.gui.preferences.customentrytypes.CustomEntryTypesTab; @@ -81,7 +82,8 @@ public PreferencesDialogViewModel(DialogService dialogService, PreferencesServic new XmpPrivacyTab(), new CustomImporterTab(), new CustomExporterTab(), - new NetworkTab() + new NetworkTab(), + new AiTab() ); } diff --git a/src/main/java/org/jabref/gui/preferences/ai/AiTab.fxml b/src/main/java/org/jabref/gui/preferences/ai/AiTab.fxml new file mode 100644 index 00000000000..d349e6cbe03 --- /dev/null +++ b/src/main/java/org/jabref/gui/preferences/ai/AiTab.fxml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/src/main/java/org/jabref/gui/preferences/ai/AiTab.java b/src/main/java/org/jabref/gui/preferences/ai/AiTab.java new file mode 100644 index 00000000000..e1af8a98138 --- /dev/null +++ b/src/main/java/org/jabref/gui/preferences/ai/AiTab.java @@ -0,0 +1,43 @@ +package org.jabref.gui.preferences.ai; + +import javafx.fxml.FXML; +import javafx.scene.control.CheckBox; +import javafx.scene.control.TextField; + +import org.jabref.gui.preferences.AbstractPreferenceTabView; +import org.jabref.gui.preferences.PreferencesTab; +import org.jabref.logic.l10n.Localization; + +import com.airhacks.afterburner.views.ViewLoader; + +public class AiTab extends AbstractPreferenceTabView implements PreferencesTab { + @FXML + private CheckBox useAi; + + @FXML + private TextField openAiToken; + + public AiTab() { + ViewLoader.view(this) + .root(this) + .load(); + } + + public void initialize() { + this.viewModel = new AiTabViewModel(preferencesService, dialogService); + + useAi.selectedProperty().bindBidirectional(viewModel.useAiProperty()); + openAiToken.textProperty().bindBidirectional(viewModel.openAiTokenProperty()); + + openAiToken.setDisable(!useAi.isSelected()); + + useAi.selectedProperty().addListener((observable, oldValue, newValue) -> { + openAiToken.setDisable(!newValue); + }); + } + + @Override + public String getTabName() { + return Localization.lang("AI"); + } +} diff --git a/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java b/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java new file mode 100644 index 00000000000..b971de37fa3 --- /dev/null +++ b/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java @@ -0,0 +1,77 @@ +package org.jabref.gui.preferences.ai; + +import java.util.regex.Pattern; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + +import org.jabref.gui.DialogService; +import org.jabref.gui.preferences.PreferenceTabViewModel; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.strings.StringUtil; +import org.jabref.preferences.AiPreferences; +import org.jabref.preferences.PreferencesService; + +public class AiTabViewModel implements PreferenceTabViewModel { + private final BooleanProperty useAi = new SimpleBooleanProperty(); + private final StringProperty openAiToken = new SimpleStringProperty(); + + // What about a checkbox "Use AI in JabRef"? + + private final AiPreferences aiPreferences; + private final DialogService dialogService; + + private static final String OPENAI_TOKEN_PATTERN_STRING = "sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20}"; + private static final Pattern OPENAI_TOKEN_PATTERN = Pattern.compile(OPENAI_TOKEN_PATTERN_STRING); + + public AiTabViewModel(PreferencesService preferencesService, DialogService dialogService) { + this.aiPreferences = preferencesService.getAiPreferences(); + this.dialogService = dialogService; + } + + @Override + public void setValues() { + useAi.setValue(aiPreferences.isUseAi()); + openAiToken.setValue(aiPreferences.getOpenAiToken()); + } + + @Override + public void storeSettings() { + aiPreferences.setUseAi(useAi.get()); + aiPreferences.setOpenAiToken(openAiToken.get()); + } + + @Override + public boolean validateSettings() { + if (useAi.get()) { + return validateOpenAiToken(); + } + + return true; + } + + private boolean validateOpenAiToken() { + if (StringUtil.isBlank(openAiToken.get())) { + // Uhm, actually, it can be empty, if user does not want to use AI things. + dialogService.showErrorDialogAndWait(Localization.lang("Format error"), Localization.lang("The OpenAI token cannot be empty")); + return false; + } + + if (!OPENAI_TOKEN_PATTERN.matcher(openAiToken.get()).matches()) { + dialogService.showErrorDialogAndWait(Localization.lang("Format error"), Localization.lang("The OpenAI token is not valid")); + return false; + } + + return true; + } + + public StringProperty openAiTokenProperty() { + return openAiToken; + } + + public BooleanProperty useAiProperty() { + return useAi; + } +} diff --git a/src/main/java/org/jabref/preferences/AiPreferences.java b/src/main/java/org/jabref/preferences/AiPreferences.java index c2bfb1c8249..6818f00720d 100644 --- a/src/main/java/org/jabref/preferences/AiPreferences.java +++ b/src/main/java/org/jabref/preferences/AiPreferences.java @@ -1,42 +1,40 @@ package org.jabref.preferences; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleObjectProperty; - -import dev.langchain4j.model.chat.ChatLanguageModel; -import dev.langchain4j.model.embedding.EmbeddingModel; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; public class AiPreferences { - private final ObjectProperty embeddingModel; - private final ObjectProperty chatModel; + private final BooleanProperty useAi; + private final StringProperty openAiToken; - public AiPreferences(EmbeddingModel embeddingModel, ChatLanguageModel chatModel) { - this.embeddingModel = new SimpleObjectProperty<>(embeddingModel); - this.chatModel = new SimpleObjectProperty<>(chatModel); + public AiPreferences(boolean useAi, String openAiToken) { + this.useAi = new SimpleBooleanProperty(useAi); + this.openAiToken = new SimpleStringProperty(openAiToken); } - public EmbeddingModel getEmbeddingModel() { - return embeddingModel.get(); + public BooleanProperty useAiProperty() { + return useAi; } - public ObjectProperty embeddingModelProperty() { - return embeddingModel; + public boolean isUseAi() { + return useAi.get(); } - public void setEmbeddingModel(EmbeddingModel embeddingModel) { - this.embeddingModel.set(embeddingModel); + public void setUseAi(boolean useAi) { + this.useAi.set(useAi); } - - public ChatLanguageModel getChatModel() { - return chatModel.get(); + public StringProperty openAiTokenProperty() { + return openAiToken; } - public ObjectProperty chatModelProperty() { - return chatModel; + public String getOpenAiToken() { + return openAiToken.get(); } - public void setChatModel(ChatLanguageModel chatModel) { - this.chatModel.set(chatModel); + public void setOpenAiToken(String openAiToken) { + this.openAiToken.set(openAiToken); } } diff --git a/src/main/java/org/jabref/preferences/JabRefPreferences.java b/src/main/java/org/jabref/preferences/JabRefPreferences.java index 641bf568c90..a6c5f621dd7 100644 --- a/src/main/java/org/jabref/preferences/JabRefPreferences.java +++ b/src/main/java/org/jabref/preferences/JabRefPreferences.java @@ -449,6 +449,9 @@ public class JabRefPreferences implements PreferencesService { private static final String USE_REMOTE_SERVER = "useRemoteServer"; private static final String REMOTE_SERVER_PORT = "remoteServerPort"; + // AI + private static final String USE_AI = "useAi"; + private static final Logger LOGGER = LoggerFactory.getLogger(JabRefPreferences.class); private static final Preferences PREFS_NODE = Preferences.userRoot().node("/org/jabref"); @@ -506,6 +509,7 @@ public class JabRefPreferences implements PreferencesService { private JournalAbbreviationPreferences journalAbbreviationPreferences; private FieldPreferences fieldPreferences; private MergeDialogPreferences mergeDialogPreferences; + private AiPreferences aiPreferences; // The constructor is made private to enforce this as a singleton class: private JabRefPreferences() { @@ -831,6 +835,9 @@ private JabRefPreferences() { defaults.put(THEME, Theme.BASE_CSS); defaults.put(THEME_SYNC_OS, Boolean.FALSE); setLanguageDependentDefaultValues(); + + // AI + defaults.put(USE_AI, Boolean.FALSE); } public void setLanguageDependentDefaultValues() { @@ -2706,21 +2713,42 @@ public MergeDialogPreferences getMergeDialogPreferences() { @Override public AiPreferences getAiPreferences() { - String token = System.getenv("OPENAI_API_TOKEN"); - - if (token == null) { - // Not good. - throw new RuntimeException(Localization.lang("No OpenAI token found")); + if (aiPreferences != null) { + return aiPreferences; } - EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel(); + String token = getOpenAiTokenFromKeyring(); + aiPreferences = new AiPreferences(getBoolean(USE_AI), token); - OpenAiChatModel chatModel = OpenAiChatModel - .builder() - .apiKey(token) - .build(); + EasyBind.listen(aiPreferences.openAiTokenProperty(), (obs, oldValue, newValue) -> storeOpenAiTokenToKeyring(newValue)); + EasyBind.listen(aiPreferences.useAiProperty(), (obs, oldValue, newValue) -> putBoolean(USE_AI, newValue)); - return new AiPreferences(embeddingModel, chatModel); + return aiPreferences; + } + + private String getOpenAiTokenFromKeyring() { + try (final Keyring keyring = Keyring.create()) { + String rawPassword = keyring.getPassword("org.jabref.customapikeys", "openaitoken"); + Password password = new Password(rawPassword, getInternalPreferences().getUserAndHost()); + return password.decrypt(); + } catch (Exception e) { + // What to do in this place? + // There are many different error types. + LOGGER.warn("JabRef could not open keyring for retrieving OpenAI API token"); + return ""; // What to return? Is empty key valid? + } + } + + private void storeOpenAiTokenToKeyring(String newToken) { + try (final Keyring keyring = Keyring.create()) { + Password password = new Password(newToken, getInternalPreferences().getUserAndHost()); + String rawPassword = password.encrypt(); + keyring.setPassword("org.jabref.customapikeys", "openaitoken", rawPassword); + } catch (Exception e) { + // What to do in this place? + // There are many different error types. + LOGGER.warn("JabRef could not open keyring for retrieving OpenAI API token"); + } } //************************************************************************************************************* From ddacd479ef65d06070391a679eb1eeea15d2ee75 Mon Sep 17 00:00:00 2001 From: inanyan Date: Sat, 11 May 2024 18:33:59 +0300 Subject: [PATCH 13/30] AI is split to classes. Preferences tab. UI changes --- build.gradle | 3 +- .../org/jabref/gui/entryeditor/AiChatTab.java | 309 +++++++++--------- .../jabref/gui/entryeditor/EntryEditor.java | 2 +- src/main/java/org/jabref/logic/ai/AiChat.java | 101 ++++++ .../java/org/jabref/logic/ai/AiChatData.java | 47 +++ .../org/jabref/logic/ai/AiConnection.java | 34 ++ .../java/org/jabref/logic/ai/AiIngestor.java | 87 +++++ src/main/resources/tinylog.properties | 9 +- 8 files changed, 435 insertions(+), 157 deletions(-) create mode 100644 src/main/java/org/jabref/logic/ai/AiChat.java create mode 100644 src/main/java/org/jabref/logic/ai/AiChatData.java create mode 100644 src/main/java/org/jabref/logic/ai/AiConnection.java create mode 100644 src/main/java/org/jabref/logic/ai/AiIngestor.java diff --git a/build.gradle b/build.gradle index 784a900cde9..cb18812fb26 100644 --- a/build.gradle +++ b/build.gradle @@ -477,7 +477,8 @@ compileJava { 'langchain4j/dev.langchain4j.rag.content.retriever' : 'org.jabref', 'langchain4j/dev.langchain4j.store.embedding' : 'org.jabref', 'langchain4j/dev.langchain4j.memory' : 'org.jabref', - 'langchain4j/dev.langchain4j.store.memory.chat' : 'org.jabref' + 'langchain4j/dev.langchain4j.store.memory.chat' : 'org.jabref', + 'langchain4j/dev.langchain4j.data.message' : 'org.jabref', ] } } diff --git a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java index 618d5a96522..31ce5e19b85 100644 --- a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java @@ -1,18 +1,27 @@ package org.jabref.gui.entryeditor; -import java.io.StringWriter; -import java.nio.file.Path; -import java.util.Optional; +import java.util.List; +import javafx.geometry.Insets; +import javafx.geometry.NodeOrientation; +import javafx.geometry.Pos; +import javafx.scene.Node; import javafx.scene.control.Button; import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; import javafx.scene.control.TextField; import javafx.scene.control.Tooltip; import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; +import org.jabref.gui.DialogService; +import org.jabref.logic.ai.AiChat; +import org.jabref.logic.ai.AiChatData; +import org.jabref.logic.ai.AiConnection; +import org.jabref.logic.ai.AiIngestor; import org.jabref.logic.l10n.Localization; -import org.jabref.logic.xmp.XmpUtilReader; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.LinkedFile; @@ -21,94 +30,70 @@ import org.jabref.preferences.PreferencesService; import com.tobiasdiez.easybind.EasyBind; -import dev.langchain4j.chain.ConversationalRetrievalChain; -import dev.langchain4j.data.document.Document; -import dev.langchain4j.data.document.DocumentSplitter; -import dev.langchain4j.data.document.splitter.DocumentSplitters; -import dev.langchain4j.data.segment.TextSegment; -import dev.langchain4j.memory.ChatMemory; -import dev.langchain4j.memory.chat.MessageWindowChatMemory; -import dev.langchain4j.model.chat.ChatLanguageModel; -import dev.langchain4j.model.embedding.AllMiniLmL6V2EmbeddingModel; -import dev.langchain4j.model.embedding.EmbeddingModel; -import dev.langchain4j.model.openai.OpenAiChatModel; -import dev.langchain4j.rag.content.retriever.ContentRetriever; -import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; -import dev.langchain4j.store.embedding.EmbeddingStore; -import dev.langchain4j.store.embedding.EmbeddingStoreIngestor; -import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; -import dev.langchain4j.store.memory.chat.ChatMemoryStore; -import dev.langchain4j.store.memory.chat.InMemoryChatMemoryStore; -import org.apache.pdfbox.pdmodel.PDDocument; -import org.apache.pdfbox.text.PDFTextStripper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.UserMessage; public class AiChatTab extends EntryEditorTab { public static final String NAME = "AI chat"; - private static final Logger LOGGER = LoggerFactory.getLogger(AiChatTab.class.getName()); - private final PreferencesService preferencesService; - private final BibDatabaseContext bibDatabaseContext; + + private final DialogService dialogService; + private final FilePreferences filePreferences; private final AiPreferences aiPreferences; - private ChatLanguageModel chatModel = null; - private final EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel(); - // Stores embeddings generated from full-text articles. - // Depends on the embedding model. - private EmbeddingStore embeddingStore = null; - // An object that augments the user prompt with relevant information from full-text articles. - // Depends on the embedding model and the embedding store. - private ContentRetriever contentRetriever = null; - // The actual chat memory. - private ChatMemoryStore chatMemoryStore = null; - // An algorithm for manipulating chat memory. - // Depends on chat memory store. - private ChatMemory chatMemory = null; - // Holds and performs the conversation with user. Stores the message history and manages API calls. - // Depends on the chat language model and content retriever. - private ConversationalRetrievalChain chain = null; - - /* - Classes from langchain: - - Global (depends on preferences changes): - - ChatModel. - - EmbeddingsModel - - Per entry (depends on BibEntry): - - EmbeddingsStore - stores embeddings of full-text article. - - ContentRetriever - a thing that augments the user prompt with relevant information. - - ChatMemoryStore - really stores chat history. - - ChatMemory - algorithm for manipulating chat memory. - - ConversationalRetrievalChain - main wrapper between the user and AI. Chat history, API calls. - - We can store only embeddings and chat memory in bib entries, and then reconstruct this classes. - */ - - public AiChatTab(PreferencesService preferencesService, BibDatabaseContext bibDatabaseContext) { - this.preferencesService = preferencesService; - this.bibDatabaseContext = bibDatabaseContext; + + private final BibDatabaseContext bibDatabaseContext; + + private AiConnection aiConnection = null; + private AiChat aiChat = null; + + private VBox chatVBox = null; + + public AiChatTab(DialogService dialogService, PreferencesService preferencesService, BibDatabaseContext bibDatabaseContext) { + this.dialogService = dialogService; + this.filePreferences = preferencesService.getFilePreferences(); this.aiPreferences = preferencesService.getAiPreferences(); + this.bibDatabaseContext = bibDatabaseContext; + setText(Localization.lang(NAME)); setTooltip(new Tooltip(Localization.lang("AI chat with full-text article"))); + setUpAiConnection(); + } + + // Set up the AI connection if AI is used. + // Also listen for AI preferences changes and update the classes appropriately. + private void setUpAiConnection() { + if (aiPreferences.isUseAi()) { + aiConnection = new AiConnection(aiPreferences.getOpenAiToken()); + } + EasyBind.listen(aiPreferences.useAiProperty(), (obs, oldValue, newValue) -> { if (newValue) { - makeChatModel(aiPreferences.getOpenAiToken()); + aiConnection = new AiConnection(aiPreferences.getOpenAiToken()); + rebuildAiChat(); + } else { + aiConnection = null; + // QUESTION: If user chose AI but then unchooses, what should we do with the AI chat? + aiChat = null; } }); - EasyBind.listen(aiPreferences.openAiTokenProperty(), (obs, oldValue, newValue) -> makeChatModel(newValue)); - if (aiPreferences.isUseAi()) { - makeChatModel(aiPreferences.getOpenAiToken()); - } + EasyBind.listen(aiPreferences.openAiTokenProperty(), (obs, oldValue, newValue) -> { + if (aiConnection != null) { + aiConnection = new AiConnection(newValue); + rebuildAiChat(); + } + }); } - private void makeChatModel(String apiKey) { - chatModel = OpenAiChatModel - .builder() - .apiKey(apiKey) - .build(); + private void rebuildAiChat() { + if (aiChat != null) { + AiChatData data = aiChat.getData(); + aiChat = new AiChat(data, aiConnection); + } } @Override @@ -118,120 +103,136 @@ public boolean shouldShow(BibEntry entry) { @Override protected void bindToEntry(BibEntry entry) { + Node node; + if (entry.getFiles().isEmpty()) { - setContent(new Label(Localization.lang("No files attached"))); - } else if (!entry.getFiles().stream().allMatch(file -> file.getFileType().equals("PDF"))) { + node = stateNoFiles(); + } else if (!entry.getFiles().stream().allMatch(file -> "PDF".equals(file.getFileType()))) { /* QUESTION: What is the type of file.getFileType()???? I thought it is the part after the dot, but it turns out not. I got the "PDF" string by looking at tests. */ - setContent(new Label(Localization.lang("Only PDF files are supported"))); + node = stateWrongFilesFormat(); } else { - bindToEntryRaw(entry); + configureAiChat(entry); + node = stateAiChat(); + restoreMessages(aiChat.getData().getChatMemoryStore().getMessages(aiChat.getChatId())); } + + setContent(node); } - private void bindToEntryRaw(BibEntry entry) { - configureAI(entry); - makeContent(); + private Node stateNoFiles() { + return new Label(Localization.lang("No files attached")); } - private void makeContent() { - Label askLabel = new Label(Localization.lang("Ask AI") + ": "); + private Node stateWrongFilesFormat() { + return new Label(Localization.lang("Only PDF files are supported")); + } - TextField promptField = new TextField(); + private Node stateAiChat() { + // Don't bully me for this style. - Button submitButton = new Button(Localization.lang("Submit")); + VBox aiChatBox = new VBox(10); + aiChatBox.setPadding(new Insets(10)); - HBox promptBox = new HBox(askLabel, promptField, submitButton); + ScrollPane chatScrollPane = new ScrollPane(); + chatScrollPane.setStyle("-fx-border-color: black;"); + chatScrollPane.setPadding(new Insets(10, 10, 0, 10)); + VBox.setVgrow(chatScrollPane, Priority.ALWAYS); - Label answerLabel = new Label(Localization.lang("Answer") + ": "); + chatVBox = new VBox(10); - Label realAnswerLabel = new Label(); + // Chat messages will be children of chatVBox. - HBox answerBox = new HBox(answerLabel, realAnswerLabel); + chatScrollPane.setContent(chatVBox); - VBox vbox = new VBox(promptBox, answerBox); + aiChatBox.getChildren().add(chatScrollPane); - submitButton.setOnAction(e -> { - // TODO: Check if the prompt is empty. - realAnswerLabel.setText(chain.execute(promptField.getText())); - }); + HBox userPromptHBox = new HBox(10); + userPromptHBox.setAlignment(Pos.CENTER); - setContent(vbox); - } + TextField userPromptTextField = new TextField(); + HBox.setHgrow(userPromptTextField, Priority.ALWAYS); + + userPromptHBox.getChildren().add(userPromptTextField); + + Button userPromptSubmitButton = new Button(Localization.lang("Submit")); + userPromptSubmitButton.setOnAction(e -> { + String userPrompt = userPromptTextField.getText(); + userPromptTextField.setText(""); + + addMessage(true, userPrompt); - private void configureAI(BibEntry entry) { - makeAiObjects(); - ingestFiles(entry); + String aiMessage = aiChat.execute(userPrompt); + + addMessage(false, aiMessage); + }); + + userPromptHBox.getChildren().add(userPromptSubmitButton); + + aiChatBox.getChildren().add(userPromptHBox); + + return aiChatBox; } - private void makeAiObjects() { - this.embeddingStore = new InMemoryEmbeddingStore<>(); - - this.contentRetriever = EmbeddingStoreContentRetriever - .builder() - .embeddingStore(embeddingStore) - .embeddingModel(embeddingModel) - .build(); - - this.chatMemoryStore = new InMemoryChatMemoryStore(); - - this.chatMemory = MessageWindowChatMemory - .builder() - .chatMemoryStore(chatMemoryStore) - .maxMessages(10) // This was the default value in the original implementation. - .build(); - - this.chain = ConversationalRetrievalChain - .builder() - .chatLanguageModel(chatModel) - .contentRetriever(contentRetriever) - .chatMemory(chatMemory) - .build(); + private void addMessage(boolean isUser, String text) { + Node messageNode = generateMessage(isUser, text); + chatVBox.getChildren().add(messageNode); } - // TODO: Proper error handling. + private static final String USER_MESSAGE_COLOR = "#7ee3fb"; + private static final String AI_MESSAGE_COLOR = "#bac8cb"; - private void ingestFiles(BibEntry entry) { - DocumentSplitter documentSplitter = DocumentSplitters.recursive(300, 0); - EmbeddingStoreIngestor ingestor = EmbeddingStoreIngestor.builder() - .embeddingStore(embeddingStore) - .embeddingModel(embeddingModel) // What if null? - .documentSplitter(documentSplitter) - .build(); + private static Node generateMessage(boolean isUser, String text) { + Pane pane = new Pane(); - for (LinkedFile linkedFile : entry.getFiles()) { - Optional path = linkedFile.findIn(bibDatabaseContext, filePreferences); - if (path.isEmpty()) { - LOGGER.warn("Could not find file {}", linkedFile.getLink()); - continue; - } - String fileContents = readPDFFile(path.get()); - Document document = new Document(fileContents); - ingestor.ingest(document); + if (isUser) { + pane.setNodeOrientation(NodeOrientation.RIGHT_TO_LEFT); } + + VBox paneVBox = new VBox(10); + + paneVBox.setStyle("-fx-background-color: " + (isUser ? USER_MESSAGE_COLOR : AI_MESSAGE_COLOR) + ";"); + paneVBox.setPadding(new Insets(10)); + + Label authorLabel = new Label(Localization.lang(isUser ? "User" : "AI")); + authorLabel.setStyle("-fx-font-weight: bold"); + paneVBox.getChildren().add(authorLabel); + + Label messageLabel = new Label(text); + paneVBox.getChildren().add(messageLabel); + + pane.getChildren().add(paneVBox); + + return pane; } - private String readPDFFile(Path path) { - try (PDDocument document = new XmpUtilReader().loadWithAutomaticDecryption(path)) { - PDFTextStripper stripper = new PDFTextStripper(); + private void configureAiChat(BibEntry entry) { + aiChat = new AiChat(aiConnection); - int lastPage = document.getNumberOfPages(); - stripper.setStartPage(1); - stripper.setEndPage(lastPage); - StringWriter writer = new StringWriter(); - stripper.writeText(document, writer); + AiIngestor ingestor = new AiIngestor(aiChat.getData().getEmbeddingStore(), aiConnection.getEmbeddingModel()); - String result = writer.toString(); - LOGGER.trace("PDF content: {}", result); + for (LinkedFile linkedFile : entry.getFiles()) { + try { + ingestor.ingestLinkedFile(linkedFile, bibDatabaseContext, filePreferences); + } catch (Exception e) { + dialogService.showErrorDialogAndWait(Localization.lang("Error while loading file"), + Localization.lang("An error occurred while loading a file into the AI") + ":\n" + + e.getMessage() + "\n" + + Localization.lang("This file will be skipped") + "."); + } + } + } - return result; - } catch ( - Exception e) { - LOGGER.error(e.getMessage(), e); - return ""; + private void restoreMessages(List messages) { + for (ChatMessage message : messages) { + if (message instanceof UserMessage userMessage) { + addMessage(true, userMessage.singleText()); + } else if (message instanceof AiMessage aiMessage) { + addMessage(false, aiMessage.text()); + } } } } diff --git a/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java b/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java index 361b557b3cb..2fa33f74d71 100644 --- a/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java +++ b/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java @@ -314,7 +314,7 @@ private List createTabs() { entryEditorTabs.add(sourceTab); entryEditorTabs.add(new LatexCitationsTab(databaseContext, preferencesService, dialogService, directoryMonitorManager)); entryEditorTabs.add(new FulltextSearchResultsTab(stateManager, preferencesService, dialogService, taskExecutor)); - entryEditorTabs.add(new AiChatTab(preferencesService, databaseContext)); + entryEditorTabs.add(new AiChatTab(dialogService, preferencesService, databaseContext)); return entryEditorTabs; } diff --git a/src/main/java/org/jabref/logic/ai/AiChat.java b/src/main/java/org/jabref/logic/ai/AiChat.java new file mode 100644 index 00000000000..d5e25b2a317 --- /dev/null +++ b/src/main/java/org/jabref/logic/ai/AiChat.java @@ -0,0 +1,101 @@ +package org.jabref.logic.ai; + +import dev.langchain4j.chain.ConversationalRetrievalChain; +import dev.langchain4j.memory.ChatMemory; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; +import dev.langchain4j.rag.content.retriever.ContentRetriever; +import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; + +/** + * This class maintains an AI chat. + * It depends on the AiConnection, which holds embedding and chat models. + * This class was created for maintaining (hiding) the langchain4j state. + *

+ * AiChatData can be used to restore the serialized state of a chat. + *

+ * An outer class is responsible for synchronizing objects of this class with AiPreference changes. + */ +public class AiChat { + // Stores the embeddings and the chat history. + private final AiChatData data; + // The main class that executes user prompts. Maintains API calls and retrieval augmented generation (RAG). + private final ConversationalRetrievalChain chain; + // TODO: It turns out ChatMemoryStore is a more global class than I though. Make it global. + // Initially I thought it is local to every chat. + // But no, ChatMemoryStore is a store for many different chats. + // In current implementation I use it as I said it first: different ChatMemoryStore for different chats, + // so I need ID for several algorithms. + private final Object chatId; + + public AiChat(AiConnection aiConnection) { + this.data = new AiChatData(); + + // This class is basically an "algorithm class" for retrieving the relevant contents of documents. + ContentRetriever contentRetriever = EmbeddingStoreContentRetriever + .builder() + .embeddingStore(data.getEmbeddingStore()) + .embeddingModel(aiConnection.getEmbeddingModel()) + .build(); + + // This class is also an "algorithm class" that maintains the chat history. + // An algorithm for managing chat history is needed because you cannot stuff the whole history for the AI: + // there would be too many tokens. This class, for example, sends only the 10 last messages. + ChatMemory chatMemory = MessageWindowChatMemory + .builder() + .chatMemoryStore(data.getChatMemoryStore()) + .maxMessages(10) // This was the default value in the original implementation. + .build(); + + this.chatId = chatMemory.id(); + + // TODO: Investigate whether the ChatMemoryStore holds the whole chat history. + // As I think, ChatMemory should only send 10 last messages to the AI provider, but not remove the last + // messages from the store. + + this.chain = ConversationalRetrievalChain + .builder() + .chatLanguageModel(aiConnection.getChatModel()) + .contentRetriever(contentRetriever) + .chatMemory(chatMemory) + .build(); + } + + public AiChat(AiChatData data, AiConnection aiConnection) { + // Code duplication... + + this.data = data; + + ContentRetriever contentRetriever = EmbeddingStoreContentRetriever + .builder() + .embeddingStore(data.getEmbeddingStore()) + .embeddingModel(aiConnection.getEmbeddingModel()) + .build(); + + ChatMemory chatMemory = MessageWindowChatMemory + .builder() + .chatMemoryStore(data.getChatMemoryStore()) + .maxMessages(10) // This was the default value in the original implementation. + .build(); + + this.chatId = chatMemory.id(); + + this.chain = ConversationalRetrievalChain + .builder() + .chatLanguageModel(aiConnection.getChatModel()) + .contentRetriever(contentRetriever) + .chatMemory(chatMemory) + .build(); + } + + public String execute(String prompt) { + return chain.execute(prompt); + } + + public AiChatData getData() { + return data; + } + + public Object getChatId() { + return chatId; + } +} diff --git a/src/main/java/org/jabref/logic/ai/AiChatData.java b/src/main/java/org/jabref/logic/ai/AiChatData.java new file mode 100644 index 00000000000..7bfe1009eab --- /dev/null +++ b/src/main/java/org/jabref/logic/ai/AiChatData.java @@ -0,0 +1,47 @@ +package org.jabref.logic.ai; + +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.store.embedding.EmbeddingStore; +import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; +import dev.langchain4j.store.memory.chat.ChatMemoryStore; +import dev.langchain4j.store.memory.chat.InMemoryChatMemoryStore; + +/** + * This class holds model agnostic information about one AI chat for a bib entry. + * It stores all the embeddings, generated from the full-text article in linked files of a bib entry, + * and the chat history. + *

+ * This class could be used to serialize and deserialize the information for chat and/or recreate AiChat objects. + *

+ * You may ask: why embeddings are stored in this class? Indeed, logically for one bib entry there could be many chats. + * But in current implementation of JabRef there is only one chat per bib entry. + * So in this class embeddings are also stored for convenience. + */ +public class AiChatData { + // It's important to notice that all of these fields have an interface type, so we can easily create the specific + // classes that we want in JabRef. + + // TODO: Investigate whether the embeddings generated from different models are compatible. + // I guess not, so we have to invalidate and regenerate the embeddings if the model is changed. + // Of course, that is the week 2, not week 1. + private final EmbeddingStore embeddingStore; + private final ChatMemoryStore chatMemoryStore; + + public AiChatData() { + this.embeddingStore = new InMemoryEmbeddingStore<>(); + this.chatMemoryStore = new InMemoryChatMemoryStore(); + } + + public AiChatData(EmbeddingStore embeddingStore, ChatMemoryStore chatMemoryStore) { + this.embeddingStore = embeddingStore; + this.chatMemoryStore = chatMemoryStore; + } + + public EmbeddingStore getEmbeddingStore() { + return embeddingStore; + } + + public ChatMemoryStore getChatMemoryStore() { + return chatMemoryStore; + } +} diff --git a/src/main/java/org/jabref/logic/ai/AiConnection.java b/src/main/java/org/jabref/logic/ai/AiConnection.java new file mode 100644 index 00000000000..055fc76ef85 --- /dev/null +++ b/src/main/java/org/jabref/logic/ai/AiConnection.java @@ -0,0 +1,34 @@ +package org.jabref.logic.ai; + +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.embedding.AllMiniLmL6V2EmbeddingModel; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.openai.OpenAiChatModel; + +/** + * This class maintains the connection to AI services. + * This is a global state of AI, and AiChat's use this class. + *

+ * An outer class is responsible for synchronizing objects of this class with AiPreference changes. + */ +public class AiConnection { + private final ChatLanguageModel chatModel; + private final EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel(); + + // Later this class can accepts different enums or other pices of information in order + // to construct different chat models. + public AiConnection(String apiKey) { + this.chatModel = OpenAiChatModel + .builder() + .apiKey(apiKey) + .build(); + } + + public ChatLanguageModel getChatModel() { + return chatModel; + } + + public EmbeddingModel getEmbeddingModel() { + return embeddingModel; + } +} diff --git a/src/main/java/org/jabref/logic/ai/AiIngestor.java b/src/main/java/org/jabref/logic/ai/AiIngestor.java new file mode 100644 index 00000000000..9e3ca6948c5 --- /dev/null +++ b/src/main/java/org/jabref/logic/ai/AiIngestor.java @@ -0,0 +1,87 @@ +package org.jabref.logic.ai; + +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.StringWriter; +import java.nio.file.Path; +import java.util.Optional; + +import org.jabref.logic.JabRefException; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.xmp.XmpUtilReader; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.LinkedFile; +import org.jabref.preferences.FilePreferences; + +import dev.langchain4j.data.document.Document; +import dev.langchain4j.data.document.DocumentSplitter; +import dev.langchain4j.data.document.splitter.DocumentSplitters; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.store.embedding.EmbeddingStore; +import dev.langchain4j.store.embedding.EmbeddingStoreIngestor; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class is an "algorithm class". Meaning it is used in one place and is thrown away quickly. + *

+ * This class contains a bunch of methods that are useful for loading the documents to AI. + */ +public class AiIngestor { + // Another "algorithm class" that ingests the contents of the file into the embedding store. + private final EmbeddingStoreIngestor ingestor; + + private static final Logger LOGGER = LoggerFactory.getLogger(AiIngestor.class.getName()); + + public AiIngestor(EmbeddingStore embeddingStore, EmbeddingModel embeddingModel) { + // TODO: Tweak the parameters of this object. + DocumentSplitter documentSplitter = DocumentSplitters.recursive(300, 0); + + this.ingestor = EmbeddingStoreIngestor + .builder() + .embeddingStore(embeddingStore) + .embeddingModel(embeddingModel) // What if null? + .documentSplitter(documentSplitter) + .build(); + } + + public void ingestString(String contents) { + LOGGER.trace("Ingesting: {}", contents); + Document document = new Document(contents); + ingestor.ingest(document); + } + + public void ingestPDFFile(Path path) throws IOException { + PDDocument document = new XmpUtilReader().loadWithAutomaticDecryption(path); + PDFTextStripper stripper = new PDFTextStripper(); + + int lastPage = document.getNumberOfPages(); + stripper.setStartPage(1); + stripper.setEndPage(lastPage); + StringWriter writer = new StringWriter(); + stripper.writeText(document, writer); + + String result = writer.toString(); + + ingestString(result); + } + + public void ingestLinkedFile(LinkedFile linkedFile, BibDatabaseContext bibDatabaseContext, FilePreferences filePreferences) throws IOException, JabRefException { + if (!"PDF".equals(linkedFile.getFileType())) { + String errorMsg = Localization.lang("Unsupported file type") + ": " + + linkedFile.getFileType() + ". " + + Localization.lang("Only PDF files are supported") + "."; + throw new JabRefException(errorMsg); + } + + Optional path = linkedFile.findIn(bibDatabaseContext, filePreferences); + if (path.isPresent()) { + ingestPDFFile(path.get()); + } else { + throw new FileNotFoundException(linkedFile.getLink()); + } + } +} diff --git a/src/main/resources/tinylog.properties b/src/main/resources/tinylog.properties index eef491a64fc..9fc7647a777 100644 --- a/src/main/resources/tinylog.properties +++ b/src/main/resources/tinylog.properties @@ -12,4 +12,11 @@ level@org.jabref.gui.maintable.PersistenceVisualStateTable = debug level@org.jabref.http.server.Server = debug -level@org.jabref.gui.entryeditor.AiChatTab = trace +level@org.jabref.logic.ai.AiIngestor = trace + +# TODO: Set up properly. +langchain4j.open-ai.chat-model.log-requests = true +langchain4j.open-ai.chat-model.log-responses = true +logging.level.dev.langchain4j = DEBUG +logging.level.dev.ai4j.openai4j = DEBUG + From 1da98b562bc1aa0b5e7454a29f5a05dad8a6243d Mon Sep 17 00:00:00 2001 From: inanyan Date: Wed, 15 May 2024 13:28:37 +0300 Subject: [PATCH 14/30] Changed according to code review --- build.gradle | 5 - src/main/java/org/jabref/gui/Base.css | 4 + .../org/jabref/gui/entryeditor/AiChatTab.java | 187 ++++++++---------- .../org/jabref/gui/preferences/ai/AiTab.java | 7 +- .../gui/preferences/ai/AiTabViewModel.java | 10 +- src/main/java/org/jabref/logic/ai/AiChat.java | 70 ++----- .../java/org/jabref/logic/ai/AiChatData.java | 47 ----- .../java/org/jabref/logic/ai/AiIngestor.java | 82 ++++---- .../ai/{AiConnection.java => AiService.java} | 20 +- .../jabref/preferences/JabRefPreferences.java | 27 +-- src/main/resources/tinylog.properties | 5 - 11 files changed, 188 insertions(+), 276 deletions(-) delete mode 100644 src/main/java/org/jabref/logic/ai/AiChatData.java rename src/main/java/org/jabref/logic/ai/{AiConnection.java => AiService.java} (56%) diff --git a/build.gradle b/build.gradle index cb18812fb26..a310da64e7a 100644 --- a/build.gradle +++ b/build.gradle @@ -72,11 +72,6 @@ application { // Note that the arguments are cleared for the "run" task to avoid messages like "WARNING: Unknown module: org.jabref.merged.module specified to --add-exports" - // '--add-exports=langchain4j/dev.langchain4j.model.chat=org.jabref', - // '--add-exports=langchain4j/dev.langchain4j.model.chat=org.jabref.merged.module', - // '--add-exports=langchain4j/dev.langchain4j.model.openai=org.jabref', - // '--add-exports=langchain4j/dev.langchain4j.model.openai=org.jabref.merged.module', - // Fix for https://github.com/JabRef/jabref/issues/11188 '--add-exports=javafx.base/com.sun.javafx.event=org.jabref.merged.module', '--add-exports=javafx.controls/com.sun.javafx.scene.control=org.jabref.merged.module', diff --git a/src/main/java/org/jabref/gui/Base.css b/src/main/java/org/jabref/gui/Base.css index c0fd90990ce..fa0a90f5a53 100644 --- a/src/main/java/org/jabref/gui/Base.css +++ b/src/main/java/org/jabref/gui/Base.css @@ -252,6 +252,10 @@ /* Consistent size for headers of tab-pane and side-panels*/ -jr-header-height: 3em; + + /* AI chat style */ + -jr-ai-message-user: #7ee3fb; + -jr-ai-message-ai: #bac8cb; } .unchanged { diff --git a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java index 31ce5e19b85..9566af3b3dd 100644 --- a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java @@ -1,6 +1,6 @@ package org.jabref.gui.entryeditor; -import java.util.List; +import java.nio.file.Path; import javafx.geometry.Insets; import javafx.geometry.NodeOrientation; @@ -18,10 +18,10 @@ import org.jabref.gui.DialogService; import org.jabref.logic.ai.AiChat; -import org.jabref.logic.ai.AiChatData; -import org.jabref.logic.ai.AiConnection; +import org.jabref.logic.ai.AiService; import org.jabref.logic.ai.AiIngestor; import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.io.FileUtil; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.LinkedFile; @@ -32,24 +32,30 @@ import com.tobiasdiez.easybind.EasyBind; import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.ChatMessageType; import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.store.embedding.EmbeddingStore; +import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; public class AiChatTab extends EntryEditorTab { public static final String NAME = "AI chat"; private final DialogService dialogService; - private final FilePreferences filePreferences; private final AiPreferences aiPreferences; - private final BibDatabaseContext bibDatabaseContext; - private AiConnection aiConnection = null; + private VBox chatVBox = null; + + private AiService aiService = null; private AiChat aiChat = null; - private VBox chatVBox = null; + // TODO: This field should somehow live in bib entry. + private EmbeddingStore currentEmbeddingStore = null; - public AiChatTab(DialogService dialogService, PreferencesService preferencesService, BibDatabaseContext bibDatabaseContext) { + public AiChatTab(DialogService dialogService, PreferencesService preferencesService, + BibDatabaseContext bibDatabaseContext) { this.dialogService = dialogService; this.filePreferences = preferencesService.getFilePreferences(); @@ -63,36 +69,32 @@ public AiChatTab(DialogService dialogService, PreferencesService preferencesServ setUpAiConnection(); } - // Set up the AI connection if AI is used. - // Also listen for AI preferences changes and update the classes appropriately. private void setUpAiConnection() { if (aiPreferences.isUseAi()) { - aiConnection = new AiConnection(aiPreferences.getOpenAiToken()); + aiService = new AiService(aiPreferences.getOpenAiToken()); } EasyBind.listen(aiPreferences.useAiProperty(), (obs, oldValue, newValue) -> { if (newValue) { - aiConnection = new AiConnection(aiPreferences.getOpenAiToken()); + aiService = new AiService(aiPreferences.getOpenAiToken()); rebuildAiChat(); } else { - aiConnection = null; - // QUESTION: If user chose AI but then unchooses, what should we do with the AI chat? + aiService = null; aiChat = null; } }); EasyBind.listen(aiPreferences.openAiTokenProperty(), (obs, oldValue, newValue) -> { - if (aiConnection != null) { - aiConnection = new AiConnection(newValue); + if (aiService != null) { + aiService = new AiService(newValue); rebuildAiChat(); } }); } private void rebuildAiChat() { - if (aiChat != null) { - AiChatData data = aiChat.getData(); - aiChat = new AiChat(data, aiConnection); + if (aiChat != null && currentEmbeddingStore != null) { + aiChat = new AiChat(aiService, currentEmbeddingStore); } } @@ -103,89 +105,100 @@ public boolean shouldShow(BibEntry entry) { @Override protected void bindToEntry(BibEntry entry) { - Node node; - if (entry.getFiles().isEmpty()) { - node = stateNoFiles(); - } else if (!entry.getFiles().stream().allMatch(file -> "PDF".equals(file.getFileType()))) { - /* - QUESTION: What is the type of file.getFileType()???? - I thought it is the part after the dot, but it turns out not. - I got the "PDF" string by looking at tests. - */ - node = stateWrongFilesFormat(); + setContent(new Label(Localization.lang("No files attached"))); + } else if (!entry.getFiles().stream().map(LinkedFile::getLink).map(Path::of).allMatch(FileUtil::isPDFFile)) { + setContent(new Label(Localization.lang("Only PDF files are supported"))); } else { - configureAiChat(entry); - node = stateAiChat(); - restoreMessages(aiChat.getData().getChatMemoryStore().getMessages(aiChat.getChatId())); + bindToCorrectEntry(entry); } - - setContent(node); } - private Node stateNoFiles() { - return new Label(Localization.lang("No files attached")); + private void bindToCorrectEntry(BibEntry entry) { + configureAiChat(entry); + setContent(createAiChatUI()); } - private Node stateWrongFilesFormat() { - return new Label(Localization.lang("Only PDF files are supported")); - } + private void configureAiChat(BibEntry entry) { + EmbeddingStore embeddingStore = new InMemoryEmbeddingStore<>(); + + aiChat = new AiChat(aiService, embeddingStore); - private Node stateAiChat() { - // Don't bully me for this style. + AiIngestor ingestor = new AiIngestor(embeddingStore, aiService.getEmbeddingModel()); + for (LinkedFile linkedFile : entry.getFiles()) { + try { + ingestor.ingestLinkedFile(linkedFile, bibDatabaseContext, filePreferences); + } catch (Exception e) { + dialogService.notify(Localization.lang("An error occurred while loading a file into the AI") + + ":\n" + + e.getMessage() + "\n" + + Localization.lang("This file will be skipped") + "."); + } + } + + currentEmbeddingStore = embeddingStore; + } + + private Node createAiChatUI() { VBox aiChatBox = new VBox(10); aiChatBox.setPadding(new Insets(10)); - ScrollPane chatScrollPane = new ScrollPane(); - chatScrollPane.setStyle("-fx-border-color: black;"); - chatScrollPane.setPadding(new Insets(10, 10, 0, 10)); - VBox.setVgrow(chatScrollPane, Priority.ALWAYS); - - chatVBox = new VBox(10); + aiChatBox.getChildren().add(constructChatScrollPane()); + aiChatBox.getChildren().add(constructUserPromptBox()); - // Chat messages will be children of chatVBox. + return aiChatBox; + } - chatScrollPane.setContent(chatVBox); + private Node constructChatScrollPane() { + ScrollPane chatScrollPane = new ScrollPane(); + chatScrollPane.setStyle("-fx-border-color: black;"); + chatScrollPane.setPadding(new Insets(10, 10, 0, 10)); + VBox.setVgrow(chatScrollPane, Priority.ALWAYS); - aiChatBox.getChildren().add(chatScrollPane); + chatVBox = new VBox(10); + aiService.getChatMemoryStore().getMessages(aiChat.getChatId()).forEach(this::addMessage); + chatScrollPane.setContent(chatVBox); - HBox userPromptHBox = new HBox(10); - userPromptHBox.setAlignment(Pos.CENTER); + return chatScrollPane; + } - TextField userPromptTextField = new TextField(); - HBox.setHgrow(userPromptTextField, Priority.ALWAYS); + private Node constructUserPromptBox() { + HBox userPromptHBox = new HBox(10); + userPromptHBox.setAlignment(Pos.CENTER); - userPromptHBox.getChildren().add(userPromptTextField); + TextField userPromptTextField = new TextField(); + HBox.setHgrow(userPromptTextField, Priority.ALWAYS); - Button userPromptSubmitButton = new Button(Localization.lang("Submit")); - userPromptSubmitButton.setOnAction(e -> { - String userPrompt = userPromptTextField.getText(); - userPromptTextField.setText(""); + userPromptHBox.getChildren().add(userPromptTextField); - addMessage(true, userPrompt); + Button userPromptSubmitButton = new Button(Localization.lang("Submit")); + userPromptSubmitButton.setOnAction(e -> { + String userPrompt = userPromptTextField.getText(); + userPromptTextField.setText(""); - String aiMessage = aiChat.execute(userPrompt); + addMessage(new UserMessage(userPrompt)); - addMessage(false, aiMessage); - }); + String aiMessage = aiChat.execute(userPrompt); - userPromptHBox.getChildren().add(userPromptSubmitButton); + addMessage(new AiMessage(aiMessage)); + }); - aiChatBox.getChildren().add(userPromptHBox); + userPromptHBox.getChildren().add(userPromptSubmitButton); - return aiChatBox; + return userPromptHBox; } - private void addMessage(boolean isUser, String text) { - Node messageNode = generateMessage(isUser, text); - chatVBox.getChildren().add(messageNode); + private void addMessage(ChatMessage chatMessage) { + if (chatMessage.type() == ChatMessageType.AI || chatMessage.type() == ChatMessageType.USER) { + Node messageNode = constructMessageNode(chatMessage); + chatVBox.getChildren().add(messageNode); + } } - private static final String USER_MESSAGE_COLOR = "#7ee3fb"; - private static final String AI_MESSAGE_COLOR = "#bac8cb"; + private static Node constructMessageNode(ChatMessage chatMessage) { + boolean isUser = chatMessage.type() == ChatMessageType.USER; - private static Node generateMessage(boolean isUser, String text) { Pane pane = new Pane(); if (isUser) { @@ -194,14 +207,14 @@ private static Node generateMessage(boolean isUser, String text) { VBox paneVBox = new VBox(10); - paneVBox.setStyle("-fx-background-color: " + (isUser ? USER_MESSAGE_COLOR : AI_MESSAGE_COLOR) + ";"); + paneVBox.setStyle("-fx-background-color: " + (isUser ? "-jr-ar-message-user" : "-jr-ai-message-ai") + ";"); paneVBox.setPadding(new Insets(10)); Label authorLabel = new Label(Localization.lang(isUser ? "User" : "AI")); authorLabel.setStyle("-fx-font-weight: bold"); paneVBox.getChildren().add(authorLabel); - Label messageLabel = new Label(text); + Label messageLabel = new Label(chatMessage.text()); paneVBox.getChildren().add(messageLabel); pane.getChildren().add(paneVBox); @@ -209,30 +222,4 @@ private static Node generateMessage(boolean isUser, String text) { return pane; } - private void configureAiChat(BibEntry entry) { - aiChat = new AiChat(aiConnection); - - AiIngestor ingestor = new AiIngestor(aiChat.getData().getEmbeddingStore(), aiConnection.getEmbeddingModel()); - - for (LinkedFile linkedFile : entry.getFiles()) { - try { - ingestor.ingestLinkedFile(linkedFile, bibDatabaseContext, filePreferences); - } catch (Exception e) { - dialogService.showErrorDialogAndWait(Localization.lang("Error while loading file"), - Localization.lang("An error occurred while loading a file into the AI") + ":\n" - + e.getMessage() + "\n" - + Localization.lang("This file will be skipped") + "."); - } - } - } - - private void restoreMessages(List messages) { - for (ChatMessage message : messages) { - if (message instanceof UserMessage userMessage) { - addMessage(true, userMessage.singleText()); - } else if (message instanceof AiMessage aiMessage) { - addMessage(false, aiMessage.text()); - } - } - } } diff --git a/src/main/java/org/jabref/gui/preferences/ai/AiTab.java b/src/main/java/org/jabref/gui/preferences/ai/AiTab.java index e1af8a98138..98a963485cc 100644 --- a/src/main/java/org/jabref/gui/preferences/ai/AiTab.java +++ b/src/main/java/org/jabref/gui/preferences/ai/AiTab.java @@ -11,11 +11,8 @@ import com.airhacks.afterburner.views.ViewLoader; public class AiTab extends AbstractPreferenceTabView implements PreferencesTab { - @FXML - private CheckBox useAi; - - @FXML - private TextField openAiToken; + @FXML private CheckBox useAi; + @FXML private TextField openAiToken; public AiTab() { ViewLoader.view(this) diff --git a/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java b/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java index b971de37fa3..5c01c9c0d0e 100644 --- a/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java +++ b/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java @@ -15,17 +15,14 @@ import org.jabref.preferences.PreferencesService; public class AiTabViewModel implements PreferenceTabViewModel { + private static final String OPENAI_TOKEN_PATTERN_STRING = "sk-[A-Za-z0-9-_]*[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}"; + private static final Pattern OPENAI_TOKEN_PATTERN = Pattern.compile(OPENAI_TOKEN_PATTERN_STRING); + private final BooleanProperty useAi = new SimpleBooleanProperty(); private final StringProperty openAiToken = new SimpleStringProperty(); - - // What about a checkbox "Use AI in JabRef"? - private final AiPreferences aiPreferences; private final DialogService dialogService; - private static final String OPENAI_TOKEN_PATTERN_STRING = "sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20}"; - private static final Pattern OPENAI_TOKEN_PATTERN = Pattern.compile(OPENAI_TOKEN_PATTERN_STRING); - public AiTabViewModel(PreferencesService preferencesService, DialogService dialogService) { this.aiPreferences = preferencesService.getAiPreferences(); this.dialogService = dialogService; @@ -54,7 +51,6 @@ public boolean validateSettings() { private boolean validateOpenAiToken() { if (StringUtil.isBlank(openAiToken.get())) { - // Uhm, actually, it can be empty, if user does not want to use AI things. dialogService.showErrorDialogAndWait(Localization.lang("Format error"), Localization.lang("The OpenAI token cannot be empty")); return false; } diff --git a/src/main/java/org/jabref/logic/ai/AiChat.java b/src/main/java/org/jabref/logic/ai/AiChat.java index d5e25b2a317..61d7f202a1b 100644 --- a/src/main/java/org/jabref/logic/ai/AiChat.java +++ b/src/main/java/org/jabref/logic/ai/AiChat.java @@ -1,40 +1,36 @@ package org.jabref.logic.ai; +import java.util.UUID; + import dev.langchain4j.chain.ConversationalRetrievalChain; +import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.memory.ChatMemory; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.rag.content.retriever.ContentRetriever; import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; +import dev.langchain4j.store.embedding.EmbeddingStore; /** * This class maintains an AI chat. - * It depends on the AiConnection, which holds embedding and chat models. + * It depends on the {@link AiService}, which holds embedding and chat models. * This class was created for maintaining (hiding) the langchain4j state. *

- * AiChatData can be used to restore the serialized state of a chat. - *

- * An outer class is responsible for synchronizing objects of this class with AiPreference changes. + * An outer class is responsible for synchronizing objects of this class with {@link org.jabref.preferences.AiPreferences} changes. */ public class AiChat { - // Stores the embeddings and the chat history. - private final AiChatData data; + public static final int MESSAGE_WINDOW_SIZE = 10; + // The main class that executes user prompts. Maintains API calls and retrieval augmented generation (RAG). private final ConversationalRetrievalChain chain; - // TODO: It turns out ChatMemoryStore is a more global class than I though. Make it global. - // Initially I thought it is local to every chat. - // But no, ChatMemoryStore is a store for many different chats. - // In current implementation I use it as I said it first: different ChatMemoryStore for different chats, - // so I need ID for several algorithms. - private final Object chatId; - public AiChat(AiConnection aiConnection) { - this.data = new AiChatData(); + private final Object chatId; + public AiChat(AiService aiService, EmbeddingStore embeddingStore) { // This class is basically an "algorithm class" for retrieving the relevant contents of documents. ContentRetriever contentRetriever = EmbeddingStoreContentRetriever .builder() - .embeddingStore(data.getEmbeddingStore()) - .embeddingModel(aiConnection.getEmbeddingModel()) + .embeddingStore(embeddingStore) + .embeddingModel(aiService.getEmbeddingModel()) .build(); // This class is also an "algorithm class" that maintains the chat history. @@ -42,46 +38,16 @@ public AiChat(AiConnection aiConnection) { // there would be too many tokens. This class, for example, sends only the 10 last messages. ChatMemory chatMemory = MessageWindowChatMemory .builder() - .chatMemoryStore(data.getChatMemoryStore()) - .maxMessages(10) // This was the default value in the original implementation. + .chatMemoryStore(aiService.getChatMemoryStore()) + .maxMessages(MESSAGE_WINDOW_SIZE) // This was the default value in the original implementation. + .id(UUID.randomUUID()) .build(); this.chatId = chatMemory.id(); - // TODO: Investigate whether the ChatMemoryStore holds the whole chat history. - // As I think, ChatMemory should only send 10 last messages to the AI provider, but not remove the last - // messages from the store. - this.chain = ConversationalRetrievalChain .builder() - .chatLanguageModel(aiConnection.getChatModel()) - .contentRetriever(contentRetriever) - .chatMemory(chatMemory) - .build(); - } - - public AiChat(AiChatData data, AiConnection aiConnection) { - // Code duplication... - - this.data = data; - - ContentRetriever contentRetriever = EmbeddingStoreContentRetriever - .builder() - .embeddingStore(data.getEmbeddingStore()) - .embeddingModel(aiConnection.getEmbeddingModel()) - .build(); - - ChatMemory chatMemory = MessageWindowChatMemory - .builder() - .chatMemoryStore(data.getChatMemoryStore()) - .maxMessages(10) // This was the default value in the original implementation. - .build(); - - this.chatId = chatMemory.id(); - - this.chain = ConversationalRetrievalChain - .builder() - .chatLanguageModel(aiConnection.getChatModel()) + .chatLanguageModel(aiService.getChatModel()) .contentRetriever(contentRetriever) .chatMemory(chatMemory) .build(); @@ -91,10 +57,6 @@ public String execute(String prompt) { return chain.execute(prompt); } - public AiChatData getData() { - return data; - } - public Object getChatId() { return chatId; } diff --git a/src/main/java/org/jabref/logic/ai/AiChatData.java b/src/main/java/org/jabref/logic/ai/AiChatData.java deleted file mode 100644 index 7bfe1009eab..00000000000 --- a/src/main/java/org/jabref/logic/ai/AiChatData.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.jabref.logic.ai; - -import dev.langchain4j.data.segment.TextSegment; -import dev.langchain4j.store.embedding.EmbeddingStore; -import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; -import dev.langchain4j.store.memory.chat.ChatMemoryStore; -import dev.langchain4j.store.memory.chat.InMemoryChatMemoryStore; - -/** - * This class holds model agnostic information about one AI chat for a bib entry. - * It stores all the embeddings, generated from the full-text article in linked files of a bib entry, - * and the chat history. - *

- * This class could be used to serialize and deserialize the information for chat and/or recreate AiChat objects. - *

- * You may ask: why embeddings are stored in this class? Indeed, logically for one bib entry there could be many chats. - * But in current implementation of JabRef there is only one chat per bib entry. - * So in this class embeddings are also stored for convenience. - */ -public class AiChatData { - // It's important to notice that all of these fields have an interface type, so we can easily create the specific - // classes that we want in JabRef. - - // TODO: Investigate whether the embeddings generated from different models are compatible. - // I guess not, so we have to invalidate and regenerate the embeddings if the model is changed. - // Of course, that is the week 2, not week 1. - private final EmbeddingStore embeddingStore; - private final ChatMemoryStore chatMemoryStore; - - public AiChatData() { - this.embeddingStore = new InMemoryEmbeddingStore<>(); - this.chatMemoryStore = new InMemoryChatMemoryStore(); - } - - public AiChatData(EmbeddingStore embeddingStore, ChatMemoryStore chatMemoryStore) { - this.embeddingStore = embeddingStore; - this.chatMemoryStore = chatMemoryStore; - } - - public EmbeddingStore getEmbeddingStore() { - return embeddingStore; - } - - public ChatMemoryStore getChatMemoryStore() { - return chatMemoryStore; - } -} diff --git a/src/main/java/org/jabref/logic/ai/AiIngestor.java b/src/main/java/org/jabref/logic/ai/AiIngestor.java index 9e3ca6948c5..60e5962f16a 100644 --- a/src/main/java/org/jabref/logic/ai/AiIngestor.java +++ b/src/main/java/org/jabref/logic/ai/AiIngestor.java @@ -8,6 +8,7 @@ import org.jabref.logic.JabRefException; import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.io.FileUtil; import org.jabref.logic.xmp.XmpUtilReader; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.LinkedFile; @@ -26,62 +27,75 @@ import org.slf4j.LoggerFactory; /** - * This class is an "algorithm class". Meaning it is used in one place and is thrown away quickly. - *

* This class contains a bunch of methods that are useful for loading the documents to AI. + *

+ * This class is an "algorithm class". Meaning it is used in one place and is thrown away quickly. */ public class AiIngestor { + private static final Logger LOGGER = LoggerFactory.getLogger(AiIngestor.class.getName()); + + public static final int DOCUMENT_SPLITTER_MAX_SEGMENT_SIZE_IN_CHARS = 300; + public static final int DOCUMENT_SPLITTER_MAX_OVERLAP_SIZE_IN_CHARS = 0; + // Another "algorithm class" that ingests the contents of the file into the embedding store. private final EmbeddingStoreIngestor ingestor; - private static final Logger LOGGER = LoggerFactory.getLogger(AiIngestor.class.getName()); - public AiIngestor(EmbeddingStore embeddingStore, EmbeddingModel embeddingModel) { - // TODO: Tweak the parameters of this object. - DocumentSplitter documentSplitter = DocumentSplitters.recursive(300, 0); + DocumentSplitter documentSplitter = DocumentSplitters + .recursive(DOCUMENT_SPLITTER_MAX_SEGMENT_SIZE_IN_CHARS, DOCUMENT_SPLITTER_MAX_OVERLAP_SIZE_IN_CHARS); this.ingestor = EmbeddingStoreIngestor .builder() .embeddingStore(embeddingStore) - .embeddingModel(embeddingModel) // What if null? + .embeddingModel(embeddingModel) .documentSplitter(documentSplitter) .build(); } - public void ingestString(String contents) { - LOGGER.trace("Ingesting: {}", contents); - Document document = new Document(contents); - ingestor.ingest(document); + public void ingestLinkedFile(LinkedFile linkedFile, BibDatabaseContext bibDatabaseContext, FilePreferences filePreferences) { + // TODO: Ingest not only the contents of documents, but also their metadata. + // This will help the AI to identify a document while performing a QA session over several bib entries. + // Useful link: https://docs.langchain4j.dev/tutorials/rag/#metadata. + + Optional path = linkedFile.findIn(bibDatabaseContext, filePreferences); + if (path.isPresent()) { + ingestFile(path.get()); + } else { + LOGGER.error("Could not find path for a linked file: " + linkedFile.getLink()); + } } - public void ingestPDFFile(Path path) throws IOException { - PDDocument document = new XmpUtilReader().loadWithAutomaticDecryption(path); - PDFTextStripper stripper = new PDFTextStripper(); + public void ingestFile(Path path) { + if (FileUtil.isPDFFile(path)) { + ingestPDFFile(path); + } else { + LOGGER.info("Usupported file type of file: " + path + ". For now, only PDF files are supported"); + } + } - int lastPage = document.getNumberOfPages(); - stripper.setStartPage(1); - stripper.setEndPage(lastPage); - StringWriter writer = new StringWriter(); - stripper.writeText(document, writer); + public void ingestPDFFile(Path path) { + try { + PDDocument document = new XmpUtilReader().loadWithAutomaticDecryption(path); + PDFTextStripper stripper = new PDFTextStripper(); - String result = writer.toString(); + int lastPage = document.getNumberOfPages(); + stripper.setStartPage(1); + stripper.setEndPage(lastPage); + StringWriter writer = new StringWriter(); + stripper.writeText(document, writer); - ingestString(result); + ingestString(writer.toString()); + } catch (Exception e) { + LOGGER.error("An error occurred while reading a PDF file: " + path, e); + } } - public void ingestLinkedFile(LinkedFile linkedFile, BibDatabaseContext bibDatabaseContext, FilePreferences filePreferences) throws IOException, JabRefException { - if (!"PDF".equals(linkedFile.getFileType())) { - String errorMsg = Localization.lang("Unsupported file type") + ": " - + linkedFile.getFileType() + ". " - + Localization.lang("Only PDF files are supported") + "."; - throw new JabRefException(errorMsg); - } + public void ingestString(String string) { + ingestDocument(new Document(string)); + } - Optional path = linkedFile.findIn(bibDatabaseContext, filePreferences); - if (path.isPresent()) { - ingestPDFFile(path.get()); - } else { - throw new FileNotFoundException(linkedFile.getLink()); - } + public void ingestDocument(Document document) { + LOGGER.trace("Ingesting: {}", document); + ingestor.ingest(document); } } diff --git a/src/main/java/org/jabref/logic/ai/AiConnection.java b/src/main/java/org/jabref/logic/ai/AiService.java similarity index 56% rename from src/main/java/org/jabref/logic/ai/AiConnection.java rename to src/main/java/org/jabref/logic/ai/AiService.java index 055fc76ef85..0d2715402a4 100644 --- a/src/main/java/org/jabref/logic/ai/AiConnection.java +++ b/src/main/java/org/jabref/logic/ai/AiService.java @@ -4,20 +4,24 @@ import dev.langchain4j.model.embedding.AllMiniLmL6V2EmbeddingModel; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.store.memory.chat.ChatMemoryStore; +import dev.langchain4j.store.memory.chat.InMemoryChatMemoryStore; /** * This class maintains the connection to AI services. - * This is a global state of AI, and AiChat's use this class. + * This is a global state of AI, and {@link AiChat}'s use this class. *

- * An outer class is responsible for synchronizing objects of this class with AiPreference changes. + * An outer class is responsible for synchronizing objects of this class with {@link org.jabref.preferences.AiPreferences} changes. */ -public class AiConnection { +public class AiService { private final ChatLanguageModel chatModel; private final EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel(); + private final ChatMemoryStore chatMemoryStore = new InMemoryChatMemoryStore(); + + public AiService(String apiKey) { + // Later this class can accepts different enums or other pieces of information in order + // to construct different chat models. - // Later this class can accepts different enums or other pices of information in order - // to construct different chat models. - public AiConnection(String apiKey) { this.chatModel = OpenAiChatModel .builder() .apiKey(apiKey) @@ -31,4 +35,8 @@ public ChatLanguageModel getChatModel() { public EmbeddingModel getEmbeddingModel() { return embeddingModel; } + + public ChatMemoryStore getChatMemoryStore() { + return chatMemoryStore; + } } diff --git a/src/main/java/org/jabref/preferences/JabRefPreferences.java b/src/main/java/org/jabref/preferences/JabRefPreferences.java index a6c5f621dd7..e44dcf666a6 100644 --- a/src/main/java/org/jabref/preferences/JabRefPreferences.java +++ b/src/main/java/org/jabref/preferences/JabRefPreferences.java @@ -128,10 +128,8 @@ import com.github.javakeyring.Keyring; import com.github.javakeyring.PasswordAccessException; import com.tobiasdiez.easybind.EasyBind; -import dev.langchain4j.model.embedding.AllMiniLmL6V2EmbeddingModel; -import dev.langchain4j.model.embedding.EmbeddingModel; -import dev.langchain4j.model.openai.OpenAiChatModel; import jakarta.inject.Singleton; +import org.jspecify.annotations.Nullable; import org.jvnet.hk2.annotations.Service; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -449,7 +447,6 @@ public class JabRefPreferences implements PreferencesService { private static final String USE_REMOTE_SERVER = "useRemoteServer"; private static final String REMOTE_SERVER_PORT = "remoteServerPort"; - // AI private static final String USE_AI = "useAi"; private static final Logger LOGGER = LoggerFactory.getLogger(JabRefPreferences.class); @@ -2717,8 +2714,16 @@ public AiPreferences getAiPreferences() { return aiPreferences; } + boolean useAi = getBoolean(USE_AI); + String token = getOpenAiTokenFromKeyring(); - aiPreferences = new AiPreferences(getBoolean(USE_AI), token); + + // To ensure that the token is never null or empty while useAi is true. + if (token == null) { + useAi = false; + } + + aiPreferences = new AiPreferences(useAi, token); EasyBind.listen(aiPreferences.openAiTokenProperty(), (obs, oldValue, newValue) -> storeOpenAiTokenToKeyring(newValue)); EasyBind.listen(aiPreferences.useAiProperty(), (obs, oldValue, newValue) -> putBoolean(USE_AI, newValue)); @@ -2726,16 +2731,14 @@ public AiPreferences getAiPreferences() { return aiPreferences; } - private String getOpenAiTokenFromKeyring() { + private @Nullable String getOpenAiTokenFromKeyring() { try (final Keyring keyring = Keyring.create()) { String rawPassword = keyring.getPassword("org.jabref.customapikeys", "openaitoken"); Password password = new Password(rawPassword, getInternalPreferences().getUserAndHost()); return password.decrypt(); } catch (Exception e) { - // What to do in this place? - // There are many different error types. - LOGGER.warn("JabRef could not open keyring for retrieving OpenAI API token"); - return ""; // What to return? Is empty key valid? + LOGGER.warn("JabRef could not open keyring for retrieving OpenAI API token", e); + return null; } } @@ -2745,9 +2748,7 @@ private void storeOpenAiTokenToKeyring(String newToken) { String rawPassword = password.encrypt(); keyring.setPassword("org.jabref.customapikeys", "openaitoken", rawPassword); } catch (Exception e) { - // What to do in this place? - // There are many different error types. - LOGGER.warn("JabRef could not open keyring for retrieving OpenAI API token"); + LOGGER.warn("JabRef could not open keyring for retrieving OpenAI API token", e); } } diff --git a/src/main/resources/tinylog.properties b/src/main/resources/tinylog.properties index 9fc7647a777..aec2749f58e 100644 --- a/src/main/resources/tinylog.properties +++ b/src/main/resources/tinylog.properties @@ -14,9 +14,4 @@ level@org.jabref.http.server.Server = debug level@org.jabref.logic.ai.AiIngestor = trace -# TODO: Set up properly. -langchain4j.open-ai.chat-model.log-requests = true -langchain4j.open-ai.chat-model.log-responses = true -logging.level.dev.langchain4j = DEBUG -logging.level.dev.ai4j.openai4j = DEBUG From 9ffeaa4b223f9ebade52a480dfe1f1fcc53fb5ef Mon Sep 17 00:00:00 2001 From: inanyan Date: Wed, 15 May 2024 13:35:20 +0300 Subject: [PATCH 15/30] Deleted check for OpenAI token format --- .../org/jabref/gui/preferences/ai/AiTabViewModel.java | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java b/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java index 5c01c9c0d0e..f7200119fbc 100644 --- a/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java +++ b/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java @@ -1,7 +1,5 @@ package org.jabref.gui.preferences.ai; -import java.util.regex.Pattern; - import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleStringProperty; @@ -15,9 +13,6 @@ import org.jabref.preferences.PreferencesService; public class AiTabViewModel implements PreferenceTabViewModel { - private static final String OPENAI_TOKEN_PATTERN_STRING = "sk-[A-Za-z0-9-_]*[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}"; - private static final Pattern OPENAI_TOKEN_PATTERN = Pattern.compile(OPENAI_TOKEN_PATTERN_STRING); - private final BooleanProperty useAi = new SimpleBooleanProperty(); private final StringProperty openAiToken = new SimpleStringProperty(); private final AiPreferences aiPreferences; @@ -55,11 +50,6 @@ private boolean validateOpenAiToken() { return false; } - if (!OPENAI_TOKEN_PATTERN.matcher(openAiToken.get()).matches()) { - dialogService.showErrorDialogAndWait(Localization.lang("Format error"), Localization.lang("The OpenAI token is not valid")); - return false; - } - return true; } From 3bf8ac1176aefb706021fb7fe77c08a03df02b94 Mon Sep 17 00:00:00 2001 From: inanyan Date: Wed, 15 May 2024 14:42:40 +0300 Subject: [PATCH 16/30] Tried to solve the bug with token --- src/main/java/org/jabref/gui/entryeditor/AiChatTab.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java index 9566af3b3dd..88d53497c24 100644 --- a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java @@ -75,17 +75,14 @@ private void setUpAiConnection() { } EasyBind.listen(aiPreferences.useAiProperty(), (obs, oldValue, newValue) -> { - if (newValue) { - aiService = new AiService(aiPreferences.getOpenAiToken()); - rebuildAiChat(); - } else { + if (!newValue) { aiService = null; aiChat = null; } }); EasyBind.listen(aiPreferences.openAiTokenProperty(), (obs, oldValue, newValue) -> { - if (aiService != null) { + if (!newValue.isEmpty()) { aiService = new AiService(newValue); rebuildAiChat(); } From 14b7c72fad1bd92092dc18a08e97df3bbd5bdbd7 Mon Sep 17 00:00:00 2001 From: inanyan Date: Wed, 15 May 2024 14:44:39 +0300 Subject: [PATCH 17/30] Tried to solve the bug with token --- src/main/java/org/jabref/gui/entryeditor/AiChatTab.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java index 88d53497c24..036c404e84f 100644 --- a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java @@ -75,7 +75,10 @@ private void setUpAiConnection() { } EasyBind.listen(aiPreferences.useAiProperty(), (obs, oldValue, newValue) -> { - if (!newValue) { + if (newValue && !aiPreferences.getOpenAiToken().isEmpty()) { + aiService = new AiService(aiPreferences.getOpenAiToken()); + rebuildAiChat(); + } else { aiService = null; aiChat = null; } From 784434fd4a65e4019d19497cab49b63f33ba91b2 Mon Sep 17 00:00:00 2001 From: inanyan Date: Fri, 17 May 2024 08:39:01 +0300 Subject: [PATCH 18/30] Split preferences: "Show AI chat tab" / "Chat with PDFs" --- .../org/jabref/gui/entryeditor/AiChatTab.java | 26 ++++++++++++++----- .../entryeditor/EntryEditorPreferences.java | 15 +++++++++++ .../org/jabref/gui/preferences/ai/AiTab.fxml | 2 +- .../org/jabref/gui/preferences/ai/AiTab.java | 8 +++--- .../gui/preferences/ai/AiTabViewModel.java | 4 +-- .../entryeditor/EntryEditorTab.fxml | 1 + .../entryeditor/EntryEditorTab.java | 2 ++ .../entryeditor/EntryEditorTabViewModel.java | 7 +++++ src/main/java/org/jabref/logic/ai/AiChat.java | 18 +++++++++---- .../java/org/jabref/logic/ai/AiIngestor.java | 2 +- .../org/jabref/preferences/AiPreferences.java | 18 ++++++------- .../jabref/preferences/JabRefPreferences.java | 6 ++++- 12 files changed, 80 insertions(+), 29 deletions(-) diff --git a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java index 036c404e84f..130b69f1de7 100644 --- a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java @@ -41,9 +41,16 @@ public class AiChatTab extends EntryEditorTab { public static final String NAME = "AI chat"; + private static final String QA_SYSTEM_MESSAGE = """ + You are an AU research assistant. You read and analyze scientific articles. + The user will send you a question regarding a paper. You will be supplied also with the relevant information found in the article. + Answer the question only by using the relevant information. Don't make up the answer. + If you can't answer the user question using the provided information, then reply that you couldn't do it."""; + private final DialogService dialogService; private final FilePreferences filePreferences; private final AiPreferences aiPreferences; + private final EntryEditorPreferences entryEditorPreferences; private final BibDatabaseContext bibDatabaseContext; private VBox chatVBox = null; @@ -60,6 +67,7 @@ public AiChatTab(DialogService dialogService, PreferencesService preferencesServ this.filePreferences = preferencesService.getFilePreferences(); this.aiPreferences = preferencesService.getAiPreferences(); + this.entryEditorPreferences = preferencesService.getEntryEditorPreferences(); this.bibDatabaseContext = bibDatabaseContext; @@ -70,11 +78,11 @@ public AiChatTab(DialogService dialogService, PreferencesService preferencesServ } private void setUpAiConnection() { - if (aiPreferences.isUseAi()) { + if (aiPreferences.getEnableChatWithFiles()) { aiService = new AiService(aiPreferences.getOpenAiToken()); } - EasyBind.listen(aiPreferences.useAiProperty(), (obs, oldValue, newValue) -> { + EasyBind.listen(aiPreferences.enableChatWithFilesProperty(), (obs, oldValue, newValue) -> { if (newValue && !aiPreferences.getOpenAiToken().isEmpty()) { aiService = new AiService(aiPreferences.getOpenAiToken()); rebuildAiChat(); @@ -95,17 +103,20 @@ private void setUpAiConnection() { private void rebuildAiChat() { if (aiChat != null && currentEmbeddingStore != null) { aiChat = new AiChat(aiService, currentEmbeddingStore); + aiChat.setSystemMessage(QA_SYSTEM_MESSAGE); } } @Override public boolean shouldShow(BibEntry entry) { - return aiPreferences.isUseAi(); + return entryEditorPreferences.shouldShowAiChatTab(); } @Override protected void bindToEntry(BibEntry entry) { - if (entry.getFiles().isEmpty()) { + if (!aiPreferences.getEnableChatWithFiles()) { + setContent(new Label(Localization.lang("JabRef uses OpenAI to enable \"chatting\" with PDF files. OpenAI is an external service. To enable JabRef chatgting with PDF files, the content of the PDF files need to be shared with OpenAI. As soon as you ask a question, the text content of all PDFs attached to the entry are send to OpenAI. The privacy policy of OpenAI applies. You find it at ."))); + } else if (entry.getFiles().isEmpty()) { setContent(new Label(Localization.lang("No files attached"))); } else if (!entry.getFiles().stream().map(LinkedFile::getLink).map(Path::of).allMatch(FileUtil::isPDFFile)) { setContent(new Label(Localization.lang("Only PDF files are supported"))); @@ -123,6 +134,7 @@ private void configureAiChat(BibEntry entry) { EmbeddingStore embeddingStore = new InMemoryEmbeddingStore<>(); aiChat = new AiChat(aiService, embeddingStore); + aiChat.setSystemMessage(QA_SYSTEM_MESSAGE); AiIngestor ingestor = new AiIngestor(embeddingStore, aiService.getEmbeddingModel()); @@ -152,11 +164,12 @@ private Node createAiChatUI() { private Node constructChatScrollPane() { ScrollPane chatScrollPane = new ScrollPane(); + chatScrollPane.setFitToWidth(true); chatScrollPane.setStyle("-fx-border-color: black;"); - chatScrollPane.setPadding(new Insets(10, 10, 0, 10)); VBox.setVgrow(chatScrollPane, Priority.ALWAYS); chatVBox = new VBox(10); + chatVBox.setPadding(new Insets(10, 10, 0, 10)); aiService.getChatMemoryStore().getMessages(aiChat.getChatId()).forEach(this::addMessage); chatScrollPane.setContent(chatVBox); @@ -206,8 +219,9 @@ private static Node constructMessageNode(ChatMessage chatMessage) { } VBox paneVBox = new VBox(10); + paneVBox.setMaxWidth(500); - paneVBox.setStyle("-fx-background-color: " + (isUser ? "-jr-ar-message-user" : "-jr-ai-message-ai") + ";"); + paneVBox.setStyle("-fx-background-color: " + (isUser ? "-jr-ai-message-user" : "-jr-ai-message-ai") + ";"); paneVBox.setPadding(new Insets(10)); Label authorLabel = new Label(Localization.lang(isUser ? "User" : "AI")); diff --git a/src/main/java/org/jabref/gui/entryeditor/EntryEditorPreferences.java b/src/main/java/org/jabref/gui/entryeditor/EntryEditorPreferences.java index ae0fda0194c..7a0cf3efb1d 100644 --- a/src/main/java/org/jabref/gui/entryeditor/EntryEditorPreferences.java +++ b/src/main/java/org/jabref/gui/entryeditor/EntryEditorPreferences.java @@ -40,6 +40,7 @@ public static JournalPopupEnabled fromString(String status) { private final MapProperty> defaultEntryEditorTabList; private final BooleanProperty shouldOpenOnNewEntry; private final BooleanProperty shouldShowRecommendationsTab; + private final BooleanProperty shouldShowAiChatTab; private final BooleanProperty shouldShowLatexCitationsTab; private final BooleanProperty showSourceTabByDefault; private final BooleanProperty enableValidation; @@ -54,6 +55,7 @@ public EntryEditorPreferences(Map> entryEditorTabList, Map> defaultEntryEditorTabList, boolean shouldOpenOnNewEntry, boolean shouldShowRecommendationsTab, + boolean shouldShowAiChatTab, boolean shouldShowLatexCitationsTab, boolean showSourceTabByDefault, boolean enableValidation, @@ -68,6 +70,7 @@ public EntryEditorPreferences(Map> entryEditorTabList, this.defaultEntryEditorTabList = new SimpleMapProperty<>(FXCollections.observableMap(defaultEntryEditorTabList)); this.shouldOpenOnNewEntry = new SimpleBooleanProperty(shouldOpenOnNewEntry); this.shouldShowRecommendationsTab = new SimpleBooleanProperty(shouldShowRecommendationsTab); + this.shouldShowAiChatTab = new SimpleBooleanProperty(shouldShowAiChatTab); this.shouldShowLatexCitationsTab = new SimpleBooleanProperty(shouldShowLatexCitationsTab); this.showSourceTabByDefault = new SimpleBooleanProperty(showSourceTabByDefault); this.enableValidation = new SimpleBooleanProperty(enableValidation); @@ -119,6 +122,18 @@ public void setShouldShowRecommendationsTab(boolean shouldShowRecommendationsTab this.shouldShowRecommendationsTab.set(shouldShowRecommendationsTab); } + public boolean shouldShowAiChatTab() { + return shouldShowAiChatTab.get(); + } + + public BooleanProperty shouldShowAiChatTabProperty() { + return shouldShowAiChatTab; + } + + public void setShouldShowAiChatTab(boolean shouldShowAiChatTab) { + this.shouldShowAiChatTab.set(shouldShowAiChatTab); + } + public boolean shouldShowLatexCitationsTab() { return shouldShowLatexCitationsTab.get(); } diff --git a/src/main/java/org/jabref/gui/preferences/ai/AiTab.fxml b/src/main/java/org/jabref/gui/preferences/ai/AiTab.fxml index d349e6cbe03..6409dd42c98 100644 --- a/src/main/java/org/jabref/gui/preferences/ai/AiTab.fxml +++ b/src/main/java/org/jabref/gui/preferences/ai/AiTab.fxml @@ -5,7 +5,7 @@ - + + diff --git a/src/main/java/org/jabref/gui/preferences/entryeditor/EntryEditorTab.java b/src/main/java/org/jabref/gui/preferences/entryeditor/EntryEditorTab.java index 5e5d35cf228..20e733f0d02 100644 --- a/src/main/java/org/jabref/gui/preferences/entryeditor/EntryEditorTab.java +++ b/src/main/java/org/jabref/gui/preferences/entryeditor/EntryEditorTab.java @@ -22,6 +22,7 @@ public class EntryEditorTab extends AbstractPreferenceTabView embeddingStore) { // This class is basically an "algorithm class" for retrieving the relevant contents of documents. @@ -31,20 +34,20 @@ public AiChat(AiService aiService, EmbeddingStore embeddingStore) { .builder() .embeddingStore(embeddingStore) .embeddingModel(aiService.getEmbeddingModel()) + .maxResults(RAG_MAX_RESULTS) + .minScore(RAG_MIN_SCORE) .build(); // This class is also an "algorithm class" that maintains the chat history. // An algorithm for managing chat history is needed because you cannot stuff the whole history for the AI: // there would be too many tokens. This class, for example, sends only the 10 last messages. - ChatMemory chatMemory = MessageWindowChatMemory + this.chatMemory = MessageWindowChatMemory .builder() .chatMemoryStore(aiService.getChatMemoryStore()) .maxMessages(MESSAGE_WINDOW_SIZE) // This was the default value in the original implementation. .id(UUID.randomUUID()) .build(); - this.chatId = chatMemory.id(); - this.chain = ConversationalRetrievalChain .builder() .chatLanguageModel(aiService.getChatModel()) @@ -53,11 +56,16 @@ public AiChat(AiService aiService, EmbeddingStore embeddingStore) { .build(); } + public void setSystemMessage(String message) { + // ChatMemory automatically manages that there is only one system message. + this.chatMemory.add(new SystemMessage(message)); + } + public String execute(String prompt) { return chain.execute(prompt); } public Object getChatId() { - return chatId; + return this.chatMemory.id(); } } diff --git a/src/main/java/org/jabref/logic/ai/AiIngestor.java b/src/main/java/org/jabref/logic/ai/AiIngestor.java index 60e5962f16a..45e1c547dae 100644 --- a/src/main/java/org/jabref/logic/ai/AiIngestor.java +++ b/src/main/java/org/jabref/logic/ai/AiIngestor.java @@ -35,7 +35,7 @@ public class AiIngestor { private static final Logger LOGGER = LoggerFactory.getLogger(AiIngestor.class.getName()); public static final int DOCUMENT_SPLITTER_MAX_SEGMENT_SIZE_IN_CHARS = 300; - public static final int DOCUMENT_SPLITTER_MAX_OVERLAP_SIZE_IN_CHARS = 0; + public static final int DOCUMENT_SPLITTER_MAX_OVERLAP_SIZE_IN_CHARS = 30; // Another "algorithm class" that ingests the contents of the file into the embedding store. private final EmbeddingStoreIngestor ingestor; diff --git a/src/main/java/org/jabref/preferences/AiPreferences.java b/src/main/java/org/jabref/preferences/AiPreferences.java index 6818f00720d..8143ed31437 100644 --- a/src/main/java/org/jabref/preferences/AiPreferences.java +++ b/src/main/java/org/jabref/preferences/AiPreferences.java @@ -6,24 +6,24 @@ import javafx.beans.property.StringProperty; public class AiPreferences { - private final BooleanProperty useAi; + private final BooleanProperty enableChatWithFiles; private final StringProperty openAiToken; - public AiPreferences(boolean useAi, String openAiToken) { - this.useAi = new SimpleBooleanProperty(useAi); + public AiPreferences(boolean enableChatWithFiles, String openAiToken) { + this.enableChatWithFiles = new SimpleBooleanProperty(enableChatWithFiles); this.openAiToken = new SimpleStringProperty(openAiToken); } - public BooleanProperty useAiProperty() { - return useAi; + public BooleanProperty enableChatWithFilesProperty() { + return enableChatWithFiles; } - public boolean isUseAi() { - return useAi.get(); + public boolean getEnableChatWithFiles() { + return enableChatWithFiles.get(); } - public void setUseAi(boolean useAi) { - this.useAi.set(useAi); + public void setEnableChatWithFiles(boolean enableChatWithFiles) { + this.enableChatWithFiles.set(enableChatWithFiles); } public StringProperty openAiTokenProperty() { diff --git a/src/main/java/org/jabref/preferences/JabRefPreferences.java b/src/main/java/org/jabref/preferences/JabRefPreferences.java index e44dcf666a6..901a2e8f80d 100644 --- a/src/main/java/org/jabref/preferences/JabRefPreferences.java +++ b/src/main/java/org/jabref/preferences/JabRefPreferences.java @@ -336,6 +336,7 @@ public class JabRefPreferences implements PreferencesService { public static final String NAME_FORMATER_KEY = "nameFormatterNames"; public static final String PUSH_TO_APPLICATION = "pushToApplication"; public static final String SHOW_RECOMMENDATIONS = "showRecommendations"; + public static final String SHOW_AI_CHAT = "showAiChat"; public static final String ACCEPT_RECOMMENDATIONS = "acceptRecommendations"; public static final String SHOW_LATEX_CITATIONS = "showLatexCitations"; public static final String SEND_LANGUAGE_DATA = "sendLanguageData"; @@ -663,6 +664,7 @@ private JabRefPreferences() { defaults.put(SHOW_USER_COMMENTS_FIELDS, Boolean.TRUE); defaults.put(SHOW_RECOMMENDATIONS, Boolean.TRUE); + defaults.put(SHOW_AI_CHAT, Boolean.FALSE); defaults.put(ACCEPT_RECOMMENDATIONS, Boolean.FALSE); defaults.put(SHOW_LATEX_CITATIONS, Boolean.TRUE); defaults.put(SHOW_SCITE_TAB, Boolean.TRUE); @@ -1487,6 +1489,7 @@ public EntryEditorPreferences getEntryEditorPreferences() { getDefaultEntryEditorTabs(), getBoolean(AUTO_OPEN_FORM), getBoolean(SHOW_RECOMMENDATIONS), + getBoolean(SHOW_AI_CHAT), getBoolean(SHOW_LATEX_CITATIONS), getBoolean(DEFAULT_SHOW_SOURCE), getBoolean(VALIDATE_IN_ENTRY_EDITOR), @@ -1501,6 +1504,7 @@ public EntryEditorPreferences getEntryEditorPreferences() { // defaultEntryEditorTabs are read-only EasyBind.listen(entryEditorPreferences.shouldOpenOnNewEntryProperty(), (obs, oldValue, newValue) -> putBoolean(AUTO_OPEN_FORM, newValue)); EasyBind.listen(entryEditorPreferences.shouldShowRecommendationsTabProperty(), (obs, oldValue, newValue) -> putBoolean(SHOW_RECOMMENDATIONS, newValue)); + EasyBind.listen(entryEditorPreferences.shouldShowAiChatTabProperty(), (obs, oldValue, newValue) -> putBoolean(SHOW_AI_CHAT, newValue)); EasyBind.listen(entryEditorPreferences.shouldShowLatexCitationsTabProperty(), (obs, oldValue, newValue) -> putBoolean(SHOW_LATEX_CITATIONS, newValue)); EasyBind.listen(entryEditorPreferences.showSourceTabByDefaultProperty(), (obs, oldValue, newValue) -> putBoolean(DEFAULT_SHOW_SOURCE, newValue)); EasyBind.listen(entryEditorPreferences.enableValidationProperty(), (obs, oldValue, newValue) -> putBoolean(VALIDATE_IN_ENTRY_EDITOR, newValue)); @@ -2726,7 +2730,7 @@ public AiPreferences getAiPreferences() { aiPreferences = new AiPreferences(useAi, token); EasyBind.listen(aiPreferences.openAiTokenProperty(), (obs, oldValue, newValue) -> storeOpenAiTokenToKeyring(newValue)); - EasyBind.listen(aiPreferences.useAiProperty(), (obs, oldValue, newValue) -> putBoolean(USE_AI, newValue)); + EasyBind.listen(aiPreferences.enableChatWithFilesProperty(), (obs, oldValue, newValue) -> putBoolean(USE_AI, newValue)); return aiPreferences; } From 7fac426383550ad5cc98530690c5a585a904c802 Mon Sep 17 00:00:00 2001 From: InAnYan Date: Fri, 17 May 2024 11:58:54 +0300 Subject: [PATCH 19/30] Fix typos --- src/main/java/org/jabref/gui/entryeditor/AiChatTab.java | 2 +- src/main/java/org/jabref/preferences/JabRefPreferences.java | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java index 130b69f1de7..c2f70bc2236 100644 --- a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java @@ -42,7 +42,7 @@ public class AiChatTab extends EntryEditorTab { public static final String NAME = "AI chat"; private static final String QA_SYSTEM_MESSAGE = """ - You are an AU research assistant. You read and analyze scientific articles. + You are an AI research assistant. You read and analyze scientific articles. The user will send you a question regarding a paper. You will be supplied also with the relevant information found in the article. Answer the question only by using the relevant information. Don't make up the answer. If you can't answer the user question using the provided information, then reply that you couldn't do it."""; diff --git a/src/main/java/org/jabref/preferences/JabRefPreferences.java b/src/main/java/org/jabref/preferences/JabRefPreferences.java index 901a2e8f80d..979e5bf3679 100644 --- a/src/main/java/org/jabref/preferences/JabRefPreferences.java +++ b/src/main/java/org/jabref/preferences/JabRefPreferences.java @@ -664,7 +664,7 @@ private JabRefPreferences() { defaults.put(SHOW_USER_COMMENTS_FIELDS, Boolean.TRUE); defaults.put(SHOW_RECOMMENDATIONS, Boolean.TRUE); - defaults.put(SHOW_AI_CHAT, Boolean.FALSE); + defaults.put(SHOW_AI_CHAT, Boolean.TRUE); defaults.put(ACCEPT_RECOMMENDATIONS, Boolean.FALSE); defaults.put(SHOW_LATEX_CITATIONS, Boolean.TRUE); defaults.put(SHOW_SCITE_TAB, Boolean.TRUE); @@ -2725,6 +2725,7 @@ public AiPreferences getAiPreferences() { // To ensure that the token is never null or empty while useAi is true. if (token == null) { useAi = false; + token = ""; } aiPreferences = new AiPreferences(useAi, token); From 8a12515955a5677de60c8af6477b49d5946f4c8e Mon Sep 17 00:00:00 2001 From: InAnYan Date: Fri, 17 May 2024 13:06:10 +0300 Subject: [PATCH 20/30] Improve UI --- .../org/jabref/gui/entryeditor/AiChatTab.java | 95 +++++++++++++++---- .../jabref/gui/entryeditor/EntryEditor.java | 2 +- 2 files changed, 76 insertions(+), 21 deletions(-) diff --git a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java index c2f70bc2236..2e7097eadd6 100644 --- a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java @@ -2,21 +2,21 @@ import java.nio.file.Path; +import dev.langchain4j.agent.tool.P; +import javafx.application.Platform; import javafx.geometry.Insets; import javafx.geometry.NodeOrientation; import javafx.geometry.Pos; import javafx.scene.Node; -import javafx.scene.control.Button; -import javafx.scene.control.Label; -import javafx.scene.control.ScrollPane; -import javafx.scene.control.TextField; -import javafx.scene.control.Tooltip; +import javafx.scene.control.*; import javafx.scene.layout.HBox; import javafx.scene.layout.Pane; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.jabref.gui.DialogService; +import org.jabref.gui.util.BackgroundTask; +import org.jabref.gui.util.TaskExecutor; import org.jabref.logic.ai.AiChat; import org.jabref.logic.ai.AiService; import org.jabref.logic.ai.AiIngestor; @@ -37,10 +37,14 @@ import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; +import org.slf4j.LoggerFactory; +import org.tinylog.Logger; public class AiChatTab extends EntryEditorTab { public static final String NAME = "AI chat"; + private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(AiChatTab.class.getName()); + private static final String QA_SYSTEM_MESSAGE = """ You are an AI research assistant. You read and analyze scientific articles. The user will send you a question regarding a paper. You will be supplied also with the relevant information found in the article. @@ -52,8 +56,10 @@ public class AiChatTab extends EntryEditorTab { private final AiPreferences aiPreferences; private final EntryEditorPreferences entryEditorPreferences; private final BibDatabaseContext bibDatabaseContext; + private final TaskExecutor taskExecutor; private VBox chatVBox = null; + private TextField userPromptTextField = null; private AiService aiService = null; private AiChat aiChat = null; @@ -62,7 +68,7 @@ public class AiChatTab extends EntryEditorTab { private EmbeddingStore currentEmbeddingStore = null; public AiChatTab(DialogService dialogService, PreferencesService preferencesService, - BibDatabaseContext bibDatabaseContext) { + BibDatabaseContext bibDatabaseContext, TaskExecutor taskExecutor) { this.dialogService = dialogService; this.filePreferences = preferencesService.getFilePreferences(); @@ -71,6 +77,8 @@ public AiChatTab(DialogService dialogService, PreferencesService preferencesServ this.bibDatabaseContext = bibDatabaseContext; + this.taskExecutor = taskExecutor; + setText(Localization.lang(NAME)); setTooltip(new Tooltip(Localization.lang("AI chat with full-text article"))); @@ -169,10 +177,12 @@ private Node constructChatScrollPane() { VBox.setVgrow(chatScrollPane, Priority.ALWAYS); chatVBox = new VBox(10); - chatVBox.setPadding(new Insets(10, 10, 0, 10)); + chatVBox.setPadding(new Insets(10)); aiService.getChatMemoryStore().getMessages(aiChat.getChatId()).forEach(this::addMessage); chatScrollPane.setContent(chatVBox); + chatScrollPane.vvalueProperty().bind(chatVBox.heightProperty()); + return chatScrollPane; } @@ -180,32 +190,77 @@ private Node constructUserPromptBox() { HBox userPromptHBox = new HBox(10); userPromptHBox.setAlignment(Pos.CENTER); - TextField userPromptTextField = new TextField(); + userPromptTextField = new TextField(); HBox.setHgrow(userPromptTextField, Priority.ALWAYS); + userPromptTextField.setOnAction(e -> sendMessageToAiEvent()); userPromptHBox.getChildren().add(userPromptTextField); Button userPromptSubmitButton = new Button(Localization.lang("Submit")); - userPromptSubmitButton.setOnAction(e -> { - String userPrompt = userPromptTextField.getText(); - userPromptTextField.setText(""); + userPromptSubmitButton.setOnAction(e -> sendMessageToAiEvent()); - addMessage(new UserMessage(userPrompt)); + userPromptHBox.getChildren().add(userPromptSubmitButton); - String aiMessage = aiChat.execute(userPrompt); + return userPromptHBox; + } - addMessage(new AiMessage(aiMessage)); - }); + private void sendMessageToAiEvent() { + String userPrompt = userPromptTextField.getText(); + userPromptTextField.clear(); - userPromptHBox.getChildren().add(userPromptSubmitButton); + addMessage(new UserMessage(userPrompt)); - return userPromptHBox; + Node aiMessage = addMessage(new AiMessage("empty")); + setContentsOfMessage(aiMessage, new ProgressIndicator()); + + BackgroundTask.wrap(() -> aiChat.execute(userPrompt)) + .onSuccess(aiMessageText -> setContentsOfMessage(aiMessage, makeMessageTextArea(aiMessageText))) + .onFailure(e -> { + LOGGER.error("Got an error while sending a message to AI", e); + setContentsOfMessage(aiMessage, constructErrorPane(e)); + }) + .executeWith(taskExecutor); + } + + private static void setContentsOfMessage(Node messageNode, Node content) { + ((VBox)((Pane)messageNode).getChildren().getFirst()).getChildren().set(1, content); + } + + private static TextArea makeMessageTextArea(String content) { + TextArea message = new TextArea(content); + message.setWrapText(true); + message.setEditable(false); + return message; } - private void addMessage(ChatMessage chatMessage) { + private Node constructErrorPane(Exception e) { + Pane pane = new Pane(); + pane.setStyle("-fx-background-color: -jr-red"); + + VBox paneVBox = new VBox(10); + paneVBox.setMaxWidth(500); + paneVBox.setPadding(new Insets(10)); + + Label errorLabel = new Label(Localization.lang("Error")); + errorLabel.setStyle("-fx-font-weight: bold"); + paneVBox.getChildren().add(errorLabel); + + TextArea message = makeMessageTextArea(e.getMessage()); + paneVBox.getChildren().add(message); + + pane.getChildren().add(paneVBox); + + return pane; + } + + private Node addMessage(ChatMessage chatMessage) { if (chatMessage.type() == ChatMessageType.AI || chatMessage.type() == ChatMessageType.USER) { Node messageNode = constructMessageNode(chatMessage); chatVBox.getChildren().add(messageNode); + return messageNode; + } else { + Logger.warn("Cannot construct the UI for a system or tool message."); + return null; } } @@ -228,8 +283,8 @@ private static Node constructMessageNode(ChatMessage chatMessage) { authorLabel.setStyle("-fx-font-weight: bold"); paneVBox.getChildren().add(authorLabel); - Label messageLabel = new Label(chatMessage.text()); - paneVBox.getChildren().add(messageLabel); + TextArea message = makeMessageTextArea(chatMessage.text()); + paneVBox.getChildren().add(message); pane.getChildren().add(paneVBox); diff --git a/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java b/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java index 2fa33f74d71..19e2f042a68 100644 --- a/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java +++ b/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java @@ -314,7 +314,7 @@ private List createTabs() { entryEditorTabs.add(sourceTab); entryEditorTabs.add(new LatexCitationsTab(databaseContext, preferencesService, dialogService, directoryMonitorManager)); entryEditorTabs.add(new FulltextSearchResultsTab(stateManager, preferencesService, dialogService, taskExecutor)); - entryEditorTabs.add(new AiChatTab(dialogService, preferencesService, databaseContext)); + entryEditorTabs.add(new AiChatTab(dialogService, preferencesService, databaseContext, taskExecutor)); return entryEditorTabs; } From 221f96193e69d7a789f6d60ce3fa2d79c9f94f63 Mon Sep 17 00:00:00 2001 From: InAnYan Date: Sat, 18 May 2024 20:06:06 +0300 Subject: [PATCH 21/30] Save chat history in memory. Split UI to classes --- .../org/jabref/gui/entryeditor/AiChatTab.java | 294 ------------------ .../jabref/gui/entryeditor/EntryEditor.java | 3 +- .../aichattab/AiChatComponent.java | 96 ++++++ .../gui/entryeditor/aichattab/AiChatTab.java | 187 +++++++++++ .../aichattab/ChatMessageComponent.java | 131 ++++++++ src/main/java/org/jabref/logic/ai/AiChat.java | 12 +- .../java/org/jabref/logic/ai/AiService.java | 5 - .../java/org/jabref/model/entry/BibEntry.java | 10 + 8 files changed, 434 insertions(+), 304 deletions(-) delete mode 100644 src/main/java/org/jabref/gui/entryeditor/AiChatTab.java create mode 100644 src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatComponent.java create mode 100644 src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java create mode 100644 src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java diff --git a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java deleted file mode 100644 index 2e7097eadd6..00000000000 --- a/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java +++ /dev/null @@ -1,294 +0,0 @@ -package org.jabref.gui.entryeditor; - -import java.nio.file.Path; - -import dev.langchain4j.agent.tool.P; -import javafx.application.Platform; -import javafx.geometry.Insets; -import javafx.geometry.NodeOrientation; -import javafx.geometry.Pos; -import javafx.scene.Node; -import javafx.scene.control.*; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Pane; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; - -import org.jabref.gui.DialogService; -import org.jabref.gui.util.BackgroundTask; -import org.jabref.gui.util.TaskExecutor; -import org.jabref.logic.ai.AiChat; -import org.jabref.logic.ai.AiService; -import org.jabref.logic.ai.AiIngestor; -import org.jabref.logic.l10n.Localization; -import org.jabref.logic.util.io.FileUtil; -import org.jabref.model.database.BibDatabaseContext; -import org.jabref.model.entry.BibEntry; -import org.jabref.model.entry.LinkedFile; -import org.jabref.preferences.AiPreferences; -import org.jabref.preferences.FilePreferences; -import org.jabref.preferences.PreferencesService; - -import com.tobiasdiez.easybind.EasyBind; -import dev.langchain4j.data.message.AiMessage; -import dev.langchain4j.data.message.ChatMessage; -import dev.langchain4j.data.message.ChatMessageType; -import dev.langchain4j.data.message.UserMessage; -import dev.langchain4j.data.segment.TextSegment; -import dev.langchain4j.store.embedding.EmbeddingStore; -import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; -import org.slf4j.LoggerFactory; -import org.tinylog.Logger; - -public class AiChatTab extends EntryEditorTab { - public static final String NAME = "AI chat"; - - private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(AiChatTab.class.getName()); - - private static final String QA_SYSTEM_MESSAGE = """ - You are an AI research assistant. You read and analyze scientific articles. - The user will send you a question regarding a paper. You will be supplied also with the relevant information found in the article. - Answer the question only by using the relevant information. Don't make up the answer. - If you can't answer the user question using the provided information, then reply that you couldn't do it."""; - - private final DialogService dialogService; - private final FilePreferences filePreferences; - private final AiPreferences aiPreferences; - private final EntryEditorPreferences entryEditorPreferences; - private final BibDatabaseContext bibDatabaseContext; - private final TaskExecutor taskExecutor; - - private VBox chatVBox = null; - private TextField userPromptTextField = null; - - private AiService aiService = null; - private AiChat aiChat = null; - - // TODO: This field should somehow live in bib entry. - private EmbeddingStore currentEmbeddingStore = null; - - public AiChatTab(DialogService dialogService, PreferencesService preferencesService, - BibDatabaseContext bibDatabaseContext, TaskExecutor taskExecutor) { - this.dialogService = dialogService; - - this.filePreferences = preferencesService.getFilePreferences(); - this.aiPreferences = preferencesService.getAiPreferences(); - this.entryEditorPreferences = preferencesService.getEntryEditorPreferences(); - - this.bibDatabaseContext = bibDatabaseContext; - - this.taskExecutor = taskExecutor; - - setText(Localization.lang(NAME)); - setTooltip(new Tooltip(Localization.lang("AI chat with full-text article"))); - - setUpAiConnection(); - } - - private void setUpAiConnection() { - if (aiPreferences.getEnableChatWithFiles()) { - aiService = new AiService(aiPreferences.getOpenAiToken()); - } - - EasyBind.listen(aiPreferences.enableChatWithFilesProperty(), (obs, oldValue, newValue) -> { - if (newValue && !aiPreferences.getOpenAiToken().isEmpty()) { - aiService = new AiService(aiPreferences.getOpenAiToken()); - rebuildAiChat(); - } else { - aiService = null; - aiChat = null; - } - }); - - EasyBind.listen(aiPreferences.openAiTokenProperty(), (obs, oldValue, newValue) -> { - if (!newValue.isEmpty()) { - aiService = new AiService(newValue); - rebuildAiChat(); - } - }); - } - - private void rebuildAiChat() { - if (aiChat != null && currentEmbeddingStore != null) { - aiChat = new AiChat(aiService, currentEmbeddingStore); - aiChat.setSystemMessage(QA_SYSTEM_MESSAGE); - } - } - - @Override - public boolean shouldShow(BibEntry entry) { - return entryEditorPreferences.shouldShowAiChatTab(); - } - - @Override - protected void bindToEntry(BibEntry entry) { - if (!aiPreferences.getEnableChatWithFiles()) { - setContent(new Label(Localization.lang("JabRef uses OpenAI to enable \"chatting\" with PDF files. OpenAI is an external service. To enable JabRef chatgting with PDF files, the content of the PDF files need to be shared with OpenAI. As soon as you ask a question, the text content of all PDFs attached to the entry are send to OpenAI. The privacy policy of OpenAI applies. You find it at ."))); - } else if (entry.getFiles().isEmpty()) { - setContent(new Label(Localization.lang("No files attached"))); - } else if (!entry.getFiles().stream().map(LinkedFile::getLink).map(Path::of).allMatch(FileUtil::isPDFFile)) { - setContent(new Label(Localization.lang("Only PDF files are supported"))); - } else { - bindToCorrectEntry(entry); - } - } - - private void bindToCorrectEntry(BibEntry entry) { - configureAiChat(entry); - setContent(createAiChatUI()); - } - - private void configureAiChat(BibEntry entry) { - EmbeddingStore embeddingStore = new InMemoryEmbeddingStore<>(); - - aiChat = new AiChat(aiService, embeddingStore); - aiChat.setSystemMessage(QA_SYSTEM_MESSAGE); - - AiIngestor ingestor = new AiIngestor(embeddingStore, aiService.getEmbeddingModel()); - - for (LinkedFile linkedFile : entry.getFiles()) { - try { - ingestor.ingestLinkedFile(linkedFile, bibDatabaseContext, filePreferences); - } catch (Exception e) { - dialogService.notify(Localization.lang("An error occurred while loading a file into the AI") - + ":\n" - + e.getMessage() + "\n" - + Localization.lang("This file will be skipped") + "."); - } - } - - currentEmbeddingStore = embeddingStore; - } - - private Node createAiChatUI() { - VBox aiChatBox = new VBox(10); - aiChatBox.setPadding(new Insets(10)); - - aiChatBox.getChildren().add(constructChatScrollPane()); - aiChatBox.getChildren().add(constructUserPromptBox()); - - return aiChatBox; - } - - private Node constructChatScrollPane() { - ScrollPane chatScrollPane = new ScrollPane(); - chatScrollPane.setFitToWidth(true); - chatScrollPane.setStyle("-fx-border-color: black;"); - VBox.setVgrow(chatScrollPane, Priority.ALWAYS); - - chatVBox = new VBox(10); - chatVBox.setPadding(new Insets(10)); - aiService.getChatMemoryStore().getMessages(aiChat.getChatId()).forEach(this::addMessage); - chatScrollPane.setContent(chatVBox); - - chatScrollPane.vvalueProperty().bind(chatVBox.heightProperty()); - - return chatScrollPane; - } - - private Node constructUserPromptBox() { - HBox userPromptHBox = new HBox(10); - userPromptHBox.setAlignment(Pos.CENTER); - - userPromptTextField = new TextField(); - HBox.setHgrow(userPromptTextField, Priority.ALWAYS); - userPromptTextField.setOnAction(e -> sendMessageToAiEvent()); - - userPromptHBox.getChildren().add(userPromptTextField); - - Button userPromptSubmitButton = new Button(Localization.lang("Submit")); - userPromptSubmitButton.setOnAction(e -> sendMessageToAiEvent()); - - userPromptHBox.getChildren().add(userPromptSubmitButton); - - return userPromptHBox; - } - - private void sendMessageToAiEvent() { - String userPrompt = userPromptTextField.getText(); - userPromptTextField.clear(); - - addMessage(new UserMessage(userPrompt)); - - Node aiMessage = addMessage(new AiMessage("empty")); - setContentsOfMessage(aiMessage, new ProgressIndicator()); - - BackgroundTask.wrap(() -> aiChat.execute(userPrompt)) - .onSuccess(aiMessageText -> setContentsOfMessage(aiMessage, makeMessageTextArea(aiMessageText))) - .onFailure(e -> { - LOGGER.error("Got an error while sending a message to AI", e); - setContentsOfMessage(aiMessage, constructErrorPane(e)); - }) - .executeWith(taskExecutor); - } - - private static void setContentsOfMessage(Node messageNode, Node content) { - ((VBox)((Pane)messageNode).getChildren().getFirst()).getChildren().set(1, content); - } - - private static TextArea makeMessageTextArea(String content) { - TextArea message = new TextArea(content); - message.setWrapText(true); - message.setEditable(false); - return message; - } - - private Node constructErrorPane(Exception e) { - Pane pane = new Pane(); - pane.setStyle("-fx-background-color: -jr-red"); - - VBox paneVBox = new VBox(10); - paneVBox.setMaxWidth(500); - paneVBox.setPadding(new Insets(10)); - - Label errorLabel = new Label(Localization.lang("Error")); - errorLabel.setStyle("-fx-font-weight: bold"); - paneVBox.getChildren().add(errorLabel); - - TextArea message = makeMessageTextArea(e.getMessage()); - paneVBox.getChildren().add(message); - - pane.getChildren().add(paneVBox); - - return pane; - } - - private Node addMessage(ChatMessage chatMessage) { - if (chatMessage.type() == ChatMessageType.AI || chatMessage.type() == ChatMessageType.USER) { - Node messageNode = constructMessageNode(chatMessage); - chatVBox.getChildren().add(messageNode); - return messageNode; - } else { - Logger.warn("Cannot construct the UI for a system or tool message."); - return null; - } - } - - private static Node constructMessageNode(ChatMessage chatMessage) { - boolean isUser = chatMessage.type() == ChatMessageType.USER; - - Pane pane = new Pane(); - - if (isUser) { - pane.setNodeOrientation(NodeOrientation.RIGHT_TO_LEFT); - } - - VBox paneVBox = new VBox(10); - paneVBox.setMaxWidth(500); - - paneVBox.setStyle("-fx-background-color: " + (isUser ? "-jr-ai-message-user" : "-jr-ai-message-ai") + ";"); - paneVBox.setPadding(new Insets(10)); - - Label authorLabel = new Label(Localization.lang(isUser ? "User" : "AI")); - authorLabel.setStyle("-fx-font-weight: bold"); - paneVBox.getChildren().add(authorLabel); - - TextArea message = makeMessageTextArea(chatMessage.text()); - paneVBox.getChildren().add(message); - - pane.getChildren().add(paneVBox); - - return pane; - } - -} diff --git a/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java b/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java index 19e2f042a68..ef5fdadf5ad 100644 --- a/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java +++ b/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java @@ -31,6 +31,7 @@ import org.jabref.gui.StateManager; import org.jabref.gui.citationkeypattern.GenerateCitationKeySingleAction; import org.jabref.gui.cleanup.CleanupSingleAction; +import org.jabref.gui.entryeditor.aichattab.AiChatTab; import org.jabref.gui.entryeditor.citationrelationtab.CitationRelationsTab; import org.jabref.gui.entryeditor.fileannotationtab.FileAnnotationTab; import org.jabref.gui.entryeditor.fileannotationtab.FulltextSearchResultsTab; @@ -314,7 +315,7 @@ private List createTabs() { entryEditorTabs.add(sourceTab); entryEditorTabs.add(new LatexCitationsTab(databaseContext, preferencesService, dialogService, directoryMonitorManager)); entryEditorTabs.add(new FulltextSearchResultsTab(stateManager, preferencesService, dialogService, taskExecutor)); - entryEditorTabs.add(new AiChatTab(dialogService, preferencesService, databaseContext, taskExecutor)); + entryEditorTabs.add(new AiChatTab(preferencesService, databaseContext, taskExecutor)); return entryEditorTabs; } diff --git a/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatComponent.java b/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatComponent.java new file mode 100644 index 00000000000..2f209cf143a --- /dev/null +++ b/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatComponent.java @@ -0,0 +1,96 @@ +package org.jabref.gui.entryeditor.aichattab; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.service.V; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.ScrollPane; +import javafx.scene.control.TextField; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import oracle.jdbc.driver.Const; +import org.jabref.gui.util.BackgroundTask; +import org.jabref.logic.ai.AiChat; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.entry.BibEntry; + +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +public class AiChatComponent { + private final Consumer sendMessageCallback; + + private final VBox aiChatBox = new VBox(10); + private final VBox chatVBox = new VBox(10); + private final TextField userPromptTextField = new TextField(); + + public AiChatComponent(Consumer sendMessageCallback) { + this.sendMessageCallback = sendMessageCallback; + + buildUI(); + } + + private void buildUI() { + aiChatBox.setPadding(new Insets(10)); + + aiChatBox.getChildren().add(constructChatScrollPane()); + aiChatBox.getChildren().add(constructUserPromptBox()); + } + + private Node constructChatScrollPane() { + ScrollPane chatScrollPane = new ScrollPane(); + chatScrollPane.setFitToWidth(true); + chatScrollPane.setStyle("-fx-border-color: black;"); + VBox.setVgrow(chatScrollPane, Priority.ALWAYS); + + chatVBox.setPadding(new Insets(10)); + + chatScrollPane.setContent(chatVBox); + + chatScrollPane.vvalueProperty().bind(chatVBox.heightProperty()); + + return chatScrollPane; + } + + private Node constructUserPromptBox() { + HBox userPromptHBox = new HBox(10); + userPromptHBox.setAlignment(Pos.CENTER); + + HBox.setHgrow(userPromptTextField, Priority.ALWAYS); + userPromptTextField.setOnAction(e -> internalSendMessageEvent()); + + userPromptHBox.getChildren().add(userPromptTextField); + + Button userPromptSubmitButton = new Button(Localization.lang("Submit")); + userPromptSubmitButton.setOnAction(e -> internalSendMessageEvent()); + + userPromptHBox.getChildren().add(userPromptSubmitButton); + + return userPromptHBox; + } + + public void addMessage(ChatMessageComponent chatMessageComponent) { + chatVBox.getChildren().add(chatMessageComponent.getNode()); + } + + private void internalSendMessageEvent() { + String userPrompt = userPromptTextField.getText(); + userPromptTextField.clear(); + sendMessageCallback.accept(userPrompt); + } + + public void restoreMessages(List messages) { + messages.forEach(message -> addMessage(new ChatMessageComponent(message))); + } + + public Node getNode() { + return aiChatBox; + } +} diff --git a/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java new file mode 100644 index 00000000000..957d5f5a226 --- /dev/null +++ b/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java @@ -0,0 +1,187 @@ +package org.jabref.gui.entryeditor.aichattab; + +import java.nio.file.Path; +import java.util.ArrayList; + +import javafx.geometry.Insets; +import javafx.geometry.NodeOrientation; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; + +import org.checkerframework.checker.units.qual.C; +import org.jabref.gui.DialogService; +import org.jabref.gui.entryeditor.EntryEditorPreferences; +import org.jabref.gui.entryeditor.EntryEditorTab; +import org.jabref.gui.util.BackgroundTask; +import org.jabref.gui.util.TaskExecutor; +import org.jabref.logic.ai.AiChat; +import org.jabref.logic.ai.AiService; +import org.jabref.logic.ai.AiIngestor; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.io.FileUtil; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.LinkedFile; +import org.jabref.preferences.AiPreferences; +import org.jabref.preferences.FilePreferences; +import org.jabref.preferences.PreferencesService; + +import com.tobiasdiez.easybind.EasyBind; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.ChatMessageType; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.store.embedding.EmbeddingStore; +import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; +import org.slf4j.LoggerFactory; +import org.tinylog.Logger; + +public class AiChatTab extends EntryEditorTab { + public static final String NAME = "AI chat"; + + private static final org.slf4j.Logger LOGGER = LoggerFactory.getLogger(AiChatTab.class.getName()); + + private static final String QA_SYSTEM_MESSAGE = """ + You are an AI research assistant. You read and analyze scientific articles. + The user will send you a question regarding a paper. You will be supplied also with the relevant information found in the article. + Answer the question only by using the relevant information. Don't make up the answer. + If you can't answer the user question using the provided information, then reply that you couldn't do it."""; + + private final FilePreferences filePreferences; + private final AiPreferences aiPreferences; + private final EntryEditorPreferences entryEditorPreferences; + private final BibDatabaseContext bibDatabaseContext; + private final TaskExecutor taskExecutor; + + private AiChatComponent aiChatComponent = null; + + private AiService aiService = null; + private AiChat aiChat = null; + + private BibEntry currentBibEntry = null; + + // TODO: Proper embeddings. + private EmbeddingStore embeddingStore = null; + + public AiChatTab(PreferencesService preferencesService, + BibDatabaseContext bibDatabaseContext, TaskExecutor taskExecutor) { + this.filePreferences = preferencesService.getFilePreferences(); + this.aiPreferences = preferencesService.getAiPreferences(); + this.entryEditorPreferences = preferencesService.getEntryEditorPreferences(); + + this.bibDatabaseContext = bibDatabaseContext; + + this.taskExecutor = taskExecutor; + + setText(Localization.lang(NAME)); + setTooltip(new Tooltip(Localization.lang("AI chat with full-text article"))); + + setUpAiConnection(); + } + + private void setUpAiConnection() { + if (aiPreferences.getEnableChatWithFiles()) { + aiService = new AiService(aiPreferences.getOpenAiToken()); + } + + EasyBind.listen(aiPreferences.enableChatWithFilesProperty(), (obs, oldValue, newValue) -> { + if (newValue && !aiPreferences.getOpenAiToken().isEmpty()) { + aiService = new AiService(aiPreferences.getOpenAiToken()); + rebuildAiChat(); + } else { + aiService = null; + aiChat = null; + } + }); + + EasyBind.listen(aiPreferences.openAiTokenProperty(), (obs, oldValue, newValue) -> { + if (!newValue.isEmpty()) { + aiService = new AiService(newValue); + rebuildAiChat(); + } + }); + } + + private void rebuildAiChat() { + if (aiChat != null) { + createAiChat(); + if (currentBibEntry != null) { + aiChat.restoreMessages(currentBibEntry.getAiChatMessages()); + } + } + } + + @Override + public boolean shouldShow(BibEntry entry) { + return entryEditorPreferences.shouldShowAiChatTab(); + } + + @Override + protected void bindToEntry(BibEntry entry) { + if (!aiPreferences.getEnableChatWithFiles()) { + setContent(new Label(Localization.lang("JabRef uses OpenAI to enable \"chatting\" with PDF files. OpenAI is an external service. To enable JabRef chatgting with PDF files, the content of the PDF files need to be shared with OpenAI. As soon as you ask a question, the text content of all PDFs attached to the entry are send to OpenAI. The privacy policy of OpenAI applies. You find it at ."))); + } else if (entry.getFiles().isEmpty()) { + setContent(new Label(Localization.lang("No files attached"))); + } else if (!entry.getFiles().stream().map(LinkedFile::getLink).map(Path::of).allMatch(FileUtil::isPDFFile)) { + setContent(new Label(Localization.lang("Only PDF files are supported"))); + } else { + bindToCorrectEntry(entry); + } + } + + private void bindToCorrectEntry(BibEntry entry) { + currentBibEntry = entry; + + createAiChat(); + aiChat.restoreMessages(entry.getAiChatMessages()); + ingestFiles(entry); + buildChatUI(entry); + } + + private void createAiChat() { + embeddingStore = new InMemoryEmbeddingStore<>(); + aiChat = new AiChat(aiService, embeddingStore); + aiChat.setSystemMessage(QA_SYSTEM_MESSAGE); + } + + private void ingestFiles(BibEntry entry) { + AiIngestor aiIngestor = new AiIngestor(embeddingStore, aiService.getEmbeddingModel()); + entry.getFiles().forEach(file -> { + aiIngestor.ingestLinkedFile(file, bibDatabaseContext, filePreferences); + }); + } + + private void buildChatUI(BibEntry entry) { + aiChatComponent = new AiChatComponent((userPrompt) -> { + UserMessage userMessage = new UserMessage(userPrompt); + aiChatComponent.addMessage(new ChatMessageComponent(userMessage)); + entry.getAiChatMessages().add(userMessage); + + ChatMessageComponent aiChatMessageComponent = new ChatMessageComponent(); + aiChatComponent.addMessage(aiChatMessageComponent); + + BackgroundTask.wrap(() -> aiChat.execute(userPrompt)) + .onSuccess(aiMessageText -> { + AiMessage aiMessage = new AiMessage(aiMessageText); + aiChatMessageComponent.setMessage(aiMessage); + entry.getAiChatMessages().add(aiMessage); + }) + .onFailure(e -> { + // TODO: User-friendly error message. + LOGGER.error("Got an error while sending a message to AI", e); + aiChatMessageComponent.setError(e.getMessage()); + }) + .executeWith(taskExecutor); + }); + + aiChatComponent.restoreMessages(entry.getAiChatMessages()); + + setContent(aiChatComponent.getNode()); + } +} diff --git a/src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java b/src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java new file mode 100644 index 00000000000..1fbe027248b --- /dev/null +++ b/src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java @@ -0,0 +1,131 @@ +package org.jabref.gui.entryeditor.aichattab; + +import dev.langchain4j.data.message.*; +import javafx.geometry.Insets; +import javafx.geometry.NodeOrientation; +import javafx.scene.Node; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.TextArea; +import javafx.scene.layout.Pane; +import javafx.scene.layout.VBox; +import org.jabref.logic.l10n.Localization; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ChatMessageComponent { + private static final Logger LOGGER = LoggerFactory.getLogger(ChatMessageComponent.class); + + private final Pane pane; + private final VBox paneVBox; + private final Label authorLabel; + private final Pane contentPane; + + public ChatMessageComponent() { + pane = new Pane(); + + paneVBox = new VBox(10); + paneVBox.setMaxWidth(500); + + paneVBox.setPadding(new Insets(10)); + paneVBox.setStyle("-fx-background-color: -jr-ai-message-ai"); + + authorLabel = new Label(Localization.lang("AI")); + authorLabel.setStyle("-fx-font-weight: bold"); + paneVBox.getChildren().add(authorLabel); + + contentPane = new Pane(); + contentPane.getChildren().add(new ProgressIndicator()); + paneVBox.getChildren().add(contentPane); + + pane.getChildren().add(paneVBox); + } + + public ChatMessageComponent(ChatMessage chatMessage) { + this(); + setMessage(chatMessage); + } + + public void setMessage(ChatMessage chatMessage) { + if (!(chatMessage instanceof UserMessage) && !(chatMessage instanceof AiMessage)) { + return; + } + + boolean isUser = chatMessage instanceof UserMessage; + + if (isUser) { + pane.setNodeOrientation(NodeOrientation.RIGHT_TO_LEFT); + } + + paneVBox.setStyle("-fx-background-color: " + (isUser ? "-jr-ai-message-user" : "-jr-ai-message-ai") + ";"); + + authorLabel.setText(Localization.lang(isUser ? "User" : "AI")); + + contentPane.getChildren().clear(); + contentPane.setStyle(""); + + contentPane.getChildren().add(makeMessageTextArea(getChatMessageText(chatMessage))); + } + + public void setError(String message) { + contentPane.setStyle("-fx-background-color: -jr-red"); + contentPane.getChildren().clear(); + contentPane.setStyle(""); + + VBox paneVBox = new VBox(10); + paneVBox.setMaxWidth(500); + paneVBox.setPadding(new Insets(10)); + + Label errorLabel = new Label(Localization.lang("Error")); + errorLabel.setStyle("-fx-font-weight: bold"); + paneVBox.getChildren().add(errorLabel); + + TextArea messageTextArea = makeMessageTextArea(message); + paneVBox.getChildren().add(messageTextArea); + + contentPane.getChildren().add(paneVBox); + } + + private static TextArea makeMessageTextArea(String content) { + TextArea message = new TextArea(content); + message.setWrapText(true); + message.setEditable(false); + return message; + } + + // Safely gets text from a chat message. + private static String getChatMessageText(ChatMessage chatMessage) { + // This mangling is needed because ChatMessage.text() is deprecated. + + switch (chatMessage) { + case AiMessage aiChatMessage -> { + return aiChatMessage.text(); + } + + case UserMessage userChatMessage -> { + if (userChatMessage.hasSingleText()) { + return userChatMessage.singleText(); + } else { + return ""; + } + } + + case SystemMessage systemChatMessage -> { + return systemChatMessage.text(); + } + + case ToolExecutionResultMessage toolChatMessage -> { + return toolChatMessage.text(); + } + + case null, default -> { + LOGGER.error("Unimplemented getChatMessageText for a new kind of ChatMessage"); + return ""; + } + } + } + + public Node getNode() { + return pane; + } +} diff --git a/src/main/java/org/jabref/logic/ai/AiChat.java b/src/main/java/org/jabref/logic/ai/AiChat.java index 7ef1e34edf2..0367912c085 100644 --- a/src/main/java/org/jabref/logic/ai/AiChat.java +++ b/src/main/java/org/jabref/logic/ai/AiChat.java @@ -1,15 +1,20 @@ package org.jabref.logic.ai; +import java.util.List; import java.util.UUID; import dev.langchain4j.chain.ConversationalRetrievalChain; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.memory.ChatMemory; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.rag.content.retriever.ContentRetriever; import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; import dev.langchain4j.store.embedding.EmbeddingStore; +import org.jabref.model.entry.BibEntry; /** * This class maintains an AI chat. @@ -43,9 +48,7 @@ public AiChat(AiService aiService, EmbeddingStore embeddingStore) { // there would be too many tokens. This class, for example, sends only the 10 last messages. this.chatMemory = MessageWindowChatMemory .builder() - .chatMemoryStore(aiService.getChatMemoryStore()) .maxMessages(MESSAGE_WINDOW_SIZE) // This was the default value in the original implementation. - .id(UUID.randomUUID()) .build(); this.chain = ConversationalRetrievalChain @@ -62,10 +65,11 @@ public void setSystemMessage(String message) { } public String execute(String prompt) { + // chain.execute() will automatically add messages to ChatMemory. return chain.execute(prompt); } - public Object getChatId() { - return this.chatMemory.id(); + public void restoreMessages(List messages) { + messages.forEach(this.chatMemory::add); } } diff --git a/src/main/java/org/jabref/logic/ai/AiService.java b/src/main/java/org/jabref/logic/ai/AiService.java index 0d2715402a4..61acc138026 100644 --- a/src/main/java/org/jabref/logic/ai/AiService.java +++ b/src/main/java/org/jabref/logic/ai/AiService.java @@ -16,7 +16,6 @@ public class AiService { private final ChatLanguageModel chatModel; private final EmbeddingModel embeddingModel = new AllMiniLmL6V2EmbeddingModel(); - private final ChatMemoryStore chatMemoryStore = new InMemoryChatMemoryStore(); public AiService(String apiKey) { // Later this class can accepts different enums or other pieces of information in order @@ -35,8 +34,4 @@ public ChatLanguageModel getChatModel() { public EmbeddingModel getEmbeddingModel() { return embeddingModel; } - - public ChatMemoryStore getChatMemoryStore() { - return chatMemoryStore; - } } diff --git a/src/main/java/org/jabref/model/entry/BibEntry.java b/src/main/java/org/jabref/model/entry/BibEntry.java index 6f463422a36..d21722f743d 100644 --- a/src/main/java/org/jabref/model/entry/BibEntry.java +++ b/src/main/java/org/jabref/model/entry/BibEntry.java @@ -18,6 +18,10 @@ import java.util.function.BiFunction; import java.util.stream.Collectors; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.store.embedding.EmbeddingStore; +import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; import javafx.beans.Observable; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; @@ -137,6 +141,8 @@ public class BibEntry implements Cloneable { */ private boolean changed; + private final List aiChatMessages = new ArrayList<>(); + /** * Constructs a new BibEntry. The internal ID is set to IdGenerator.next() */ @@ -1193,4 +1199,8 @@ public boolean isEmpty() { } return StandardField.AUTOMATIC_FIELDS.containsAll(this.getFields()); } + + public List getAiChatMessages() { + return aiChatMessages; + } } From 185de4e4fc59f0c44914601ce6e9dd548e67e20d Mon Sep 17 00:00:00 2001 From: inanyan Date: Sun, 19 May 2024 16:37:24 +0300 Subject: [PATCH 22/30] Manage chat history --- .../gui/entryeditor/aichattab/AiChatTab.java | 14 ++++ .../gui/exporter/SaveDatabaseAction.java | 40 ++++++++++ .../importer/actions/OpenDatabaseAction.java | 4 +- .../actions/loadchathistory/AiChatFile.java | 8 ++ .../loadchathistory/AiChatFileMessage.java | 77 +++++++++++++++++++ .../LoadChatHistoryAction.java | 77 +++++++++++++++++++ 6 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFile.java create mode 100644 src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFileMessage.java create mode 100644 src/main/java/org/jabref/gui/importer/actions/loadchathistory/LoadChatHistoryAction.java diff --git a/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java index 957d5f5a226..21d7229044f 100644 --- a/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java @@ -2,6 +2,7 @@ import java.nio.file.Path; import java.util.ArrayList; +import java.util.Optional; import javafx.geometry.Insets; import javafx.geometry.NodeOrientation; @@ -126,6 +127,10 @@ public boolean shouldShow(BibEntry entry) { protected void bindToEntry(BibEntry entry) { if (!aiPreferences.getEnableChatWithFiles()) { setContent(new Label(Localization.lang("JabRef uses OpenAI to enable \"chatting\" with PDF files. OpenAI is an external service. To enable JabRef chatgting with PDF files, the content of the PDF files need to be shared with OpenAI. As soon as you ask a question, the text content of all PDFs attached to the entry are send to OpenAI. The privacy policy of OpenAI applies. You find it at ."))); + } else if (entry.getCitationKey().isEmpty()) { + setContent(new Label(Localization.lang("Please provide a citation key for the entry in order to enable chatting with PDF files."))); + } else if (!checkIfCitationKeyIsUnique(bibDatabaseContext, entry.getCitationKey().get())) { + setContent(new Label(Localization.lang("Please provide a unique citation key for the entry in order to enable chatting with PDF files."))); } else if (entry.getFiles().isEmpty()) { setContent(new Label(Localization.lang("No files attached"))); } else if (!entry.getFiles().stream().map(LinkedFile::getLink).map(Path::of).allMatch(FileUtil::isPDFFile)) { @@ -135,6 +140,15 @@ protected void bindToEntry(BibEntry entry) { } } + private static boolean checkIfCitationKeyIsUnique(BibDatabaseContext bibDatabaseContext, String citationKey) { + return bibDatabaseContext.getDatabase().getEntries().stream() + .map(BibEntry::getCitationKey) + .filter(Optional::isPresent) + .map(Optional::get) + .filter(key -> key.equals(citationKey)) + .count() == 1; + } + private void bindToCorrectEntry(BibEntry entry) { currentBibEntry = entry; diff --git a/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java b/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java index 1c2d8c810ab..60adf64781b 100644 --- a/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java +++ b/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java @@ -6,6 +6,7 @@ import java.nio.charset.UnsupportedCharsetException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.HashMap; import java.util.List; import java.util.Optional; import java.util.Set; @@ -22,6 +23,9 @@ import org.jabref.gui.LibraryTab; import org.jabref.gui.autosaveandbackup.AutosaveManager; import org.jabref.gui.autosaveandbackup.BackupManager; +import org.jabref.gui.importer.actions.loadchathistory.AiChatFile; +import org.jabref.gui.importer.actions.loadchathistory.AiChatFileMessage; +import org.jabref.gui.importer.actions.loadchathistory.LoadChatHistoryAction; import org.jabref.gui.maintable.BibEntryTableViewModel; import org.jabref.gui.maintable.columns.MainTableColumn; import org.jabref.gui.util.BackgroundTask; @@ -38,6 +42,8 @@ import org.jabref.logic.shared.DatabaseLocation; import org.jabref.logic.shared.prefs.SharedDatabasePreferences; import org.jabref.logic.util.StandardFileType; +import org.jabref.logic.util.io.FileUtil; +import org.jabref.model.database.BibDatabase; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.database.event.ChangePropagation; import org.jabref.model.entry.BibEntryTypesManager; @@ -45,6 +51,7 @@ import org.jabref.model.metadata.SelfContainedSaveOrder; import org.jabref.preferences.PreferencesService; +import com.fasterxml.jackson.databind.ObjectMapper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -283,10 +290,43 @@ private boolean saveDatabase(Path file, boolean selectedOnly, Charset encoding, } catch (IOException ex) { throw new SaveException("Problems saving: " + ex, ex); } + + // TODO: What to do with chat backups? Should we do it? + try (AtomicFileWriter fileWriter = new AtomicFileWriter(FileUtil.addExtension(file, LoadChatHistoryAction.AI_CHAT_HISTORY_EXTENSION), encoding, saveConfiguration.shouldMakeBackup())) { + // TODO: What to do with selectedOnly? + + ObjectMapper objectMapper = new ObjectMapper(); + AiChatFile aiChatFile = makeAiChatFile(bibDatabaseContext.getDatabase()); + objectMapper.writeValue(fileWriter, aiChatFile); + + if (fileWriter.hasEncodingProblems()) { + saveWithDifferentEncoding(file, selectedOnly, encoding, fileWriter.getEncodingProblems(), saveType, saveOrder); + } + } catch (UnsupportedCharsetException ex) { + throw new SaveException(Localization.lang("Character encoding '%0' is not supported.", encoding.displayName()), ex); + } catch (IOException ex) { + throw new SaveException("Problems saving: " + ex, ex); + } + return true; } } + private AiChatFile makeAiChatFile(BibDatabase bibDatabase) { + AiChatFile aiChatFile = new AiChatFile(); + aiChatFile.chatHistoryMap = new HashMap<>(); + + bibDatabase.getEntries().forEach(entry -> + entry.getCitationKey().ifPresent(citationKey -> { + List aiChatFileMessages = entry.getAiChatMessages().stream().map(AiChatFileMessage::fromLangchain).toList(); + aiChatFile.chatHistoryMap.put(citationKey, aiChatFileMessages); + } + ) + ); + + return aiChatFile; + } + private void saveWithDifferentEncoding(Path file, boolean selectedOnly, Charset encoding, Set encodingProblems, BibDatabaseWriter.SaveType saveType, SelfContainedSaveOrder saveOrder) throws SaveException { DialogPane pane = new DialogPane(); VBox vbox = new VBox(); diff --git a/src/main/java/org/jabref/gui/importer/actions/OpenDatabaseAction.java b/src/main/java/org/jabref/gui/importer/actions/OpenDatabaseAction.java index 4369f7b87d7..31571b287f0 100644 --- a/src/main/java/org/jabref/gui/importer/actions/OpenDatabaseAction.java +++ b/src/main/java/org/jabref/gui/importer/actions/OpenDatabaseAction.java @@ -20,6 +20,7 @@ import org.jabref.gui.actions.SimpleCommand; import org.jabref.gui.autosaveandbackup.BackupManager; import org.jabref.gui.dialogs.BackupUIManager; +import org.jabref.gui.importer.actions.loadchathistory.LoadChatHistoryAction; import org.jabref.gui.shared.SharedDatabaseUIManager; import org.jabref.gui.telemetry.Telemetry; import org.jabref.gui.undo.CountingUndoManager; @@ -54,7 +55,8 @@ public class OpenDatabaseAction extends SimpleCommand { // Warning for migrating the Review into the Comment field new MergeReviewIntoCommentAction(), // Check for new custom entry types loaded from the BIB file: - new CheckForNewEntryTypesAction()); + new CheckForNewEntryTypesAction(), + new LoadChatHistoryAction()); private final LibraryTabContainer tabContainer; private final PreferencesService preferencesService; diff --git a/src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFile.java b/src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFile.java new file mode 100644 index 00000000000..848eaa968c4 --- /dev/null +++ b/src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFile.java @@ -0,0 +1,8 @@ +package org.jabref.gui.importer.actions.loadchathistory; + +import java.util.List; +import java.util.Map; + +public class AiChatFile { + public Map> chatHistoryMap; +} diff --git a/src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFileMessage.java b/src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFileMessage.java new file mode 100644 index 00000000000..951f8ff3e56 --- /dev/null +++ b/src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFileMessage.java @@ -0,0 +1,77 @@ +package org.jabref.gui.importer.actions.loadchathistory; + +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.SystemMessage; +import dev.langchain4j.data.message.ToolExecutionResultMessage; +import dev.langchain4j.data.message.UserMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AiChatFileMessage { + private static final Logger LOGGER = LoggerFactory.getLogger(AiChatFileMessage.class); + + public static final String USER_MESSAGE_TYPE = "user"; + public static final String AI_MESSAGE_TYPE = "ai"; + public static final String SYSTEM_MESSAGE_TYPE = "system"; + + private final String type; + private final String content; + + @JsonCreator + public AiChatFileMessage(@JsonProperty("type") String type, @JsonProperty("content") String content) { + this.type = type; + this.content = content; + } + + public static AiChatFileMessage fromLangchain(ChatMessage chatMessage) { + switch (chatMessage) { + case UserMessage userMessage -> { + return new AiChatFileMessage(USER_MESSAGE_TYPE, userMessage.singleText()); + } + case AiMessage aiMessage -> { + return new AiChatFileMessage(AI_MESSAGE_TYPE, aiMessage.text()); + } + case SystemMessage systemMessage -> { + return new AiChatFileMessage(SYSTEM_MESSAGE_TYPE, systemMessage.text()); + } + case ToolExecutionResultMessage toolExecutionResultMessage -> { + return new AiChatFileMessage(SYSTEM_MESSAGE_TYPE, toolExecutionResultMessage.text()); + } + default -> { + LOGGER.error("Found an unknown message type while parsing AI chat history: {}", chatMessage.getClass()); + return null; + } + } + } + + public Optional toLangchainMessage() { + switch (type) { + case USER_MESSAGE_TYPE -> { + return Optional.of(new UserMessage(content)); + } + case AI_MESSAGE_TYPE -> { + return Optional.of(new AiMessage(content)); + } + case SYSTEM_MESSAGE_TYPE -> { + return Optional.of(new SystemMessage(content)); + } + default -> { + LOGGER.error("Found an unknown message type while parsing AI chat history: {}", type); + return Optional.empty(); + } + } + } + + public String getType() { + return type; + } + + public String getContent() { + return content; + } +} diff --git a/src/main/java/org/jabref/gui/importer/actions/loadchathistory/LoadChatHistoryAction.java b/src/main/java/org/jabref/gui/importer/actions/loadchathistory/LoadChatHistoryAction.java new file mode 100644 index 00000000000..7647c934f8f --- /dev/null +++ b/src/main/java/org/jabref/gui/importer/actions/loadchathistory/LoadChatHistoryAction.java @@ -0,0 +1,77 @@ +package org.jabref.gui.importer.actions.loadchathistory; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.jabref.gui.DialogService; +import org.jabref.gui.importer.actions.GUIPostOpenAction; +import org.jabref.logic.importer.ParserResult; +import org.jabref.logic.util.io.FileUtil; +import org.jabref.model.database.BibDatabase; +import org.jabref.model.entry.BibEntry; + +import com.fasterxml.jackson.databind.ObjectMapper; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.UserMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class LoadChatHistoryAction implements GUIPostOpenAction { + private static final Logger LOGGER = LoggerFactory.getLogger(LoadChatHistoryAction.class); + + public static final String AI_CHAT_HISTORY_EXTENSION = ".aichats"; + + @Override + public boolean isActionNecessary(ParserResult pr) { + return true; + } + + @Override + public void performAction(ParserResult pr, DialogService dialogService) { + pr.getDatabaseContext().getDatabasePath().ifPresent(databasePath -> readAiChatsFile(pr.getDatabase(), databasePath)); + } + + private void readAiChatsFile(BibDatabase bibDatabase, Path databasePath) { + Path chatPath = FileUtil.addExtension(databasePath, AI_CHAT_HISTORY_EXTENSION); + File chatFile = chatPath.toFile(); + try { + InputStream inputStream = new FileInputStream(chatFile); + ObjectMapper objectMapper = new ObjectMapper(); + AiChatFile aiChatFile = objectMapper.readValue(inputStream, AiChatFile.class); + loadAiChatHistory(bibDatabase, aiChatFile); + } catch (FileNotFoundException e) { + LOGGER.info("There is no .aichats file for the opened library."); + } catch (IOException e) { + LOGGER.error("An error occurred while reading " + chatPath, e); + } + } + + private void loadAiChatHistory(BibDatabase bibDatabase, AiChatFile aiChatFile) { + aiChatFile.chatHistoryMap.forEach((citationKey, chatHistory) -> { + List bibEntries = bibDatabase.getEntriesByCitationKey(citationKey); + + if (bibEntries.isEmpty()) { + LOGGER.warn("Found a chat history for an unknown bib entry with citation key \"" + citationKey + "\""); + } else if (bibEntries.size() != 1) { + LOGGER.warn("Found a chat history for an bib entry with citation key \"" + citationKey + "\" but there are several bib entries in the database with the same key"); + } else { + BibEntry bibEntry = bibEntries.getFirst(); + List parsedChatMessages = parseChatMessages(chatHistory); + bibEntry.getAiChatMessages().addAll(parsedChatMessages); + } + }); + } + + private List parseChatMessages(List chatHistory) { + return chatHistory.stream().map(AiChatFileMessage::toLangchainMessage).filter(Optional::isPresent).map(Optional::get).toList(); + } +} + From 267efc9dc1ae477fe21b5d2bb0706e84984c7a5b Mon Sep 17 00:00:00 2001 From: inanyan Date: Sat, 25 May 2024 11:55:39 +0300 Subject: [PATCH 23/30] Clean saving chat history --- src/main/java/module-info.java | 3 + .../aichattab/ChatMessageComponent.java | 2 +- .../gui/exporter/SaveDatabaseAction.java | 18 +++-- .../LoadChatHistoryAction.java | 24 ++---- .../importer/actions/OpenDatabaseAction.java | 1 - .../actions/loadchathistory/AiChatFile.java | 8 -- .../loadchathistory/AiChatFileMessage.java | 77 ------------------- src/main/java/org/jabref/logic/ai/AiChat.java | 4 +- .../java/org/jabref/logic/ai/AiChatsFile.java | 20 +++++ .../java/org/jabref/logic/ai/AiIngestor.java | 4 - .../java/org/jabref/logic/ai/AiService.java | 2 - .../java/org/jabref/logic/ai/ChatMessage.java | 62 +++++++++++++++ .../org/jabref/logic/ai/ChatMessageType.java | 6 ++ .../java/org/jabref/model/entry/BibEntry.java | 5 +- 14 files changed, 113 insertions(+), 123 deletions(-) rename src/main/java/org/jabref/gui/importer/actions/{loadchathistory => }/LoadChatHistoryAction.java (71%) delete mode 100644 src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFile.java delete mode 100644 src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFileMessage.java create mode 100644 src/main/java/org/jabref/logic/ai/AiChatsFile.java create mode 100644 src/main/java/org/jabref/logic/ai/ChatMessage.java create mode 100644 src/main/java/org/jabref/logic/ai/ChatMessageType.java diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 5a0eba3059f..a59a2ddd46d 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -154,4 +154,7 @@ requires de.saxsys.mvvmfx.validation; requires dd.plist; requires mslinks; + requires langchain4j.embeddings.all.minilm.l6.v2; + requires langchain4j.core; + requires langchain4j.open.ai; } diff --git a/src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java b/src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java index 1fbe027248b..5086f89d6c6 100644 --- a/src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java +++ b/src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java @@ -68,9 +68,9 @@ public void setMessage(ChatMessage chatMessage) { } public void setError(String message) { - contentPane.setStyle("-fx-background-color: -jr-red"); contentPane.getChildren().clear(); contentPane.setStyle(""); + contentPane.setStyle("-fx-background-color: -jr-red"); VBox paneVBox = new VBox(10); paneVBox.setMaxWidth(500); diff --git a/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java b/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java index 60adf64781b..24b647f519b 100644 --- a/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java +++ b/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java @@ -8,6 +8,7 @@ import java.nio.file.Path; import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; @@ -25,11 +26,13 @@ import org.jabref.gui.autosaveandbackup.BackupManager; import org.jabref.gui.importer.actions.loadchathistory.AiChatFile; import org.jabref.gui.importer.actions.loadchathistory.AiChatFileMessage; -import org.jabref.gui.importer.actions.loadchathistory.LoadChatHistoryAction; +import org.jabref.gui.importer.actions.LoadChatHistoryAction; import org.jabref.gui.maintable.BibEntryTableViewModel; import org.jabref.gui.maintable.columns.MainTableColumn; import org.jabref.gui.util.BackgroundTask; import org.jabref.gui.util.FileDialogConfiguration; +import org.jabref.logic.ai.AiChatsFile; +import org.jabref.logic.ai.ChatMessage; import org.jabref.logic.exporter.AtomicFileWriter; import org.jabref.logic.exporter.BibDatabaseWriter; import org.jabref.logic.exporter.BibWriter; @@ -296,7 +299,7 @@ private boolean saveDatabase(Path file, boolean selectedOnly, Charset encoding, // TODO: What to do with selectedOnly? ObjectMapper objectMapper = new ObjectMapper(); - AiChatFile aiChatFile = makeAiChatFile(bibDatabaseContext.getDatabase()); + AiChatsFile aiChatFile = makeAiChatsFile(bibDatabaseContext.getDatabase()); objectMapper.writeValue(fileWriter, aiChatFile); if (fileWriter.hasEncodingProblems()) { @@ -312,19 +315,18 @@ private boolean saveDatabase(Path file, boolean selectedOnly, Charset encoding, } } - private AiChatFile makeAiChatFile(BibDatabase bibDatabase) { - AiChatFile aiChatFile = new AiChatFile(); - aiChatFile.chatHistoryMap = new HashMap<>(); + private AiChatsFile makeAiChatsFile(BibDatabase bibDatabase) { + Map> chatHistoryMap = new HashMap<>(); bibDatabase.getEntries().forEach(entry -> entry.getCitationKey().ifPresent(citationKey -> { - List aiChatFileMessages = entry.getAiChatMessages().stream().map(AiChatFileMessage::fromLangchain).toList(); - aiChatFile.chatHistoryMap.put(citationKey, aiChatFileMessages); + List aiChatFileMessages = entry.getAiChatMessages(); + chatHistoryMap.put(citationKey, aiChatFileMessages); } ) ); - return aiChatFile; + return new AiChatsFile(chatHistoryMap); } private void saveWithDifferentEncoding(Path file, boolean selectedOnly, Charset encoding, Set encodingProblems, BibDatabaseWriter.SaveType saveType, SelfContainedSaveOrder saveOrder) throws SaveException { diff --git a/src/main/java/org/jabref/gui/importer/actions/loadchathistory/LoadChatHistoryAction.java b/src/main/java/org/jabref/gui/importer/actions/LoadChatHistoryAction.java similarity index 71% rename from src/main/java/org/jabref/gui/importer/actions/loadchathistory/LoadChatHistoryAction.java rename to src/main/java/org/jabref/gui/importer/actions/LoadChatHistoryAction.java index 7647c934f8f..c4b167f842b 100644 --- a/src/main/java/org/jabref/gui/importer/actions/loadchathistory/LoadChatHistoryAction.java +++ b/src/main/java/org/jabref/gui/importer/actions/LoadChatHistoryAction.java @@ -1,26 +1,23 @@ -package org.jabref.gui.importer.actions.loadchathistory; +package org.jabref.gui.importer.actions; import java.io.File; import java.io.FileInputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; import java.nio.file.Path; -import java.util.ArrayList; import java.util.List; import java.util.Optional; import org.jabref.gui.DialogService; -import org.jabref.gui.importer.actions.GUIPostOpenAction; +import org.jabref.logic.ai.AiChatsFile; +import org.jabref.logic.ai.ChatMessage; import org.jabref.logic.importer.ParserResult; import org.jabref.logic.util.io.FileUtil; import org.jabref.model.database.BibDatabase; import org.jabref.model.entry.BibEntry; import com.fasterxml.jackson.databind.ObjectMapper; -import dev.langchain4j.data.message.ChatMessage; -import dev.langchain4j.data.message.UserMessage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,17 +42,17 @@ private void readAiChatsFile(BibDatabase bibDatabase, Path databasePath) { try { InputStream inputStream = new FileInputStream(chatFile); ObjectMapper objectMapper = new ObjectMapper(); - AiChatFile aiChatFile = objectMapper.readValue(inputStream, AiChatFile.class); + AiChatsFile aiChatFile = objectMapper.readValue(inputStream, AiChatsFile.class); loadAiChatHistory(bibDatabase, aiChatFile); } catch (FileNotFoundException e) { - LOGGER.info("There is no .aichats file for the opened library."); + LOGGER.info("There is no " + AI_CHAT_HISTORY_EXTENSION + " file for the opened library."); } catch (IOException e) { LOGGER.error("An error occurred while reading " + chatPath, e); } } - private void loadAiChatHistory(BibDatabase bibDatabase, AiChatFile aiChatFile) { - aiChatFile.chatHistoryMap.forEach((citationKey, chatHistory) -> { + private void loadAiChatHistory(BibDatabase bibDatabase, AiChatsFile aiChatFile) { + aiChatFile.getChatHistoryMap().forEach((citationKey, chatHistory) -> { List bibEntries = bibDatabase.getEntriesByCitationKey(citationKey); if (bibEntries.isEmpty()) { @@ -64,14 +61,9 @@ private void loadAiChatHistory(BibDatabase bibDatabase, AiChatFile aiChatFile) { LOGGER.warn("Found a chat history for an bib entry with citation key \"" + citationKey + "\" but there are several bib entries in the database with the same key"); } else { BibEntry bibEntry = bibEntries.getFirst(); - List parsedChatMessages = parseChatMessages(chatHistory); - bibEntry.getAiChatMessages().addAll(parsedChatMessages); + bibEntry.getAiChatMessages().addAll(chatHistory); } }); } - - private List parseChatMessages(List chatHistory) { - return chatHistory.stream().map(AiChatFileMessage::toLangchainMessage).filter(Optional::isPresent).map(Optional::get).toList(); - } } diff --git a/src/main/java/org/jabref/gui/importer/actions/OpenDatabaseAction.java b/src/main/java/org/jabref/gui/importer/actions/OpenDatabaseAction.java index 31571b287f0..2cd9c46c35c 100644 --- a/src/main/java/org/jabref/gui/importer/actions/OpenDatabaseAction.java +++ b/src/main/java/org/jabref/gui/importer/actions/OpenDatabaseAction.java @@ -20,7 +20,6 @@ import org.jabref.gui.actions.SimpleCommand; import org.jabref.gui.autosaveandbackup.BackupManager; import org.jabref.gui.dialogs.BackupUIManager; -import org.jabref.gui.importer.actions.loadchathistory.LoadChatHistoryAction; import org.jabref.gui.shared.SharedDatabaseUIManager; import org.jabref.gui.telemetry.Telemetry; import org.jabref.gui.undo.CountingUndoManager; diff --git a/src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFile.java b/src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFile.java deleted file mode 100644 index 848eaa968c4..00000000000 --- a/src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFile.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.jabref.gui.importer.actions.loadchathistory; - -import java.util.List; -import java.util.Map; - -public class AiChatFile { - public Map> chatHistoryMap; -} diff --git a/src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFileMessage.java b/src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFileMessage.java deleted file mode 100644 index 951f8ff3e56..00000000000 --- a/src/main/java/org/jabref/gui/importer/actions/loadchathistory/AiChatFileMessage.java +++ /dev/null @@ -1,77 +0,0 @@ -package org.jabref.gui.importer.actions.loadchathistory; - -import java.util.Optional; - -import com.fasterxml.jackson.annotation.JsonCreator; -import com.fasterxml.jackson.annotation.JsonProperty; -import dev.langchain4j.data.message.AiMessage; -import dev.langchain4j.data.message.ChatMessage; -import dev.langchain4j.data.message.SystemMessage; -import dev.langchain4j.data.message.ToolExecutionResultMessage; -import dev.langchain4j.data.message.UserMessage; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class AiChatFileMessage { - private static final Logger LOGGER = LoggerFactory.getLogger(AiChatFileMessage.class); - - public static final String USER_MESSAGE_TYPE = "user"; - public static final String AI_MESSAGE_TYPE = "ai"; - public static final String SYSTEM_MESSAGE_TYPE = "system"; - - private final String type; - private final String content; - - @JsonCreator - public AiChatFileMessage(@JsonProperty("type") String type, @JsonProperty("content") String content) { - this.type = type; - this.content = content; - } - - public static AiChatFileMessage fromLangchain(ChatMessage chatMessage) { - switch (chatMessage) { - case UserMessage userMessage -> { - return new AiChatFileMessage(USER_MESSAGE_TYPE, userMessage.singleText()); - } - case AiMessage aiMessage -> { - return new AiChatFileMessage(AI_MESSAGE_TYPE, aiMessage.text()); - } - case SystemMessage systemMessage -> { - return new AiChatFileMessage(SYSTEM_MESSAGE_TYPE, systemMessage.text()); - } - case ToolExecutionResultMessage toolExecutionResultMessage -> { - return new AiChatFileMessage(SYSTEM_MESSAGE_TYPE, toolExecutionResultMessage.text()); - } - default -> { - LOGGER.error("Found an unknown message type while parsing AI chat history: {}", chatMessage.getClass()); - return null; - } - } - } - - public Optional toLangchainMessage() { - switch (type) { - case USER_MESSAGE_TYPE -> { - return Optional.of(new UserMessage(content)); - } - case AI_MESSAGE_TYPE -> { - return Optional.of(new AiMessage(content)); - } - case SYSTEM_MESSAGE_TYPE -> { - return Optional.of(new SystemMessage(content)); - } - default -> { - LOGGER.error("Found an unknown message type while parsing AI chat history: {}", type); - return Optional.empty(); - } - } - } - - public String getType() { - return type; - } - - public String getContent() { - return content; - } -} diff --git a/src/main/java/org/jabref/logic/ai/AiChat.java b/src/main/java/org/jabref/logic/ai/AiChat.java index 0367912c085..d916f49be75 100644 --- a/src/main/java/org/jabref/logic/ai/AiChat.java +++ b/src/main/java/org/jabref/logic/ai/AiChat.java @@ -1,11 +1,11 @@ package org.jabref.logic.ai; import java.util.List; +import java.util.Optional; import java.util.UUID; import dev.langchain4j.chain.ConversationalRetrievalChain; import dev.langchain4j.data.message.AiMessage; -import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.SystemMessage; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.data.segment.TextSegment; @@ -70,6 +70,6 @@ public String execute(String prompt) { } public void restoreMessages(List messages) { - messages.forEach(this.chatMemory::add); + messages.stream().map(ChatMessage::toLangchainMessage).filter(Optional::isPresent).map(Optional::get).forEach(this.chatMemory::add); } } diff --git a/src/main/java/org/jabref/logic/ai/AiChatsFile.java b/src/main/java/org/jabref/logic/ai/AiChatsFile.java new file mode 100644 index 00000000000..d6689125b02 --- /dev/null +++ b/src/main/java/org/jabref/logic/ai/AiChatsFile.java @@ -0,0 +1,20 @@ +package org.jabref.logic.ai; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +public class AiChatsFile { + private final Map> chatHistoryMap; + + @JsonCreator + public AiChatsFile(@JsonProperty("chatHistoryMap") Map> chatHistoryMap) { + this.chatHistoryMap = chatHistoryMap; + } + + public Map> getChatHistoryMap() { + return chatHistoryMap; + } +} diff --git a/src/main/java/org/jabref/logic/ai/AiIngestor.java b/src/main/java/org/jabref/logic/ai/AiIngestor.java index 45e1c547dae..609d79eb77b 100644 --- a/src/main/java/org/jabref/logic/ai/AiIngestor.java +++ b/src/main/java/org/jabref/logic/ai/AiIngestor.java @@ -1,13 +1,9 @@ package org.jabref.logic.ai; -import java.io.FileNotFoundException; -import java.io.IOException; import java.io.StringWriter; import java.nio.file.Path; import java.util.Optional; -import org.jabref.logic.JabRefException; -import org.jabref.logic.l10n.Localization; import org.jabref.logic.util.io.FileUtil; import org.jabref.logic.xmp.XmpUtilReader; import org.jabref.model.database.BibDatabaseContext; diff --git a/src/main/java/org/jabref/logic/ai/AiService.java b/src/main/java/org/jabref/logic/ai/AiService.java index 61acc138026..38e4e09cc6e 100644 --- a/src/main/java/org/jabref/logic/ai/AiService.java +++ b/src/main/java/org/jabref/logic/ai/AiService.java @@ -4,8 +4,6 @@ import dev.langchain4j.model.embedding.AllMiniLmL6V2EmbeddingModel; import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.openai.OpenAiChatModel; -import dev.langchain4j.store.memory.chat.ChatMemoryStore; -import dev.langchain4j.store.memory.chat.InMemoryChatMemoryStore; /** * This class maintains the connection to AI services. diff --git a/src/main/java/org/jabref/logic/ai/ChatMessage.java b/src/main/java/org/jabref/logic/ai/ChatMessage.java new file mode 100644 index 00000000000..40a3288047a --- /dev/null +++ b/src/main/java/org/jabref/logic/ai/ChatMessage.java @@ -0,0 +1,62 @@ +package org.jabref.logic.ai; + +import java.util.Optional; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.UserMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ChatMessage { + private static final Logger LOGGER = LoggerFactory.getLogger(ChatMessage.class); + + private final ChatMessageType type; + private final String content; + + @JsonCreator + public ChatMessage(@JsonProperty("type") ChatMessageType type, + @JsonProperty("content") String content) { + this.type = type; + this.content = content; + } + + public ChatMessageType getType() { + return type; + } + + public String getContent() { + return content; + } + + public static Optional fromLangchain(dev.langchain4j.data.message.ChatMessage chatMessage) { + switch (chatMessage) { + case UserMessage userMessage -> { + return Optional.of(new ChatMessage(ChatMessageType.USER, userMessage.singleText())); + } + case AiMessage aiMessage -> { + return Optional.of(new ChatMessage(ChatMessageType.ASSISTANT, aiMessage.text())); + } + default -> { + LOGGER.error("Unable to convert langchain4j chat message to JabRef chat message, the type is {}", chatMessage.getClass()); + return Optional.empty(); + } + } + } + + public Optional toLangchainMessage() { + switch (type) { + case ChatMessageType.USER -> { + return Optional.of(new UserMessage(content)); + } + case ChatMessageType.ASSISTANT -> { + return Optional.of(new AiMessage(content)); + } + default -> { + LOGGER.error("Unable to convert JabRef chat message to langchain4j chat message, the type is {}", type); + return Optional.empty(); + } + } + } +} diff --git a/src/main/java/org/jabref/logic/ai/ChatMessageType.java b/src/main/java/org/jabref/logic/ai/ChatMessageType.java new file mode 100644 index 00000000000..91873e34b26 --- /dev/null +++ b/src/main/java/org/jabref/logic/ai/ChatMessageType.java @@ -0,0 +1,6 @@ +package org.jabref.logic.ai; + +public enum ChatMessageType { + USER, + ASSISTANT, +} diff --git a/src/main/java/org/jabref/model/entry/BibEntry.java b/src/main/java/org/jabref/model/entry/BibEntry.java index d21722f743d..7c4e3ae8a5d 100644 --- a/src/main/java/org/jabref/model/entry/BibEntry.java +++ b/src/main/java/org/jabref/model/entry/BibEntry.java @@ -18,10 +18,6 @@ import java.util.function.BiFunction; import java.util.stream.Collectors; -import dev.langchain4j.data.message.ChatMessage; -import dev.langchain4j.data.segment.TextSegment; -import dev.langchain4j.store.embedding.EmbeddingStore; -import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; import javafx.beans.Observable; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; @@ -29,6 +25,7 @@ import javafx.collections.ObservableMap; import org.jabref.architecture.AllowedToUseLogic; +import org.jabref.logic.ai.ChatMessage; import org.jabref.logic.bibtex.FileFieldWriter; import org.jabref.logic.importer.util.FileFieldParser; import org.jabref.model.FieldChange; From 62ea22ec3dcaca0ff1ef6ae2a3673f30a658a341 Mon Sep 17 00:00:00 2001 From: inanyan Date: Sat, 25 May 2024 11:55:54 +0300 Subject: [PATCH 24/30] Fix module-info.java --- src/main/java/module-info.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index a59a2ddd46d..5a0eba3059f 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -154,7 +154,4 @@ requires de.saxsys.mvvmfx.validation; requires dd.plist; requires mslinks; - requires langchain4j.embeddings.all.minilm.l6.v2; - requires langchain4j.core; - requires langchain4j.open.ai; } From 4ddf173c1cf91b198c7aa27a3e79d28445af269e Mon Sep 17 00:00:00 2001 From: inanyan Date: Sat, 25 May 2024 11:57:51 +0300 Subject: [PATCH 25/30] Fix SaveDatabaseAction --- src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java b/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java index 24b647f519b..47dc4b5d19e 100644 --- a/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java +++ b/src/main/java/org/jabref/gui/exporter/SaveDatabaseAction.java @@ -24,8 +24,6 @@ import org.jabref.gui.LibraryTab; import org.jabref.gui.autosaveandbackup.AutosaveManager; import org.jabref.gui.autosaveandbackup.BackupManager; -import org.jabref.gui.importer.actions.loadchathistory.AiChatFile; -import org.jabref.gui.importer.actions.loadchathistory.AiChatFileMessage; import org.jabref.gui.importer.actions.LoadChatHistoryAction; import org.jabref.gui.maintable.BibEntryTableViewModel; import org.jabref.gui.maintable.columns.MainTableColumn; From f9bd0d0153042dc925dec8b2c46ad86ed68d96f0 Mon Sep 17 00:00:00 2001 From: inanyan Date: Sat, 25 May 2024 12:12:29 +0300 Subject: [PATCH 26/30] Fix chat UI --- .../aichattab/AiChatComponent.java | 12 +---- .../gui/entryeditor/aichattab/AiChatTab.java | 6 +-- .../aichattab/ChatMessageComponent.java | 44 +++---------------- .../java/org/jabref/logic/ai/ChatMessage.java | 12 ++++- 4 files changed, 20 insertions(+), 54 deletions(-) diff --git a/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatComponent.java b/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatComponent.java index 2f209cf143a..6466f66ecf0 100644 --- a/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatComponent.java +++ b/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatComponent.java @@ -1,28 +1,20 @@ package org.jabref.gui.entryeditor.aichattab; -import dev.langchain4j.data.message.AiMessage; -import dev.langchain4j.data.message.ChatMessage; -import dev.langchain4j.data.message.UserMessage; -import dev.langchain4j.service.V; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.control.Button; -import javafx.scene.control.ProgressIndicator; import javafx.scene.control.ScrollPane; import javafx.scene.control.TextField; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; -import oracle.jdbc.driver.Const; -import org.jabref.gui.util.BackgroundTask; -import org.jabref.logic.ai.AiChat; + +import org.jabref.logic.ai.ChatMessage; import org.jabref.logic.l10n.Localization; -import org.jabref.model.entry.BibEntry; import java.util.List; import java.util.function.Consumer; -import java.util.function.Function; public class AiChatComponent { private final Consumer sendMessageCallback; diff --git a/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java index 21d7229044f..d013587ddb6 100644 --- a/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java @@ -23,6 +23,7 @@ import org.jabref.logic.ai.AiChat; import org.jabref.logic.ai.AiService; import org.jabref.logic.ai.AiIngestor; +import org.jabref.logic.ai.ChatMessage; import org.jabref.logic.l10n.Localization; import org.jabref.logic.util.io.FileUtil; import org.jabref.model.database.BibDatabaseContext; @@ -34,7 +35,6 @@ import com.tobiasdiez.easybind.EasyBind; import dev.langchain4j.data.message.AiMessage; -import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.ChatMessageType; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.data.segment.TextSegment; @@ -173,7 +173,7 @@ private void ingestFiles(BibEntry entry) { private void buildChatUI(BibEntry entry) { aiChatComponent = new AiChatComponent((userPrompt) -> { - UserMessage userMessage = new UserMessage(userPrompt); + ChatMessage userMessage = ChatMessage.user(userPrompt); aiChatComponent.addMessage(new ChatMessageComponent(userMessage)); entry.getAiChatMessages().add(userMessage); @@ -182,7 +182,7 @@ private void buildChatUI(BibEntry entry) { BackgroundTask.wrap(() -> aiChat.execute(userPrompt)) .onSuccess(aiMessageText -> { - AiMessage aiMessage = new AiMessage(aiMessageText); + ChatMessage aiMessage = ChatMessage.assistant(aiMessageText); aiChatMessageComponent.setMessage(aiMessage); entry.getAiChatMessages().add(aiMessage); }) diff --git a/src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java b/src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java index 5086f89d6c6..f235fad1e76 100644 --- a/src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java +++ b/src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java @@ -1,6 +1,5 @@ package org.jabref.gui.entryeditor.aichattab; -import dev.langchain4j.data.message.*; import javafx.geometry.Insets; import javafx.geometry.NodeOrientation; import javafx.scene.Node; @@ -9,6 +8,9 @@ import javafx.scene.control.TextArea; import javafx.scene.layout.Pane; import javafx.scene.layout.VBox; + +import org.jabref.logic.ai.ChatMessage; +import org.jabref.logic.ai.ChatMessageType; import org.jabref.logic.l10n.Localization; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -47,11 +49,7 @@ public ChatMessageComponent(ChatMessage chatMessage) { } public void setMessage(ChatMessage chatMessage) { - if (!(chatMessage instanceof UserMessage) && !(chatMessage instanceof AiMessage)) { - return; - } - - boolean isUser = chatMessage instanceof UserMessage; + boolean isUser = chatMessage.getType() == ChatMessageType.USER; if (isUser) { pane.setNodeOrientation(NodeOrientation.RIGHT_TO_LEFT); @@ -64,7 +62,7 @@ public void setMessage(ChatMessage chatMessage) { contentPane.getChildren().clear(); contentPane.setStyle(""); - contentPane.getChildren().add(makeMessageTextArea(getChatMessageText(chatMessage))); + contentPane.getChildren().add(makeMessageTextArea(chatMessage.getContent())); } public void setError(String message) { @@ -93,38 +91,6 @@ private static TextArea makeMessageTextArea(String content) { return message; } - // Safely gets text from a chat message. - private static String getChatMessageText(ChatMessage chatMessage) { - // This mangling is needed because ChatMessage.text() is deprecated. - - switch (chatMessage) { - case AiMessage aiChatMessage -> { - return aiChatMessage.text(); - } - - case UserMessage userChatMessage -> { - if (userChatMessage.hasSingleText()) { - return userChatMessage.singleText(); - } else { - return ""; - } - } - - case SystemMessage systemChatMessage -> { - return systemChatMessage.text(); - } - - case ToolExecutionResultMessage toolChatMessage -> { - return toolChatMessage.text(); - } - - case null, default -> { - LOGGER.error("Unimplemented getChatMessageText for a new kind of ChatMessage"); - return ""; - } - } - } - public Node getNode() { return pane; } diff --git a/src/main/java/org/jabref/logic/ai/ChatMessage.java b/src/main/java/org/jabref/logic/ai/ChatMessage.java index 40a3288047a..e9afbcffe9e 100644 --- a/src/main/java/org/jabref/logic/ai/ChatMessage.java +++ b/src/main/java/org/jabref/logic/ai/ChatMessage.java @@ -22,6 +22,14 @@ public ChatMessage(@JsonProperty("type") ChatMessageType type, this.content = content; } + public static ChatMessage user(String content) { + return new ChatMessage(ChatMessageType.USER, content); + } + + public static ChatMessage assistant(String content) { + return new ChatMessage(ChatMessageType.ASSISTANT, content); + } + public ChatMessageType getType() { return type; } @@ -33,10 +41,10 @@ public String getContent() { public static Optional fromLangchain(dev.langchain4j.data.message.ChatMessage chatMessage) { switch (chatMessage) { case UserMessage userMessage -> { - return Optional.of(new ChatMessage(ChatMessageType.USER, userMessage.singleText())); + return Optional.of(ChatMessage.user(userMessage.singleText())); } case AiMessage aiMessage -> { - return Optional.of(new ChatMessage(ChatMessageType.ASSISTANT, aiMessage.text())); + return Optional.of(ChatMessage.assistant(aiMessage.text())); } default -> { LOGGER.error("Unable to convert langchain4j chat message to JabRef chat message, the type is {}", chatMessage.getClass()); From 43845ae7fec3087e06809041b84525226edd185c Mon Sep 17 00:00:00 2001 From: inanyan Date: Sat, 25 May 2024 15:27:48 +0300 Subject: [PATCH 27/30] Make custom components for UI --- src/main/java/module-info.java | 1 + .../gui/entryeditor/aichattab/AiChatTab.java | 34 +++---- .../aichattab/ChatMessageComponent.java | 97 ------------------- .../AiChatComponentOld.java} | 13 +-- .../components/aichat/AiChatComponent.fxml | 31 ++++++ .../components/aichat/AiChatComponent.java | 72 ++++++++++++++ .../chatmessage/ChatMessageComponent.fxml | 24 +++++ .../chatmessage/ChatMessageComponent.java | 90 +++++++++++++++++ .../java/org/jabref/logic/ai/ChatMessage.java | 11 +++ 9 files changed, 247 insertions(+), 126 deletions(-) delete mode 100644 src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java rename src/main/java/org/jabref/gui/entryeditor/aichattab/{AiChatComponent.java => components/AiChatComponentOld.java} (84%) create mode 100644 src/main/java/org/jabref/gui/entryeditor/aichattab/components/aichat/AiChatComponent.fxml create mode 100644 src/main/java/org/jabref/gui/entryeditor/aichattab/components/aichat/AiChatComponent.java create mode 100644 src/main/java/org/jabref/gui/entryeditor/aichattab/components/chatmessage/ChatMessageComponent.fxml create mode 100644 src/main/java/org/jabref/gui/entryeditor/aichattab/components/chatmessage/ChatMessageComponent.java diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index 5a0eba3059f..5e3114aa2af 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -154,4 +154,5 @@ requires de.saxsys.mvvmfx.validation; requires dd.plist; requires mslinks; + requires jakarta.validation; } diff --git a/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java b/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java index d013587ddb6..fc01a5430f8 100644 --- a/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java +++ b/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatTab.java @@ -1,23 +1,14 @@ package org.jabref.gui.entryeditor.aichattab; import java.nio.file.Path; -import java.util.ArrayList; import java.util.Optional; -import javafx.geometry.Insets; -import javafx.geometry.NodeOrientation; -import javafx.geometry.Pos; -import javafx.scene.Node; import javafx.scene.control.*; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Pane; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; -import org.checkerframework.checker.units.qual.C; -import org.jabref.gui.DialogService; import org.jabref.gui.entryeditor.EntryEditorPreferences; import org.jabref.gui.entryeditor.EntryEditorTab; +import org.jabref.gui.entryeditor.aichattab.components.AiChatComponentOld; +import org.jabref.gui.entryeditor.aichattab.components.aichat.AiChatComponent; import org.jabref.gui.util.BackgroundTask; import org.jabref.gui.util.TaskExecutor; import org.jabref.logic.ai.AiChat; @@ -34,14 +25,10 @@ import org.jabref.preferences.PreferencesService; import com.tobiasdiez.easybind.EasyBind; -import dev.langchain4j.data.message.AiMessage; -import dev.langchain4j.data.message.ChatMessageType; -import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.store.embedding.EmbeddingStore; import dev.langchain4j.store.embedding.inmemory.InMemoryEmbeddingStore; import org.slf4j.LoggerFactory; -import org.tinylog.Logger; public class AiChatTab extends EntryEditorTab { public static final String NAME = "AI chat"; @@ -174,28 +161,29 @@ private void ingestFiles(BibEntry entry) { private void buildChatUI(BibEntry entry) { aiChatComponent = new AiChatComponent((userPrompt) -> { ChatMessage userMessage = ChatMessage.user(userPrompt); - aiChatComponent.addMessage(new ChatMessageComponent(userMessage)); + aiChatComponent.addMessage(userMessage); entry.getAiChatMessages().add(userMessage); - - ChatMessageComponent aiChatMessageComponent = new ChatMessageComponent(); - aiChatComponent.addMessage(aiChatMessageComponent); + aiChatComponent.setLoading(true); BackgroundTask.wrap(() -> aiChat.execute(userPrompt)) .onSuccess(aiMessageText -> { + aiChatComponent.setLoading(false); + ChatMessage aiMessage = ChatMessage.assistant(aiMessageText); - aiChatMessageComponent.setMessage(aiMessage); + aiChatComponent.addMessage(aiMessage); entry.getAiChatMessages().add(aiMessage); }) .onFailure(e -> { // TODO: User-friendly error message. LOGGER.error("Got an error while sending a message to AI", e); - aiChatMessageComponent.setError(e.getMessage()); + aiChatComponent.setLoading(false); + aiChatComponent.addError(e.getMessage()); }) .executeWith(taskExecutor); }); - aiChatComponent.restoreMessages(entry.getAiChatMessages()); + entry.getAiChatMessages().forEach(aiChatComponent::addMessage); - setContent(aiChatComponent.getNode()); + setContent(aiChatComponent); } } diff --git a/src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java b/src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java deleted file mode 100644 index f235fad1e76..00000000000 --- a/src/main/java/org/jabref/gui/entryeditor/aichattab/ChatMessageComponent.java +++ /dev/null @@ -1,97 +0,0 @@ -package org.jabref.gui.entryeditor.aichattab; - -import javafx.geometry.Insets; -import javafx.geometry.NodeOrientation; -import javafx.scene.Node; -import javafx.scene.control.Label; -import javafx.scene.control.ProgressIndicator; -import javafx.scene.control.TextArea; -import javafx.scene.layout.Pane; -import javafx.scene.layout.VBox; - -import org.jabref.logic.ai.ChatMessage; -import org.jabref.logic.ai.ChatMessageType; -import org.jabref.logic.l10n.Localization; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class ChatMessageComponent { - private static final Logger LOGGER = LoggerFactory.getLogger(ChatMessageComponent.class); - - private final Pane pane; - private final VBox paneVBox; - private final Label authorLabel; - private final Pane contentPane; - - public ChatMessageComponent() { - pane = new Pane(); - - paneVBox = new VBox(10); - paneVBox.setMaxWidth(500); - - paneVBox.setPadding(new Insets(10)); - paneVBox.setStyle("-fx-background-color: -jr-ai-message-ai"); - - authorLabel = new Label(Localization.lang("AI")); - authorLabel.setStyle("-fx-font-weight: bold"); - paneVBox.getChildren().add(authorLabel); - - contentPane = new Pane(); - contentPane.getChildren().add(new ProgressIndicator()); - paneVBox.getChildren().add(contentPane); - - pane.getChildren().add(paneVBox); - } - - public ChatMessageComponent(ChatMessage chatMessage) { - this(); - setMessage(chatMessage); - } - - public void setMessage(ChatMessage chatMessage) { - boolean isUser = chatMessage.getType() == ChatMessageType.USER; - - if (isUser) { - pane.setNodeOrientation(NodeOrientation.RIGHT_TO_LEFT); - } - - paneVBox.setStyle("-fx-background-color: " + (isUser ? "-jr-ai-message-user" : "-jr-ai-message-ai") + ";"); - - authorLabel.setText(Localization.lang(isUser ? "User" : "AI")); - - contentPane.getChildren().clear(); - contentPane.setStyle(""); - - contentPane.getChildren().add(makeMessageTextArea(chatMessage.getContent())); - } - - public void setError(String message) { - contentPane.getChildren().clear(); - contentPane.setStyle(""); - contentPane.setStyle("-fx-background-color: -jr-red"); - - VBox paneVBox = new VBox(10); - paneVBox.setMaxWidth(500); - paneVBox.setPadding(new Insets(10)); - - Label errorLabel = new Label(Localization.lang("Error")); - errorLabel.setStyle("-fx-font-weight: bold"); - paneVBox.getChildren().add(errorLabel); - - TextArea messageTextArea = makeMessageTextArea(message); - paneVBox.getChildren().add(messageTextArea); - - contentPane.getChildren().add(paneVBox); - } - - private static TextArea makeMessageTextArea(String content) { - TextArea message = new TextArea(content); - message.setWrapText(true); - message.setEditable(false); - return message; - } - - public Node getNode() { - return pane; - } -} diff --git a/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatComponent.java b/src/main/java/org/jabref/gui/entryeditor/aichattab/components/AiChatComponentOld.java similarity index 84% rename from src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatComponent.java rename to src/main/java/org/jabref/gui/entryeditor/aichattab/components/AiChatComponentOld.java index 6466f66ecf0..2a1146dff89 100644 --- a/src/main/java/org/jabref/gui/entryeditor/aichattab/AiChatComponent.java +++ b/src/main/java/org/jabref/gui/entryeditor/aichattab/components/AiChatComponentOld.java @@ -1,4 +1,4 @@ -package org.jabref.gui.entryeditor.aichattab; +package org.jabref.gui.entryeditor.aichattab.components; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -10,20 +10,21 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; +import org.jabref.gui.entryeditor.aichattab.components.chatmessage.ChatMessageComponent; import org.jabref.logic.ai.ChatMessage; import org.jabref.logic.l10n.Localization; import java.util.List; import java.util.function.Consumer; -public class AiChatComponent { +public class AiChatComponentOld { private final Consumer sendMessageCallback; private final VBox aiChatBox = new VBox(10); private final VBox chatVBox = new VBox(10); private final TextField userPromptTextField = new TextField(); - public AiChatComponent(Consumer sendMessageCallback) { + public AiChatComponentOld(Consumer sendMessageCallback) { this.sendMessageCallback = sendMessageCallback; buildUI(); @@ -68,8 +69,8 @@ private Node constructUserPromptBox() { return userPromptHBox; } - public void addMessage(ChatMessageComponent chatMessageComponent) { - chatVBox.getChildren().add(chatMessageComponent.getNode()); + public void addMessage(ChatMessage chatMessage) { + chatVBox.getChildren().add(new ChatMessageComponent().withChatMessage(chatMessage)); } private void internalSendMessageEvent() { @@ -79,7 +80,7 @@ private void internalSendMessageEvent() { } public void restoreMessages(List messages) { - messages.forEach(message -> addMessage(new ChatMessageComponent(message))); + messages.forEach(this::addMessage); } public Node getNode() { diff --git a/src/main/java/org/jabref/gui/entryeditor/aichattab/components/aichat/AiChatComponent.fxml b/src/main/java/org/jabref/gui/entryeditor/aichattab/components/aichat/AiChatComponent.fxml new file mode 100644 index 00000000000..84db7eda6f0 --- /dev/null +++ b/src/main/java/org/jabref/gui/entryeditor/aichattab/components/aichat/AiChatComponent.fxml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +