diff --git a/.github/workflows/deployment-jdk-ea.yml b/.github/workflows/deployment-jdk-ea.yml index 9bc7e1e3ddc..c31be0111f1 100644 --- a/.github/workflows/deployment-jdk-ea.yml +++ b/.github/workflows/deployment-jdk-ea.yml @@ -35,7 +35,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest, buildjet-4vcpu-ubuntu-2204-arm] + os: [ubuntu-latest, windows-latest, macos-latest, buildjet-8vcpu-ubuntu-2204-arm] jdk: [22] javafx: [23] include: @@ -45,7 +45,7 @@ jobs: - os: windows-latest displayName: windows archivePortable: 7z a -r build/distribution/JabRef-portable_windows.zip ./build/distribution/JabRef && rm -R build/distribution/JabRef - - os: buildjet-4vcpu-ubuntu-2204-arm + - os: buildjet-8vcpu-ubuntu-2204-arm displayName: "linux-arm" archivePortable: "tar -c -C build/distribution JabRef | pigz --rsyncable > build/distribution/JabRef-portable_linux-arm64.tar.gz && rm -R build/distribution/JabRef" - os: macos-latest @@ -78,7 +78,7 @@ jobs: submodules: 'true' show-progress: 'false' - name: Install pigz and cache (linux) - if: (matrix.os == 'ubuntu-latest') || (matrix.os == 'buildjet-4vcpu-ubuntu-2204-arm') + if: (matrix.os == 'ubuntu-latest') || (matrix.os == 'buildjet-8vcpu-ubuntu-2204-arm') uses: awalsh128/cache-apt-pkgs-action@master with: packages: pigz @@ -115,7 +115,7 @@ jobs: # JavaFX - name: Download and extract JavaFX ${{ matrix.javafx }} - if: (matrix.os != 'buildjet-4vcpu-ubuntu-2204-arm') + if: (matrix.os != 'buildjet-8vcpu-ubuntu-2204-arm') shell: bash run: | cd javafx @@ -127,7 +127,7 @@ jobs: EXTRACT="tar xzf *.tar.gz" EXT="tar.gz" ;; - "buildjet-4vcpu-ubuntu-2204-arm") + "buildjet-8vcpu-ubuntu-2204-arm") OS="linux" EXTRACT="tar xzf *.tar.gz" EXT="tar.gz" @@ -163,19 +163,19 @@ jobs: $EXTRACT rm *.$EXT - name: 'Set JavaFX ${{ matrix.javafx }} (linux, Windows)' - if: (matrix.os != 'macos-latest') && (matrix.os != 'buildjet-4vcpu-ubuntu-2204-arm') + if: (matrix.os != 'macos-latest') && (matrix.os != 'buildjet-8vcpu-ubuntu-2204-arm') run: | sed -i '/javafx {/{n;s#version = ".*"#sdk = "javafx/javafx-sdk-${{ matrix.javafx }}"#}' build.gradle sed -i "s#jlink {#jlink { addExtraModulePath 'javafx/javafx-jmods-${{ matrix.javafx }}'#" build.gradle cat build.gradle - name: 'Set JavaFX ${{ matrix.javafx }} (macOS)' - if: (matrix.os == 'macos-latest') && (matrix.os != 'buildjet-4vcpu-ubuntu-2204-arm') + if: (matrix.os == 'macos-latest') && (matrix.os != 'buildjet-8vcpu-ubuntu-2204-arm') run: | sed -i '.bak' -e '/javafx {/{n' -e 's#version = ".*"#sdk = "javafx/javafx-sdk-${{ matrix.javafx }}"#;}' build.gradle sed -i '.bak' -e "s#jlink {#jlink { addExtraModulePath 'javafx/javafx-jmods-${{ matrix.javafx }}'#" build.gradle cat build.gradle - name: 'Set JavaFX ${{ matrix.javafx }} (linux-arm)' - if: (matrix.os == 'buildjet-4vcpu-ubuntu-2204-arm') + if: (matrix.os == 'buildjet-8vcpu-ubuntu-2204-arm') # No JavaFX EA build for ARM at https://jdk.java.net/javafx23/, therefore using Maven Central artifact run: | curl -s "https://search.maven.org/solrsearch/select?q=g:org.openjfx+AND+a:javafx&rows=10&core=gav" > /tmp/versions.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 9da6dc37261..ea8e73f4da0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Added +- We added an AI-based chat for entries with linked PDF files. [#11430](https://github.com/JabRef/jabref/pull/11430) +- We added an AI-based summarization possibility for entries with linked PDF files. [#11430](https://github.com/JabRef/jabref/pull/11430) - We added support for selecting and using CSL Styles in JabRef's OpenOffice/LibreOffice integration for inserting bibliographic and in-text citations into a document. [#2146](https://github.com/JabRef/jabref/issues/2146), [#8893](https://github.com/JabRef/jabref/issues/8893) - We added Tools > New library based on references in PDF file... to create a new library based on the references section in a PDF file. [#11522](https://github.com/JabRef/jabref/pull/11522) - When converting the references section of a paper (PDF file), more than the last page is treated. [#11522](https://github.com/JabRef/jabref/pull/11522) diff --git a/build.gradle b/build.gradle index ea72c296b06..b14aa90cb5b 100644 --- a/build.gradle +++ b/build.gradle @@ -45,6 +45,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) @@ -125,6 +126,8 @@ repositories { maven { url 'https://s01.oss.sonatype.org/content/repositories/snapshots/' } maven { url 'https://jitpack.io' } maven { url 'https://oss.sonatype.org/content/groups/public' } + + // Required for one.jpro.jproutils:tree-showing maven { url 'https://sandec.jfrog.io/artifactory/repo' } } @@ -238,12 +241,17 @@ dependencies { exclude module: 'commons-lang3' exclude group: 'org.apache.commons.validator' exclude group: 'org.apache.commons.commons-logging' + 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,6 +323,25 @@ dependencies { // YAML formatting implementation 'org.yaml:snakeyaml:2.2' + // AI + implementation 'dev.langchain4j:langchain4j:0.33.0' + // Even though we use jvm-openai for LLM connection, we still need this package for tokenization. + implementation('dev.langchain4j:langchain4j-open-ai:0.33.0') { + exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8' + } + implementation('dev.langchain4j:langchain4j-mistral-ai:0.33.0') + implementation('dev.langchain4j:langchain4j-hugging-face:0.33.0') + implementation 'ai.djl:api:0.29.0' + implementation 'ai.djl.pytorch:pytorch-model-zoo:0.29.0' + implementation 'ai.djl.huggingface:tokenizers:0.29.0' + implementation 'io.github.stefanbratanov:jvm-openai:0.9.3' + // openai depends on okhttp, which needs kotlin - see https://github.com/square/okhttp/issues/5299 for details + implementation ('com.squareup.okhttp3:okhttp:4.12.0') { + exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8' + } + // GemxFX also (transitively) depends on kotlin + implementation 'org.jetbrains.kotlin:kotlin-stdlib-jdk8:1.9.24' + implementation 'commons-io:commons-io:2.16.1' testImplementation 'io.github.classgraph:classgraph:4.8.174' @@ -455,8 +482,8 @@ compileJava { options.generatedSourceOutputDirectory.set(file("src-gen/main/java")) moduleOptions { - // TODO: Remove access to internal api addExports = [ + // TODO: Remove access to internal api 'javafx.controls/com.sun.javafx.scene.control' : 'org.jabref', 'org.controlsfx.controls/impl.org.controlsfx.skin' : 'org.jabref' ] @@ -470,10 +497,10 @@ run { application.applicationDefaultJvmArgs = [] } - // TODO: Remove access to internal api moduleOptions { // On a change here, also adapt "application > applicationDefaultJvmArgs" addExports = [ + // TODO: Remove access to internal api 'javafx.base/com.sun.javafx.event' : 'org.jabref.merged.module', 'javafx.controls/com.sun.javafx.scene.control' : 'org.jabref', diff --git a/docs/decisions/0032-store-chats-in-local-user-folder.md b/docs/decisions/0032-store-chats-in-local-user-folder.md new file mode 100644 index 00000000000..c673b81f4c7 --- /dev/null +++ b/docs/decisions/0032-store-chats-in-local-user-folder.md @@ -0,0 +1,66 @@ +--- +nav_order: 0032 +parent: Decision Records +--- +# Store Chats Alongside Database + +## Context and Problem Statement + +Chats with AI should be stored somewhere. But where and how? + +## Considered Options + +* Inside `.bib` file +* In local user folder +* Alongside `.bib` file + +## Decision Drivers + +* Should work when shared with OneDrive, Dropbox or similar asynchronous services +* Should work on network drives +* Should be "easy" for users to follow +* Should be the same in a shared and non-shared setting (e.g., if Dropbox is used or not should make a difference) + +## Decision Outcome + +Chosen option: "In local user folder", because +it's very hard to work with a shared library, if two users will work +simultaneously on one library, then AI chats file will be absolutely arbitrary +and unmergable. + +## Pros and Cons of the Options + +### Inside `.bib` file + +* Good, because we already have a machinery for managing the fields and other information of BIB entries +* Good, because chats are stored inside one file, and if the `.bib` file is moved, the chat history is preserved +* Bad, because there may be lots of chats and messages and `.bib` file become too cluttered and too big which slows down the processing of `.bib` file +* Bad, because if user shares a `.bib` file, they will also share chat messages, but chats are not ideal, so user may not + want to share them + +### In local user folder + +One can use `%APPDATA%`, where JabRef stores the Lucene index and other information. +See `org.jabref.gui.desktop.os.NativeDesktop#getFulltextIndexBaseDirectory` for use in JabRef and + for general information. + +Concrete example for backup folder: `C:\Users\${username}\AppData\Local\org.jabref\jabref\backups`. +Example filename: `4a070cf3--Chocolate.bib--2024-03-25--14.20.12.bak`. + +* Good, because `.bib` file is kept clean +* Good, because chat messages are saved locally +* Neutral, because may be a little harder to implement +* Bad, because chat messages cannot be easily shared +* Bad, because when path of a `.bib` file is changed, the chats are lost + +### Alongside `.bib` file + +* Good, because simple implementation +* Good, because, the user can send the chats file alongside the `.bib` file if they want to share the chats. If users do not want + to share the messages, then they can omit the chats file +* Good, because `.bib` files is kept clean +* Bad, because user may not expect that a new file will be created alongside their `.bib` (or other LaTeX-related) files +* Bad, because, it may be not convenient to share both files (`.bib` file and chats file) in order to share chat history. +* Bad, because if `.bib` files are edited externally (meaning, not inside the JabRef), then chats file will not be updated correspondingly +* Bad, because if user moves `.bib` file, they should move the chats file too +* Bad, because if two persons work in parallel using a OneDrive share, the file is overwritten or a conflict file is generated. ([Dropbox "conflicted copy"](https://help.dropbox.com/en-en/organize/conflicted-copy)) diff --git a/docs/decisions/0033-store-chats-in-mvstore.md b/docs/decisions/0033-store-chats-in-mvstore.md new file mode 100644 index 00000000000..c2baf32a6fd --- /dev/null +++ b/docs/decisions/0033-store-chats-in-mvstore.md @@ -0,0 +1,53 @@ +--- +nav_order: 0033 +parent: Decision Records +--- + +# Store Chats in MVStore + +## Context and Problem Statement + +This is a follow-up to [ADR-031](0032-store-chats-in-local-user-folder). + +The chats with AI should be saved on exit from JabRef and retrieved on launch. We need to decide the format of +the serialized messages. + +## Decision Drivers + +* Easy to implement and maintain +* Memory-efficient (because JabRef is said to consume much memory) + +## Considered Options + +* JSON +* MVStore +* Custom format + +## Decision Outcome + +Chosen option: "MVStore", because it is simple and memory-efficient. + +## Pros and Cons of the Options + +### JSON + +* Good, because allows for easy storing and loading of chats +* Good, because cross-platform +* Good, because widely used and accepted, so there are lots of libraries for JSON format +* Good, because it is even possible to reuse the chats file for other purposes +* Good, because has potential for being mergeable by external tooling +* Bad, because too verbose (meaning the file size could be much smaller) + +### MVStore + +* Good, because automatic loading and saving to disk +* Good, because memory-efficient +* Bad, because does not support mutable values in maps. +* Bad, because the order of messages need to be "hand-crafted" (e.g., by mapping from an Integer to the concrete message), since [MVStore does not support storing list which update](https://github.com/koppor/mvstore-mwe/pull/1). +* Bad, because it stores data as key-values, but not as a custom data type (like tables in RDBMS) + +### Custom format + +* Good, because we have the full control +* Bad, because involves writing our own language and parser +* Bad, because we need to implement optimizations found in databases on our own (storing some data in RAM, other on disk) diff --git a/docs/decisions/0034-use-citation-key-for-grouping-chat-messages.md b/docs/decisions/0034-use-citation-key-for-grouping-chat-messages.md new file mode 100644 index 00000000000..f6ef24dc69f --- /dev/null +++ b/docs/decisions/0034-use-citation-key-for-grouping-chat-messages.md @@ -0,0 +1,69 @@ +--- +nav_order: 0034 +parent: Decision Records +--- + +# Use Citation Key for Grouping Chat Messages + +## Context and Problem Statement + +Because we store chat messages not inside a BIB entry in `.bib` filecc, the chats file is represented as a map to +BIB entry and a list of messages. We need to specify the key of this map. Turns out, it is not that easy. + +## Decision Drivers + +* The key should exist for every BIB entry +* The key should be unique along other BIB entries in one library file +* The key should not change at run-time, between launches of JabRef, and should be cross-platform (most important) + +## Considered Options + +* `BibEntry` Java object +* `BibEntry`'s `id` +* `BibEntry`'s Citation key +* `BibEntry`'s `ShareId` + +## Decision Outcome + +Chosen option: "`BibEntry`'s Citation key", because this is the only choice that complains to the third point in Decision Drivers. + +### Positive Consequences + +* Easy to implement +* Cross-platform + +### Negative Consequences + +* If the citation key is changed externally, then the chats file becomes out-of-sync +* Additional user interaction in order to make the citation key complain the first and second points of Decision Drivers + +## Pros and Cons of the Options + +### `BibEntry` Java object + +Very bad, because it works only at run-time and is not stable. + +### `BibEntry`'s `id` + +JabRef stores a unique identifier for each `BibEntry`. +This identifier is created on each load of a library (and not stored permanently). + +Very bad, for the same reasons as `BibEntry` Java object. + +### `BibEntry`'s Citation key + +* Good, because it is cross-platform, stable (meaning stays the same across launches of JabRef) +* Bad, because it is not guaranteed that citation key exists on `BibEntry`, and that it is unique across other +`BibEntriy`'s' in the library + +### `BibEntry`'s `ShareId` + +[ADR-0027](0027-synchronization.md) describes the procedure of synchronization of a Bib(La)TeX library with a server. +Thereby, also local and remote entries need to be kept consistent. +The solution chosen there is that the **server** creates a UUID for each entry. + +This approach cannot be used here, because there is no server running which we can ask for an UUID of an entry. + +## More Information + +Refer to [issue #160](https://github.com/JabRef/jabref/issues/160) in JabRef main repository diff --git a/docs/decisions/0035-generate-embeddings-online.md b/docs/decisions/0035-generate-embeddings-online.md new file mode 100644 index 00000000000..ea01351b4f6 --- /dev/null +++ b/docs/decisions/0035-generate-embeddings-online.md @@ -0,0 +1,53 @@ +--- +nav_order: 0035 +parent: Decision Records +--- + +# Generate Embeddings Online + +## Context and Problem Statement + +In order to perform a question and answering (Q&A) session over research papers +with large language model (LLM), we need to process each file: each file should +be converted to string, then this string is split into chunks, and for each chunk +an embedding vector should be generated. + +Where these embeddings should be generated? + +## Considered Options + +* Local embedding model with `langchain4j` +* OpenAI embedding API + +## Decision Drivers + +* Embedding generation should be fast +* Embeddings should have good performance (performance mean they "catch the semantics" good, see also [MTEB](https://huggingface.co/blog/mteb)) +* Generating embeddings should be cheap +* Embeddings should not be of a big size +* Embedding models and library to generate embeddings shouldn't be big in distribution binary. + +## Decision Outcome + +Chosen option: "OpenAI embedding API", because +the distribution size of JabRef will be nearly unaffected. Also, it's fast +and has a better performance, in comparison to available in `langchain4j`'s model `all-MiniLM-L6-v2`. + +## Pros and Cons of the Options + +### Local embedding model with `langchain4j` + +* Good, because works locally, privacy saved, no Internet connection is required +* Good, because user doesn't pay for anything +* Neutral, because how fast embedding generation is depends on chosen model. It may be small and fast, or big and time-consuming +* Neutral, because local embedding models may have less performance than OpenAI's (for example). *Actually, most embedding models suitable for use in JabRef are about ~50% performant) +* Bad, because embedding generation takes computer resources +* Bad, because the only framework to run embedding models in Java is ONNX, and it's very heavy in distribution binary + +### OpenAI embedding API + +* Good, because we delegate the task of generating embeddings to an online service, so the user's computer is free to do some other job +* Good, because OpenAI models have typically have better performance +* Good, because JabRef distribution size will practically be unaffected +* Bad, because user should agree to send data to a third-party service, Internet connection is required +* Bad, because user pay for embedding generation (see also [OpenAI embedding models pricing](https://platform.openai.com/docs/guides/embeddings/embedding-models)) diff --git a/docs/decisions/0036-use-textarea-for-chat-content.md b/docs/decisions/0036-use-textarea-for-chat-content.md new file mode 100644 index 00000000000..1acfe73b665 --- /dev/null +++ b/docs/decisions/0036-use-textarea-for-chat-content.md @@ -0,0 +1,84 @@ +--- +nav_order: 0036 +parent: Decision Records +--- + +# Use TextArea for Chat Message Content + +## Context and Problem Statement + +This decision record concerns the UI component that is used for rendering the content of chat messages. + +## Decision Drivers + +* Looks good (renders Markdown) +* User can select and copy text +* Has good performance + +## Considered Options + +* Use `TextArea` +* Use a [third-party package](https://github.com/JPro-one/jpro-platform) +* Use a Markdown parser and convert AST nodes to JavaFX TextFlow elements +* Use a Markdown parser to convert content into HTML and use a WebView for one message +* Use a Markdown parser and WebView for the whole chat history + +## Decision Outcome + +Chosen option: "Use TextArea". +All other options require more time to implement. +Some of the options do not support text selection and copying, +which for now we value more than Markdown rendering. + +## Pros and Cons of the Options + +### Use TextArea + +* Good, because it is easy to implement +* Good, because it supports text selection and copying +* Bad, because it does not offer rich text. Thus, Markdown can only be displayed in a plain text form. +* Bad, because default JavaFX's `TextArea` shrinks + +### Use a third-party package + +There seems to be only one package for JavaFX that provides a ready-to-use UI node for Markdown rendering. + +* Good, because it is easy to implement +* Good, because it renders Markdown +* Good, because it renders Markdown to JavaFX nodes (does not use a `WebView`) +* Good, because complex elements from Markdown are supported (tables, code blocks, etc.) +* Bad, because it has very strange issues and architectural flaws with styling +* Bad, because it does not support text selection and copying (because of underlying JavaFX `Text` nodes) + +### Use a Markdown parser and convert AST nodes to JavaFX TextFlow elements + +* Good, because we will support Markdown +* Good, because no need to write a Markdown parser from scratch +* Good, because does not use a WebView +* Good, because easy styling +* Bad, because we need some time to implement Markdown AST -> JavaFX nodes converter +* Bad, because rendering tables and code blocks may be hard +* Bad, because it will not support text selection and copying + +### Use a Markdown parser to convert content into HTML and use a WebView for one message + +* Good, because there are libraries to convert Markdown to HTML +* Good, because may be easier to implement than other choices (except `TextArea`) +* Good, because it supports text selection and copying +* Bad, because it may be a problem to connect JavaFX CSS to `WebView` +* Bad, because one `WebView` for one message is resourceful + +### Use a Markdown parser and WebView for the whole chat history + +* Good, because there are libraries to convert Markdown to HTML +* Good, because it supports text selection and copying +* Bad, because it may be a problem to connect JavaFX CSS to `WebView` +* Bad, because it may be a problem to correctly communicate with Java code and `WebView` to add new messages + +## More Information + +Actually we used an `ExpandingTextArea` from `GemsFX` package so the content can occupy +as much space as it needs in the `ScrollPane`. + +About the selection and copying, this goes down to fundamental issue from JavaFX. +`Text` and `Label` cannot be selected by any means. diff --git a/docs/decisions/0037-rag-architecture-implementation.md b/docs/decisions/0037-rag-architecture-implementation.md new file mode 100644 index 00000000000..424f5dac65d --- /dev/null +++ b/docs/decisions/0037-rag-architecture-implementation.md @@ -0,0 +1,108 @@ +--- +nav_order: 0037 +parent: Decision Records +--- + +# RAG Architecture Implementation + +## Context and Problem Statement + +The current trend in questions and answering (Q&A) using large language models (LLMs) or other +AI related technology is retrieval-augmented-generation (RAG). + +RAG is related to [Open Generative QA](https://huggingface.co/tasks/question-answering) +that means LLM (which generates text) is supplied with context (chunks of information extracted +from various sources) and then it generates answer. + +RAG architecture consists of [these steps](https://www.linkedin.com/pulse/rag-architecture-deep-dive-frank-denneman-4lple) (simplified): + +How source data is processed: + +1. **Indexing**: application is supplied with information sources (PDFs, text files, web pages, etc.) +2. **Conversion**: files are converted to string (because LLM works on text data). +3. **Splitting**: the string from previous step is split into parts (because LLM has fixed context window, meaning +it cannot handle big documents). +4. **Embedding generation**: a vector consisting of float values is generated out of chunks. This vector represents meaning +of text and the main propety of such vectors is that chunks with similar meaning has vectors that are close to. +Generation of such a vector is achieved by using a separate model called *embedding model*. +5. **Store**: chunks with relevant metadata (for example, from which document they were generated) and embedding vector are stored in a vector database. + +How answer is generated: + +1. **Ask**: user asks AI a question. +2. **Question embedding**: an embedding model generates embedding vector of a query. +3. **Data finding**: vector database performs search of most relevant pieces of information (a finite count of pieces). +That's performed by vector similarity: meaning how close are chunk vector with question vector. +4. **Prompt generation**: using a prompt template the user question is *augmented* with found information. Found information +is not generally supplied to user, as it may seem strange that a user asked a question that was already supplied with +found information. These pieces of text can be either totally ignored or showed separately in UI tab "Sources". +5. **LLM generation**: LLM generates output. + +This ADR concerns about implementation of this architecture. + +## Decision Drivers + +* Prefer good and maintained libraries over self-made solutions for better quality. +* The usage of framework should be easy. It would seem strange when user wants to download a BIB editor, but they are +required to install some separate software (or even Python runtime). +* RAG shouldn't provide any additional money costs. Users should pay only for LLM generation. + +## Considered Options + +* Use a hand-crafted RAG +* Use a third-party Java library +* Use a standalone application +* Use an online service + +## Decision Outcome + +Chosen option: mix of "Use a hand-crafted RAG" and "Use a third-party Java library". + +Third-party libraries provide excellent resources for connecting to an LLM or extracting text from PDF files. For RAG, +we mostly used all the machinery provided by `langchain4j`, but there were moments that should be hand-crafted: + +* **LLM connection**: due to () this was delegated to another library `jvm-openai`. +* **Embedding generation**: due to (), + this was delegated to another library `djl`. +* **Indexing**: `langchain4j` is just a bunch of useful tools, but we still have to orchestrate when indexing should +happen and what files should be processed. +* **Vector database**: there seems to be no embedded vector database (except SQLite with `sqlite-vss` extension). We +implemented vector database using `MVStore` because that was easy. + +## Pros and Cons of the Options + +### Use a hand-crafted RAG + +* Good, because we have the full control over generation +* Good, because extendable +* Bad, because LLM connection, embedding models, vector storage, and file conversion should be implemented manually +* Bad, because it's hard to make a complex RAG architecture + +### Use a third-party Java library + +* Good, because provides well-tested and maintained tools +* Good, because libraries have many LLM integrations, as well as embedding models, vector storage, and file conversion tools +* Good, because they provide complex RAG pipelines and extensions +* Neutral, because they provide many tools and functions, but they should be orchestrated in a real application +* Bad, because some of them are raw and undocumented +* Bad, because they are all similar to `langchain` +* Bad, because they may have bugs + +### Use a standalone application + +* Good, because they provide complex RAG pipelines and extensions +* Good, because no additional code is required (except connecting to API) +* Neutral, because they provide not that many LLM integrations, embedding models, and vector storages +* Bad, because a standalone app running is required. Users may be required to set it up properly +* Bad, because the internal working of app is hidden. Additional agreement to Privacy or Terms of Service is needed +* Bad, because hard to extend + +### Use an online service + +* Good, because all data is processed and stored not on the user's machine: faster and no memory is used. +* Good, because they provide complex RAG pipelines and extensions +* Good, because no additional code is required (except connecting to API) +* Neutral, because they provide not that many LLM integrations, embedding models, and vector storages +* Bad, because requires connection to Internet +* Bad, because data is processed by a third party company +* Bad, because most of them require additional payment (in fact, it would be impossible to develop a free service like that) diff --git a/docs/getting-into-the-code/guidelines-for-setting-up-a-local-workspace/intellij-12-build.md b/docs/getting-into-the-code/guidelines-for-setting-up-a-local-workspace/intellij-12-build.md index a821231b1c7..96680ba6de1 100644 --- a/docs/getting-into-the-code/guidelines-for-setting-up-a-local-workspace/intellij-12-build.md +++ b/docs/getting-into-the-code/guidelines-for-setting-up-a-local-workspace/intellij-12-build.md @@ -65,8 +65,10 @@ Copy following text into your clipboard: ```text --add-exports=javafx.controls/com.sun.javafx.scene.control=org.jabref --add-exports=org.controlsfx.controls/impl.org.controlsfx.skin=org.jabref ---add-reads org.jabref=org.fxmisc.flowless --add-reads org.jabref=org.apache.commons.csv +--add-reads org.jabref=org.fxmisc.flowless +--add-reads org.jabref=langchain4j.core +--add-reads org.jabref=langchain4j.open.ai ``` Then double click inside the cell "Compilation options". diff --git a/src/main/java/module-info.java b/src/main/java/module-info.java index b68acaf55e0..b05c186f5ea 100644 --- a/src/main/java/module-info.java +++ b/src/main/java/module-info.java @@ -19,6 +19,8 @@ requires com.dlsc.gemsfx; uses com.dlsc.gemsfx.TagsField; + // Provides number input fields for parameters in AI expert settings + requires com.dlsc.unitfx; requires com.tobiasdiez.easybind; @@ -140,6 +142,17 @@ requires org.jooq.jool; + // region: AI + requires ai.djl.api; + requires ai.djl.tokenizers; + requires jvm.openai; + requires langchain4j; + requires langchain4j.core; + requires langchain4j.hugging.face; + requires langchain4j.mistral.ai; + requires langchain4j.open.ai; + // endregion + // region: fulltext search requires org.apache.lucene.core; // In case the version is updated, please also adapt SearchFieldConstants#VERSION to the newly used version @@ -163,6 +176,8 @@ // region: other libraries (alphabetically) requires cuid; requires dd.plist; + // required by okhttp and some AI library + requires kotlin.stdlib; requires mslinks; requires org.antlr.antlr4.runtime; requires org.libreoffice.uno; diff --git a/src/main/java/org/jabref/Launcher.java b/src/main/java/org/jabref/Launcher.java index fa891388f98..117c9fd5c1c 100644 --- a/src/main/java/org/jabref/Launcher.java +++ b/src/main/java/org/jabref/Launcher.java @@ -36,6 +36,7 @@ import org.jabref.model.util.FileUpdateMonitor; import org.jabref.preferences.JabRefPreferences; import org.jabref.preferences.PreferencesService; +import org.jabref.preferences.ai.AiApiKeyProvider; import com.airhacks.afterburner.injection.Injector; import org.apache.commons.cli.ParseException; @@ -63,6 +64,7 @@ public static void main(String[] args) { // Initialize preferences final JabRefPreferences preferences = JabRefPreferences.getInstance(); Injector.setModelOrService(PreferencesService.class, preferences); + Injector.setModelOrService(AiApiKeyProvider.class, preferences); // Early exit in case another instance is already running if (!handleMultipleAppInstances(args, preferences.getRemotePreferences())) { diff --git a/src/main/java/org/jabref/gui/Base.css b/src/main/java/org/jabref/gui/Base.css index 662629ec718..e4f8369cede 100644 --- a/src/main/java/org/jabref/gui/Base.css +++ b/src/main/java/org/jabref/gui/Base.css @@ -260,6 +260,14 @@ /* Consistent size for headers of tab-pane and side-panels*/ -jr-header-height: 3em; + /* AI chat style */ + -jr-ai-message-user: -jr-accent; + -jr-ai-message-user-border: -jr-theme; + -jr-ai-message-ai: -jr-accent; + -jr-ai-message-ai-border: -jr-theme; + -jr-ai-message-error: -jr-error; + -jr-ai-message-error-border: derive(-jr-error, -40%); + /* region: maintable base colors **/ -jr-match-1-odd: -jr-row-odd-background; diff --git a/src/main/java/org/jabref/gui/JabRefGUI.java b/src/main/java/org/jabref/gui/JabRefGUI.java index 34d481a5a5c..6eecfb025e3 100644 --- a/src/main/java/org/jabref/gui/JabRefGUI.java +++ b/src/main/java/org/jabref/gui/JabRefGUI.java @@ -26,6 +26,7 @@ import org.jabref.gui.util.TaskExecutor; import org.jabref.gui.util.UiTaskExecutor; import org.jabref.logic.UiCommand; +import org.jabref.logic.ai.AiService; import org.jabref.logic.l10n.Localization; import org.jabref.logic.net.ProxyRegisterer; import org.jabref.logic.remote.RemotePreferences; @@ -39,6 +40,7 @@ import org.jabref.model.util.FileUpdateMonitor; import org.jabref.preferences.GuiPreferences; import org.jabref.preferences.JabRefPreferences; +import org.jabref.preferences.ai.AiApiKeyProvider; import com.airhacks.afterburner.injection.Injector; import com.tobiasdiez.easybind.EasyBind; @@ -57,6 +59,9 @@ public class JabRefGUI extends Application { private static JabRefPreferences preferencesService; private static FileUpdateMonitor fileUpdateMonitor; + // AI Service handles chat messages etc. Therefore, it is tightly coupled to the GUI. + private static AiService aiService; + private static StateManager stateManager; private static ThemeManager themeManager; private static CountingUndoManager countingUndoManager; @@ -90,6 +95,7 @@ public void start(Stage stage) { dialogService, fileUpdateMonitor, preferencesService, + aiService, stateManager, countingUndoManager, Injector.instantiateModelOrService(BibEntryTypesManager.class), @@ -150,6 +156,9 @@ public void initialize() { JabRefGUI.clipBoardManager = new ClipBoardManager(); Injector.setModelOrService(ClipBoardManager.class, clipBoardManager); + + JabRefGUI.aiService = new AiService(preferencesService.getAiPreferences(), Injector.instantiateModelOrService(AiApiKeyProvider.class), dialogService, taskExecutor); + Injector.setModelOrService(AiService.class, aiService); } private void setupProxy() { @@ -214,9 +223,11 @@ private void openWindow() { debugLogWindowState(mainStage); Scene scene = new Scene(JabRefGUI.mainFrame); + + LOGGER.debug("installing CSS"); themeManager.installCss(scene); - // Handle TextEditor key bindings + LOGGER.debug("Handle TextEditor key bindings"); scene.addEventFilter(KeyEvent.KEY_PRESSED, event -> TextInputKeyBindings.call( scene, event, @@ -228,8 +239,12 @@ private void openWindow() { mainStage.setOnShowing(this::onShowing); mainStage.setOnCloseRequest(this::onCloseRequest); mainStage.setOnHiding(this::onHiding); + + LOGGER.debug("Showing mainStage"); mainStage.show(); + LOGGER.debug("frame initialized"); + Platform.runLater(() -> mainFrame.handleUiCommands(uiCommands)); } @@ -337,9 +352,19 @@ public void startBackgroundTasks() { @Override public void stop() { + LOGGER.trace("Closing AI service"); + try { + aiService.close(); + } catch (Exception e) { + LOGGER.error("Unable to close AI service", e); + } + LOGGER.trace("Closing OpenOffice connection"); OOBibBaseConnect.closeOfficeConnection(); + LOGGER.trace("Stopping background tasks"); stopBackgroundTasks(); + LOGGER.trace("Shutting down thread pools"); shutdownThreadPools(); + LOGGER.trace("Finished stop"); } public void stopBackgroundTasks() { @@ -347,10 +372,15 @@ public void stopBackgroundTasks() { } public static void shutdownThreadPools() { + LOGGER.trace("Shutting down taskExecutor"); taskExecutor.shutdown(); + LOGGER.trace("Shutting down fileUpdateMonitor"); fileUpdateMonitor.shutdown(); + LOGGER.trace("Shutting down directoryMonitor"); DirectoryMonitor directoryMonitor = Injector.instantiateModelOrService(DirectoryMonitor.class); directoryMonitor.shutdown(); + LOGGER.trace("Shutting down HeadlessExecutorService"); HeadlessExecutorService.INSTANCE.shutdownEverything(); + LOGGER.trace("Finished shutdownThreadPools"); } } diff --git a/src/main/java/org/jabref/gui/LibraryTab.java b/src/main/java/org/jabref/gui/LibraryTab.java index 76fe7b0395b..2d2089240a6 100644 --- a/src/main/java/org/jabref/gui/LibraryTab.java +++ b/src/main/java/org/jabref/gui/LibraryTab.java @@ -65,6 +65,7 @@ import org.jabref.gui.util.OptionalObjectProperty; import org.jabref.gui.util.TaskExecutor; import org.jabref.gui.util.UiTaskExecutor; +import org.jabref.logic.ai.AiService; import org.jabref.logic.citationstyle.CitationStyleCache; import org.jabref.logic.importer.ParserResult; import org.jabref.logic.importer.util.FileFieldParser; @@ -122,6 +123,7 @@ private enum PanelMode { MAIN_TABLE, MAIN_TABLE_AND_ENTRY_EDITOR } private final CountingUndoManager undoManager; private final DialogService dialogService; private final PreferencesService preferencesService; + private final AiService aiService; private final FileUpdateMonitor fileUpdateMonitor; private final StateManager stateManager; private final BibEntryTypesManager entryTypesManager; @@ -165,6 +167,7 @@ private enum PanelMode { MAIN_TABLE, MAIN_TABLE_AND_ENTRY_EDITOR } private final ClipBoardManager clipBoardManager; private final IndexingTaskManager indexingTaskManager; + private final TaskExecutor taskExecutor; private final DirectoryMonitorManager directoryMonitorManager; @@ -172,6 +175,7 @@ private LibraryTab(BibDatabaseContext bibDatabaseContext, LibraryTabContainer tabContainer, DialogService dialogService, PreferencesService preferencesService, + AiService aiService, StateManager stateManager, FileUpdateMonitor fileUpdateMonitor, BibEntryTypesManager entryTypesManager, @@ -183,6 +187,7 @@ private LibraryTab(BibDatabaseContext bibDatabaseContext, this.undoManager = undoManager; this.dialogService = dialogService; this.preferencesService = Objects.requireNonNull(preferencesService); + this.aiService = Objects.requireNonNull(aiService); this.stateManager = Objects.requireNonNull(stateManager); this.fileUpdateMonitor = fileUpdateMonitor; this.entryTypesManager = entryTypesManager; @@ -1002,6 +1007,7 @@ public static LibraryTab createLibraryTab(BackgroundTask dataLoadi Path file, DialogService dialogService, PreferencesService preferencesService, + AiService aiService, StateManager stateManager, LibraryTabContainer tabContainer, FileUpdateMonitor fileUpdateMonitor, @@ -1017,6 +1023,7 @@ public static LibraryTab createLibraryTab(BackgroundTask dataLoadi tabContainer, dialogService, preferencesService, + aiService, stateManager, fileUpdateMonitor, entryTypesManager, @@ -1037,6 +1044,7 @@ public static LibraryTab createLibraryTab(BibDatabaseContext databaseContext, LibraryTabContainer tabContainer, DialogService dialogService, PreferencesService preferencesService, + AiService aiService, StateManager stateManager, FileUpdateMonitor fileUpdateMonitor, BibEntryTypesManager entryTypesManager, @@ -1050,6 +1058,7 @@ public static LibraryTab createLibraryTab(BibDatabaseContext databaseContext, tabContainer, dialogService, preferencesService, + aiService, stateManager, fileUpdateMonitor, entryTypesManager, @@ -1168,4 +1177,8 @@ public String toString() { ", showing=" + showing + '}'; } + + public LibraryTabContainer getLibraryTabContainer() { + return tabContainer; + } } diff --git a/src/main/java/org/jabref/gui/actions/StandardActions.java b/src/main/java/org/jabref/gui/actions/StandardActions.java index 1ec41a584f8..f2a5f260e41 100644 --- a/src/main/java/org/jabref/gui/actions/StandardActions.java +++ b/src/main/java/org/jabref/gui/actions/StandardActions.java @@ -201,7 +201,9 @@ public enum StandardActions implements Action { GROUP_SUBGROUP_SORT_ENTRIES(Localization.lang("Sort subgroups by # of entries (Descending)")), GROUP_SUBGROUP_SORT_ENTRIES_REVERSE(Localization.lang("Sort subgroups by # of entries (Ascending)")), GROUP_ENTRIES_ADD(Localization.lang("Add selected entries to this group")), - GROUP_ENTRIES_REMOVE(Localization.lang("Remove selected entries from this group")); + GROUP_ENTRIES_REMOVE(Localization.lang("Remove selected entries from this group")), + + CLEAR_EMBEDDINGS_CACHE(Localization.lang("Clear embeddings cache")); private String text; private final String description; diff --git a/src/main/java/org/jabref/gui/ai/ClearEmbeddingsAction.java b/src/main/java/org/jabref/gui/ai/ClearEmbeddingsAction.java new file mode 100644 index 00000000000..2bb569fc587 --- /dev/null +++ b/src/main/java/org/jabref/gui/ai/ClearEmbeddingsAction.java @@ -0,0 +1,61 @@ +package org.jabref.gui.ai; + +import java.util.List; + +import org.jabref.gui.DialogService; +import org.jabref.gui.StateManager; +import org.jabref.gui.actions.SimpleCommand; +import org.jabref.gui.util.BackgroundTask; +import org.jabref.gui.util.TaskExecutor; +import org.jabref.logic.ai.AiService; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.entry.LinkedFile; + +import static org.jabref.gui.actions.ActionHelper.needsDatabase; + +public class ClearEmbeddingsAction extends SimpleCommand { + private final StateManager stateManager; + private final DialogService dialogService; + private final AiService aiService; + private final TaskExecutor taskExecutor; + + public ClearEmbeddingsAction(StateManager stateManager, + DialogService dialogService, + AiService aiService, + TaskExecutor taskExecutor) { + this.stateManager = stateManager; + this.dialogService = dialogService; + this.taskExecutor = taskExecutor; + this.aiService = aiService; + this.executable.bind(needsDatabase(stateManager)); + } + + @Override + public void execute() { + if (stateManager.getActiveDatabase().isEmpty()) { + return; + } + + boolean confirmed = dialogService.showConfirmationDialogAndWait( + Localization.lang("Clear embeddings cache"), + Localization.lang("Clear embeddings cache for current library?")); + + if (!confirmed) { + return; + } + + dialogService.notify(Localization.lang("Clearing embeddings cache...")); + + List linkedFile = stateManager + .getActiveDatabase() + .get() + .getDatabase() + .getEntries() + .stream() + .flatMap(entry -> entry.getFiles().stream()) + .toList(); + + BackgroundTask.wrap(() -> aiService.getEmbeddingsManager().clearEmbeddingsFor(linkedFile)) + .executeWith(taskExecutor); + } +} diff --git a/src/main/java/org/jabref/gui/ai/components/aichat/AiChatComponent.fxml b/src/main/java/org/jabref/gui/ai/components/aichat/AiChatComponent.fxml new file mode 100644 index 00000000000..9e962a9d657 --- /dev/null +++ b/src/main/java/org/jabref/gui/ai/components/aichat/AiChatComponent.fxml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/org/jabref/gui/ai/components/aichat/AiChatComponent.java b/src/main/java/org/jabref/gui/ai/components/aichat/AiChatComponent.java new file mode 100644 index 00000000000..bd022ae15b0 --- /dev/null +++ b/src/main/java/org/jabref/gui/ai/components/aichat/AiChatComponent.java @@ -0,0 +1,222 @@ +package org.jabref.gui.ai.components.aichat; + +import javafx.application.Platform; +import javafx.beans.property.IntegerProperty; +import javafx.beans.property.SimpleIntegerProperty; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.ScrollPane; +import javafx.scene.input.KeyCode; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; + +import org.jabref.gui.DialogService; +import org.jabref.gui.ai.components.chatmessage.ChatMessageComponent; +import org.jabref.gui.util.BackgroundTask; +import org.jabref.gui.util.TaskExecutor; +import org.jabref.logic.ai.AiChatLogic; +import org.jabref.logic.ai.misc.ErrorMessage; +import org.jabref.logic.l10n.Localization; +import org.jabref.preferences.ai.AiPreferences; + +import com.airhacks.afterburner.views.ViewLoader; +import com.dlsc.gemsfx.ExpandingTextArea; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.UserMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AiChatComponent extends VBox { + private static final Logger LOGGER = LoggerFactory.getLogger(AiChatComponent.class); + + private final AiPreferences aiPreferences; + private final AiChatLogic aiChatLogic; + private final String citationKey; + private final DialogService dialogService; + private final TaskExecutor taskExecutor; + + private final IntegerProperty blockScroll = new SimpleIntegerProperty(0); + + @FXML private ScrollPane scrollPane; + @FXML private VBox chatVBox; + @FXML private HBox promptHBox; + @FXML private ExpandingTextArea userPromptTextArea; + @FXML private Button submitButton; + @FXML private StackPane stackPane; + @FXML private Label noticeText; + + public AiChatComponent(AiPreferences aiPreferences, AiChatLogic aiChatLogic, String citationKey, DialogService dialogService, TaskExecutor taskExecutor) { + this.aiPreferences = aiPreferences; + this.aiChatLogic = aiChatLogic; + this.citationKey = citationKey; + this.dialogService = dialogService; + this.taskExecutor = taskExecutor; + + ViewLoader.view(this) + .root(this) + .load(); + } + + @FXML + public void initialize() { + userPromptTextArea.setOnKeyPressed(keyEvent -> { + if (keyEvent.getCode() == KeyCode.ENTER) { + if (keyEvent.isControlDown()) { + userPromptTextArea.appendText("\n"); + } else { + onSendMessage(); + } + } + }); + + scrollPane.needsLayoutProperty().addListener((observable, oldValue, newValue) -> { + if (!newValue) { + if (blockScroll.get() == 0) { + scrollPane.setVvalue(1.0); + } else { + blockScroll.set(blockScroll.get() - 1); + } + } + }); + + chatVBox + .getChildren() + .addAll(aiChatLogic.getChatHistory() + .getMessages() + .stream() + .map(message -> new ChatMessageComponent(message, this::deleteMessage)) + .toList() + ); + + String newNotice = noticeText + .getText() + .replaceAll("%0", aiPreferences.getAiProvider().getLabel() + " " + aiPreferences.getSelectedChatModel()); + + noticeText.setText(newNotice); + + Platform.runLater(() -> userPromptTextArea.requestFocus()); + } + + @FXML + private void onSendMessage() { + String userPrompt = userPromptTextArea.getText(); + + if (!userPrompt.isEmpty()) { + userPromptTextArea.clear(); + + UserMessage userMessage = new UserMessage(userPrompt); + addMessage(userMessage); + setLoading(true); + + BackgroundTask task = + BackgroundTask + .wrap(() -> aiChatLogic.execute(userMessage)) + .showToUser(true) + .onSuccess(aiMessage -> { + setLoading(false); + addMessage(aiMessage); + requestUserPromptTextFieldFocus(); + }) + .onFailure(e -> { + LOGGER.error("Got an error while sending a message to AI", e); + setLoading(false); + + if (e.getMessage().equals("401 - null") || e.getMessage().equals("404 - null")) { + addError(Localization.lang("API base URL setting appears to be incorrect. Please check it in AI expert settings.")); + } else { + addError(e.getMessage()); + } + + switchToErrorState(userPrompt); + }); + + task.titleProperty().set(Localization.lang("Waiting for AI reply for %0...", citationKey)); + + task.executeWith(taskExecutor); + } + } + + private void switchToErrorState(String userMessage) { + promptHBox.getChildren().clear(); + + Button retryButton = new Button(Localization.lang("Retry")); + + retryButton.setOnAction(event -> { + userPromptTextArea.setText(userMessage); + + chatVBox.getChildren().removeLast(); + chatVBox.getChildren().removeLast(); + + switchToNormalState(); + + onSendMessage(); + }); + + Button cancelButton = new Button(Localization.lang("Cancel")); + + cancelButton.setOnAction(event -> { + switchToNormalState(); + }); + + promptHBox.getChildren().add(retryButton); + promptHBox.getChildren().add(cancelButton); + } + + private void switchToNormalState() { + promptHBox.getChildren().clear(); + promptHBox.getChildren().add(userPromptTextArea); + promptHBox.getChildren().add(submitButton); + } + + private void setLoading(boolean loading) { + userPromptTextArea.setDisable(loading); + submitButton.setDisable(loading); + + if (loading) { + stackPane.getChildren().add(new BorderPane(new ProgressIndicator())); + } else { + stackPane.getChildren().clear(); + stackPane.getChildren().add(scrollPane); + } + } + + private void addMessage(ChatMessage chatMessage) { + ChatMessageComponent component = new ChatMessageComponent(chatMessage, this::deleteMessage); + // chatMessage will be added to chat history in {@link AiChatLogic}. + chatVBox.getChildren().add(component); + } + + private void addError(String message) { + ErrorMessage errorMessage = new ErrorMessage(message); + + addMessage(errorMessage); + aiChatLogic.getChatHistory().add(errorMessage); + } + + private void deleteMessage(ChatMessageComponent chatMessageComponent) { + blockScroll.set(2); + + aiChatLogic.getChatHistory().remove(chatMessageComponent.getChatMessage()); + + chatVBox.getChildren().remove(chatMessageComponent); + } + + private void requestUserPromptTextFieldFocus() { + userPromptTextArea.requestFocus(); + } + + @FXML + private void onClearChatHistory() { + boolean agreed = dialogService.showConfirmationDialogAndWait(Localization.lang("Clear chat history"), Localization.lang("Are you sure you want to clear the chat history of this entry?")); + + if (agreed) { + chatVBox.getChildren().clear(); + aiChatLogic.getChatHistory().clear(); + } + } +} diff --git a/src/main/java/org/jabref/gui/ai/components/apikeymissing/ApiKeyMissingComponent.fxml b/src/main/java/org/jabref/gui/ai/components/apikeymissing/ApiKeyMissingComponent.fxml new file mode 100644 index 00000000000..dd86df8612c --- /dev/null +++ b/src/main/java/org/jabref/gui/ai/components/apikeymissing/ApiKeyMissingComponent.fxml @@ -0,0 +1,25 @@ + + + + + + + + +
+ + + + + + + + +
+
diff --git a/src/main/java/org/jabref/gui/ai/components/apikeymissing/ApiKeyMissingComponent.java b/src/main/java/org/jabref/gui/ai/components/apikeymissing/ApiKeyMissingComponent.java new file mode 100644 index 00000000000..3b41ed1fd20 --- /dev/null +++ b/src/main/java/org/jabref/gui/ai/components/apikeymissing/ApiKeyMissingComponent.java @@ -0,0 +1,30 @@ +package org.jabref.gui.ai.components.apikeymissing; + +import javafx.fxml.FXML; +import javafx.scene.layout.BorderPane; + +import org.jabref.gui.DialogService; +import org.jabref.gui.LibraryTabContainer; +import org.jabref.gui.preferences.ShowPreferencesAction; +import org.jabref.gui.preferences.ai.AiTab; + +import com.airhacks.afterburner.views.ViewLoader; + +public class ApiKeyMissingComponent extends BorderPane { + private final LibraryTabContainer libraryTabContainer; + private final DialogService dialogService; + + public ApiKeyMissingComponent(LibraryTabContainer libraryTabContainer, DialogService dialogService) { + this.libraryTabContainer = libraryTabContainer; + this.dialogService = dialogService; + + ViewLoader.view(this) + .root(this) + .load(); + } + + @FXML + private void onHyperlinkClick() { + new ShowPreferencesAction(libraryTabContainer, AiTab.class, dialogService).execute(); + } +} diff --git a/src/main/java/org/jabref/gui/ai/components/chatmessage/ChatMessageComponent.fxml b/src/main/java/org/jabref/gui/ai/components/chatmessage/ChatMessageComponent.fxml new file mode 100644 index 00000000000..9f4eebca2f2 --- /dev/null +++ b/src/main/java/org/jabref/gui/ai/components/chatmessage/ChatMessageComponent.fxml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/org/jabref/gui/ai/components/chatmessage/ChatMessageComponent.java b/src/main/java/org/jabref/gui/ai/components/chatmessage/ChatMessageComponent.java new file mode 100644 index 00000000000..60c0c4e8c2d --- /dev/null +++ b/src/main/java/org/jabref/gui/ai/components/chatmessage/ChatMessageComponent.java @@ -0,0 +1,86 @@ +package org.jabref.gui.ai.components.chatmessage; + +import java.util.function.Consumer; + +import javafx.fxml.FXML; +import javafx.geometry.NodeOrientation; +import javafx.scene.control.Label; +import javafx.scene.layout.HBox; +import javafx.scene.layout.VBox; + +import org.jabref.logic.ai.misc.ErrorMessage; +import org.jabref.logic.l10n.Localization; + +import com.airhacks.afterburner.views.ViewLoader; +import com.dlsc.gemsfx.ExpandingTextArea; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.UserMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ChatMessageComponent extends HBox { + private static final Logger LOGGER = LoggerFactory.getLogger(ChatMessageComponent.class); + + private final ChatMessage chatMessage; + private final Consumer onDeleteCallback; + + @FXML private VBox wrapperVBox; + @FXML private VBox vBox; + @FXML private Label sourceLabel; + @FXML private ExpandingTextArea contentTextArea; + @FXML private HBox buttonsHBox; + + public ChatMessageComponent(ChatMessage chatMessage, Consumer onDeleteCallback) { + this.chatMessage = chatMessage; + this.onDeleteCallback = onDeleteCallback; + + ViewLoader.view(this) + .root(this) + .load(); + } + + public ChatMessage getChatMessage() { + return chatMessage; + } + + @FXML + private void initialize() { + switch (chatMessage) { + case UserMessage userMessage -> { + setColor("-jr-ai-message-user", "-jr-ai-message-user-border"); + setNodeOrientation(NodeOrientation.RIGHT_TO_LEFT); + sourceLabel.setText(Localization.lang("User")); + contentTextArea.setText(userMessage.singleText()); + } + + case AiMessage aiMessage -> { + setColor("-jr-ai-message-ai", "-jr-ai-message-ai-border"); + setNodeOrientation(NodeOrientation.LEFT_TO_RIGHT); + sourceLabel.setText(Localization.lang("AI")); + contentTextArea.setText(aiMessage.text()); + } + + case ErrorMessage errorMessage -> { + setColor("-jr-ai-message-error", "-jr-ai-message-error-border"); + setNodeOrientation(NodeOrientation.LEFT_TO_RIGHT); + sourceLabel.setText(Localization.lang("Error")); + contentTextArea.setText(errorMessage.getText()); + } + + default -> + LOGGER.error("ChatMessageComponent supports only user, AI, or error messages, but other type was passed: {}", chatMessage.type().name()); + } + + buttonsHBox.visibleProperty().bind(wrapperVBox.hoverProperty()); + } + + @FXML + private void onDeleteClick() { + onDeleteCallback.accept(this); + } + + private void setColor(String fillColor, String borderColor) { + vBox.setStyle("-fx-background-color: " + fillColor + "; -fx-border-radius: 10; -fx-background-radius: 10; -fx-border-color: " + borderColor + "; -fx-border-width: 3;"); + } +} diff --git a/src/main/java/org/jabref/gui/ai/components/errorstate/ErrorStateComponent.fxml b/src/main/java/org/jabref/gui/ai/components/errorstate/ErrorStateComponent.fxml new file mode 100644 index 00000000000..6c51946a2bd --- /dev/null +++ b/src/main/java/org/jabref/gui/ai/components/errorstate/ErrorStateComponent.fxml @@ -0,0 +1,28 @@ + + + + + + + + +
+ + + + + + + + + +
+
diff --git a/src/main/java/org/jabref/gui/ai/components/errorstate/ErrorStateComponent.java b/src/main/java/org/jabref/gui/ai/components/errorstate/ErrorStateComponent.java new file mode 100644 index 00000000000..ca58b1f6943 --- /dev/null +++ b/src/main/java/org/jabref/gui/ai/components/errorstate/ErrorStateComponent.java @@ -0,0 +1,73 @@ +package org.jabref.gui.ai.components.errorstate; + +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.control.TextArea; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.VBox; + +import com.airhacks.afterburner.views.ViewLoader; + +public class ErrorStateComponent extends BorderPane { + @FXML private Label titleText; + @FXML private Label contentText; + @FXML private VBox contentsVBox; + + public ErrorStateComponent(String title, String content) { + ViewLoader.view(this) + .root(this) + .load(); + + setTitle(title); + setContent(content); + } + + public static ErrorStateComponent withSpinner(String title, String content) { + ErrorStateComponent errorStateComponent = new ErrorStateComponent(title, content); + + errorStateComponent.contentsVBox.getChildren().add(new ProgressIndicator()); + + return errorStateComponent; + } + + public static ErrorStateComponent withTextArea(String title, String content, String textAreaContent) { + ErrorStateComponent errorStateComponent = new ErrorStateComponent(title, content); + + TextArea textArea = new TextArea(textAreaContent); + textArea.setEditable(false); + textArea.setWrapText(true); + + errorStateComponent.contentsVBox.getChildren().add(textArea); + + return errorStateComponent; + } + + public static ErrorStateComponent withTextAreaAndButton(String title, String content, String textAreaContent, String buttonText, Runnable onClick) { + ErrorStateComponent errorStateComponent = ErrorStateComponent.withTextArea(title, content, textAreaContent); + + Button button = new Button(buttonText); + button.setOnAction(e -> onClick.run()); + + errorStateComponent.contentsVBox.getChildren().add(button); + + return errorStateComponent; + } + + public String getTitle() { + return titleText.getText(); + } + + public void setTitle(String title) { + titleText.setText(title); + } + + public String getContent() { + return contentText.getText(); + } + + public void setContent(String content) { + contentText.setText(content); + } +} diff --git a/src/main/java/org/jabref/gui/ai/components/privacynotice/PrivacyNoticeComponent.fxml b/src/main/java/org/jabref/gui/ai/components/privacynotice/PrivacyNoticeComponent.fxml new file mode 100644 index 00000000000..91984a61731 --- /dev/null +++ b/src/main/java/org/jabref/gui/ai/components/privacynotice/PrivacyNoticeComponent.fxml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/org/jabref/gui/ai/components/privacynotice/PrivacyNoticeComponent.java b/src/main/java/org/jabref/gui/ai/components/privacynotice/PrivacyNoticeComponent.java new file mode 100644 index 00000000000..c76e2beb614 --- /dev/null +++ b/src/main/java/org/jabref/gui/ai/components/privacynotice/PrivacyNoticeComponent.java @@ -0,0 +1,115 @@ +package org.jabref.gui.ai.components.privacynotice; + +import java.io.IOException; + +import javafx.fxml.FXML; +import javafx.scene.control.Hyperlink; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.text.Text; +import javafx.scene.text.TextFlow; + +import org.jabref.gui.DialogService; +import org.jabref.gui.desktop.JabRefDesktop; +import org.jabref.preferences.FilePreferences; +import org.jabref.preferences.ai.AiPreferences; + +import com.airhacks.afterburner.views.ViewLoader; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class PrivacyNoticeComponent extends ScrollPane { + private final Logger LOGGER = LoggerFactory.getLogger(PrivacyNoticeComponent.class); + + @FXML private TextFlow openAiPrivacyTextFlow; + @FXML private TextFlow mistralAiPrivacyTextFlow; + @FXML private TextFlow huggingFacePrivacyTextFlow; + @FXML private Label text1; + @FXML private Label text2; + @FXML private Label text3; + @FXML private Text embeddingModelText; + + private final DialogService dialogService; + + private final AiPreferences aiPreferences; + private final FilePreferences filePreferences; + + private final Runnable onIAgreeButtonClickCallback; + + public PrivacyNoticeComponent(DialogService dialogService, AiPreferences aiPreferences, FilePreferences filePreferences, Runnable onIAgreeButtonClickCallback) { + this.dialogService = dialogService; + this.aiPreferences = aiPreferences; + this.filePreferences = filePreferences; + + this.onIAgreeButtonClickCallback = onIAgreeButtonClickCallback; + + ViewLoader.view(this) + .root(this) + .load(); + } + + @FXML + private void initialize() { + initPrivacyHyperlink(openAiPrivacyTextFlow, "https://openai.com/policies/privacy-policy/"); + initPrivacyHyperlink(mistralAiPrivacyTextFlow, "https://mistral.ai/terms/#privacy-policy"); + initPrivacyHyperlink(huggingFacePrivacyTextFlow, "https://huggingface.co/privacy"); + + String newEmbeddingModelText = embeddingModelText.getText().replaceAll("%0", aiPreferences.getEmbeddingModel().sizeInfo()); + embeddingModelText.setText(newEmbeddingModelText); + + // Because of the https://bugs.openjdk.org/browse/JDK-8090400 bug, the text in the privacy policy cannot be + // fully wrapped. + + embeddingModelText.wrappingWidthProperty().bind(this.widthProperty()); + } + + private void initPrivacyHyperlink(TextFlow textFlow, String link) { + if (textFlow.getChildren().isEmpty() || !(textFlow.getChildren().getFirst() instanceof Text text)) { + return; + } + + String[] stringArray = text.getText().split("%0"); + + if (stringArray.length != 2) { + return; + } + + text.wrappingWidthProperty().bind(this.widthProperty()); + text.setText(stringArray[0]); + + Hyperlink hyperlink = new Hyperlink(link); + hyperlink.setWrapText(true); + hyperlink.setFont(text.getFont()); + hyperlink.setOnAction(event -> { + openBrowser(link); + }); + + textFlow.getChildren().add(hyperlink); + + Text postText = new Text(stringArray[1]); + postText.setFont(text.getFont()); + postText.wrappingWidthProperty().bind(this.widthProperty()); + + textFlow.getChildren().add(postText); + } + + @FXML + private void onIAgreeButtonClick() { + aiPreferences.setEnableAi(true); + onIAgreeButtonClickCallback.run(); + } + + @FXML + private void onDjlPrivacyPolicyClick() { + openBrowser("https://github.com/deepjavalibrary/djl/discussions/3370#discussioncomment-10233632"); + } + + private void openBrowser(String link) { + try { + JabRefDesktop.openBrowser(link, filePreferences); + } catch (IOException e) { + LOGGER.error("Error opening the browser to the Privacy Policy page of the AI provider.", e); + dialogService.showErrorDialogAndWait(e); + } + } +} diff --git a/src/main/java/org/jabref/gui/ai/components/summary/SummaryComponent.fxml b/src/main/java/org/jabref/gui/ai/components/summary/SummaryComponent.fxml new file mode 100644 index 00000000000..91d4f40b42f --- /dev/null +++ b/src/main/java/org/jabref/gui/ai/components/summary/SummaryComponent.fxml @@ -0,0 +1,21 @@ + + + + + + + + + +