diff --git a/.idea/checkstyle-idea.xml b/.idea/checkstyle-idea.xml index 7d65211ab7dd..d2b5c9534dd2 100644 --- a/.idea/checkstyle-idea.xml +++ b/.idea/checkstyle-idea.xml @@ -1,21 +1,21 @@ - 13.0.0 - JavaOnlyWithTests + 13.4.0 + JavaOnly + true + \ No newline at end of file diff --git a/.vscode/ltex.dictionary.en-US.txt b/.vscode/ltex.dictionary.en-US.txt index 4f598b0ebef6..5dc9f1b7a27d 100644 --- a/.vscode/ltex.dictionary.en-US.txt +++ b/.vscode/ltex.dictionary.en-US.txt @@ -9,3 +9,11 @@ OpenFastTrace OpenRewrite Temurin jpackage +impl +utest +dsn +summarizator +HuggingFace +vLLM +Ollama +de-facto diff --git a/CHANGELOG.md b/CHANGELOG.md index a13c2a8f2872..156a2ccf352c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Added +- Added support for selecting answer engines and summarization algorithms, allowing users to change the underlying AI behavior. - We fixed a glitch with the sidepane divider position on startup. [#15394](https://github.com/JabRef/jabref/issues/15394) - We added a label to the Group dropdown in the Import Dialog. [#15567](https://github.com/JabRef/jabref/issues/15567) - We added a related work text extractor, which finds and inserts the related work text into bib entries from references in the texts. [#9840](https://github.com/JabRef/jabref/issues/9840) @@ -34,6 +35,7 @@ Note that this project **does not** adhere to [Semantic Versioning](https://semv ### Changed +- We refactored and enhanced the code of AI features. - We replaced deprecated Gemini Models from the AI chat model selection and with current ones. [#15398](https://github.com/JabRef/jabref/issues/15398) - We changed CSL reference format by adding citation type at the end. [#15370](https://github.com/JabRef/jabref/issues/15370) [#15434](https://github.com/JabRef/jabref/issues/15434) - We changed the groups filter field to use a filter icon. [#15402](https://github.com/JabRef/jabref/issues/15402) diff --git a/build.gradle.kts b/build.gradle.kts index efccace7a8bb..79c6f0bc15c5 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -51,6 +51,18 @@ requirementTracing { "jabsrv/src/test/java" ) ) + + filteredArtifactTypes = + listOf( + "impl", + "utest", + "model", + "guard", + "pp", + "feat", + "req" + ) + // TODO: Short Tag Importer: https://github.com/itsallcode/openfasttrace-gradle#configuring-the-short-tag-importer } diff --git a/docs/code-howtos/ai.md b/docs/code-howtos/ai.md index aa4f9c45e1f3..40c32882546c 100644 --- a/docs/code-howtos/ai.md +++ b/docs/code-howtos/ai.md @@ -4,29 +4,101 @@ parent: Code Howtos # AI -The AI feature of JabRef is built on [LangChain4j](https://github.com/langchain4j/langchain4j) and [Deep Java Library](https://djl.ai/). +The JabRef has next AI features: + +- Chatting with entries, +- Chatting with groups, +- Summarization of entries, +- Parsing of plain citations using LLMs +- Extracting "References" section from PDFs with the help of LLMs. + +The features are built on [LangChain4j](https://github.com/langchain4j/langchain4j) and [Deep Java Library](https://djl.ai/). ## Architectural Decisions See [ADR-0037](../decisions/0037-rag-architecture-implementation.md) for the decision regarding the RAG infrastructure. -## Feature "Chat with PDF(s)" +The [ADR-0032](../decisions/0032-store-chats-in-local-user-folder.md) and [ADR-0033](../decisions/0033-store-chats-in-mvstore.md) are important ones, because they explain the decisions regarding the storage of AI artifacts (summaries, chat histories, embeddings, etc.). + +## Requirements + +See [the requirements page of AI features](../requirements/ai.md). + +## Features + +### Feature "Chat with PDF(s)" + +The interface with all of the features (chat history, regeneration, follow up questions, etc.) is implemented in the class [org.jabref.gui.ai.chat.AiChatView]. From there, one will find preferences and other required infrastructure. -This is implemented mainly in the class [org.jabref.logic.ai.chatting.AiChatLogic]. -From there, one will find preferences and other required infrastructure. +The RAG entry point is located in [org.jabref.logic.ai.chatting.tasks.GenerateRagResponseTask]. -## Feature "Summarize PDF(s)" +### Feature "Summarize PDF(s)" -This is implemented in the class [org.jabref.logic.ai.summarization.GenerateSummaryTask]. +This is implemented in the class [org.jabref.logic.ai.summarization.tasks.GenerateSummaryTask]. -## Feature "BibTeX from Reference Text" +### Feature "BibTeX from Reference Text" The general interface is [org.jabref.logic.importer.plaincitation.PlainCitationParser]. The class implementing it using AI is [org.jabref.logic.importer.plaincitation.LlmPlainCitationParser]. -## Feature "Reference Extractor" +### Feature "Reference Extractor" Extracts the list of references (Section ["References"](../glossary/references.md)) from the last page of the PDF to a List of BibEntry. The general interface is [org.jabref.logic.importer.fileformat.pdf.BibliographyFromPdfImporter]. The class implementing it using AI is [org.jabref.logic.importer.plaincitation.LlmPlainCitationParser]. + +## Code organization + +As every JabRef feature, AI is divided into 3 layers: GUI, logic, and model. Inside the `logic` package the AI code is split by feature (each feature has its own package). + +The GUI code strongly follows [MVVM pattern](./javafx.md). Though, the GUI code is a bit complicated as: + +1. Most of the core GUI components (chat and summary components) are designed as a state machine. Typical states include: loading, presenting the result, error, etc. +2. These core GUI components are also made that way so it would be possible to rebind them to another `BibEntry`. For the details, take a look at the section [How to add a new AI feature](## How to add a new AI feature). + +## Internal model (v2) + +There are 3 core models in the AI features: + +1. Chat history. +2. Summaries. +3. Embeddings. +4. Fully ingested documents. + +The code strictly follows the repository pattern, where an interface is created to access the internal storage for the purpose of abstraction. At the moment of writing, all of these models are implemented by using the [`MVStore`](https://www.h2database.com/html/mvstore.html). For the details of this decisions take a look at the [ADR 0033](./../0033-store-chats-in-mvstore.md). A helper class was made `MVStoreBase` so that it would be possible to use an in-memory `MVStore` in case there are some errors while opening on-disk storage. + +A note needs to be made for embeddings: the embeddings storage is also implementing the internal LangChain4j interface for embeddings so that it could be used in LangChain4j algorithms. Additionally, there is a "fully ingested" repository, which simply contains a "list" of files that were fully ingested. This helps with checking if a file needs to be ingested or not, as there is no 1 to 1 correspondense with embeddings to file (which is many to one). + +Because JabRef is not build around one global database, but rather it is a `.bib` file editor, a problem of identifying a `BibEntry` arose and it was solved in a somewhat complicated way: + +- In order to uniquely identify a library, an "AI library ID" was introduced (as a metadata field), which is just a UUID. An alternative would be to use the library path, but if the library moves, the path changes, but AI library ID is not. +- In order to uniquely identify an entry, the citation key is used, but only if it is non-empty and unique. +- In some cases (that arise potentially often), the conditions above are not met (for example, a library is not saved - it does not have a path, or an entry does not have a citation key), however user is actively working on an entry. In this case the AI features have an *in-memory cache layer*. So whenever a chat or a summary is created for an entry, it is firstly interacted with the in-memory storage layer. The cache is flushed to the on-disk storage at the close of the JabRef. +- In order to uniquely identify a file, we use the file hash. An alternative would be to use the file path, but the file could be moved, or defined by a relative path. This is also useful when several libraries cite the same paper, and instead of ingesting + +## [OLD] Internal model (v1) + +The model v1 differs from v2 by: + +1. Fields of the chat messages and summaries were differently organized in the `MVStore`. +2. A `LinkedFile#getLink()` was used to identify a file. + +To migrate from v1 to v2, the classes `ChatHistoryMigrationV1` and `SummariesMigrationV2` were made. + +## How to add a new AI feature + +This section describes the standard pattern used for AI features. If should follow a similar plan: + +1. Define the model of the artifact of your feature (for example, for summarization it is an AI summary, for chatting they are chat messages and chat history). +2. Define a repository interface (e.g. `SummaryRepository`, `ChatHistoryRepository`) and implement an `MVStore` implementation using the [org.jabref.logic.ai.util.MVStoreBase]. +3. Define a logic class in the `logic` package: either a task (e.g. `GenerateSummaryTask` or a utility class for performing an AI feature. It is recommended to make it "without side-effects" (it does not change or write anything in the system). Firstly, this will help in testing the class, and, secondly, the storage is typically hanlded in *in-memory cache* layer, that will be discussed next. +4. Make an in-memory cache storage layer for your feature that has a RAM map between a `BibEntry` (or a group, or some other object that your artifact is linked to) and your model. Sometimes this can be omitted (for example, embeddings do not have the in-memory cache and always use a repository), but generally it is made in order to always have access to the AI feature even if some precondition is not satisfied (for example, storing chat history and summmaries requires that there is a database path and a non-empty unique citation key, but in-memory layer allows to work with them as is). At the close of JabRef (or a library) the in-memory cache layer will check the preconditions and only then write the data to the repository. +5. Make a `TaskAggregator` class. This is needed in order to be able to switch a component between entries and to deduplicate the tasks. So whenever you want to generate the artifact of your feature, you need to always communicate to the `TaskAggregator` class which will either create a new task or give you an already running one. The `TaskAggregator` also connects the results to the in-memory cache. + +The next points are targeted to the GUI of the feature: + +1. Design a component using the MVVM pattern. You need to write the interface in the FXML, then write a controller `AiView` and a view-model `AiViewModel`. +2. A typical AI component will be a state machine: first and foremost, check if the AI features are enabled in JabRef (which equals to accepting a privacy policy of AI features). If not, then you must ensure that you component does nothing. To show the privacy policy banner, there is a dedicated component [org.jabref.gui.ai.AiPrivacyNoticeView]. The next states typically envolve checking some preconditions (for example, you can not summarize an entry, if it does not have linked files), and the final is the working state. You might find the [org.jabref.gui.util.BindingsHelper#bindEnum] useful. +3. The entry editor tabs are designed to be switchable (rebound to some other `BibEntry`), so you can have an `entryProperty` and whenver it is changed, the state machine of the component is rerun. +4. When you read an artifact for an entry (or a group, or other entity that is linked to your AI feature), the look-up should be made in 3 steps: look into the repository, look in to the in-memory cache, and only then contact the `TaskAggregator` to start a new generation task. diff --git a/docs/decisions/0033-store-chats-in-mvstore.md b/docs/decisions/0033-store-chats-in-mvstore.md index 49e6c2e3fc94..2e938ac0901f 100644 --- a/docs/decisions/0033-store-chats-in-mvstore.md +++ b/docs/decisions/0033-store-chats-in-mvstore.md @@ -4,6 +4,8 @@ parent: Decision Records --- # Store Chats in MVStore + + ## Context and Problem Statement @@ -51,3 +53,7 @@ Chosen option: "MVStore", because it is simple and memory-efficient. * 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) + +## More information + +For the same logic, the summaries are stored in MVStore. \ No newline at end of file diff --git a/docs/decisions/0036-use-textarea-for-chat-content.md b/docs/decisions/0036-use-markdown-for-chat-content.md similarity index 89% rename from docs/decisions/0036-use-textarea-for-chat-content.md rename to docs/decisions/0036-use-markdown-for-chat-content.md index f54b7fe56f88..a3fdeec8f79a 100644 --- a/docs/decisions/0036-use-textarea-for-chat-content.md +++ b/docs/decisions/0036-use-markdown-for-chat-content.md @@ -3,7 +3,7 @@ nav_order: 0036 parent: Decision Records --- -# Use `TextArea` for Chat Message Content +# Use Markdown rendering for Chat Message Content ## Context and Problem Statement @@ -25,10 +25,8 @@ This decision record concerns the UI component that is used for rendering the co ## 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. +Chosen option: (modified) "Use a Markdown parser and convert AST nodes to JavaFX TextFlow elements". +In JabRef there is a component `SelectableTextFlow` which allows to create a formatted text and to select it. This makes possible to use a Markdown parser that converts the content into JavaFX nodes and adds the feature selecting the text. ## Pros and Cons of the Options diff --git a/docs/decisions/0056-embedding-implementation-approach.md b/docs/decisions/0056-embedding-implementation-approach.md new file mode 100644 index 000000000000..51ea139f2453 --- /dev/null +++ b/docs/decisions/0056-embedding-implementation-approach.md @@ -0,0 +1,77 @@ +--- +nav_order: 0056 +parent: Decision Records +--- +# Implementation of embeddings in JabRef + + + +## Context and Problem Statement + +JabRef needs to implement embedding models to perform Retrieval-Augmented Generation (RAG) by generating embeddings for chunks of papers. The AI ecosystem in Java is not as diverse or developed as it is in Python, which limits the available tools for this task. + +We need to decide how to design this integration to balance ease of development with the constraints of a desktop application. + +The features that we need are: + +1. Ability to find models. +2. Ability to download models. +3. Ability to execute inference on models. + +## Decision Drivers + +* The approach should not require additional setup from the user side +* It should be cross-platform +* It should support a wide variety of model architectures +* It should have an easy-to-use API +* The request that embedding libraries make should be known and controlled +* We should know how and where some library downloads and stores models + +## Considered Options + +* Custom implementation +* Use a mix of custom implementation and an inference library +* Use a comprehensive library + +## Decision Outcome + +Chosen option: "Use a comprehensive library", because it is the standard approach in software engineering to rely on specialized libraries for complex tasks rather than re-implementing them. It allows us to delegate the heavy lifting of model management and inference to a dedicated tool. + +### Consequences + +* Good, because it reduces the maintenance burden on the JabRef team +* Good, because we do not have to implement complex inference algorithms ourselves +* Bad, because we are dependent on the external library for updates and maintenance +* Bad, because it is not easy to find such a library in Java ecosystem + +## Pros and Cons of the Options + +### Custom implementation + +* Good, because it will just work (no strange issues with PyTorch or ONNX, etc.) +* Bad, because it is a lot of work +* Bad, because for each architecture of embedding models we would have to write code + +### Use a mix of custom implementation and an inference library + +In this approach we manually implement the features 1 and 2, while relying on an inference library for 3. + +* Good, because complex computations are delegated to another library +* Good, because we are in full control of data (network rquests, model storage) +* Bad, because an inference library might be too scientifically centered with a complex API +* Bad, because we have to write code to find the models, which is not easy +* Bad, because the inference engine might not provide every tool needed + +### Use a comprehensive library + +* Good, because this doesn't require custom code +* Good, because this is the right approach from software engineering POV +* Good, because it implements all features +* Good, because we delegate responsibilities to another library +* Neutral, because there is a small number of libraries for Java for these tasks +* Bad, because it might make untraceable requests to network +* Bad, because the storage of the models might be not customizable + +## More information + +This ADR is highly related to [ADR 0037 - RAG Architecture Implementation](./0037-rag-architecture-implementation.md). diff --git a/docs/decisions/0057-choice-of-embedding-lib.md b/docs/decisions/0057-choice-of-embedding-lib.md new file mode 100644 index 000000000000..a5cbaaffa6d2 --- /dev/null +++ b/docs/decisions/0057-choice-of-embedding-lib.md @@ -0,0 +1,68 @@ +--- +nav_order: 0057 +parent: Decision Records +--- +# Choice of an embedding library + + + +## Context and Problem Statement + +[Ad](./0055-embedding-implementation-approach.md) + +Following the decision to use a comprehensive library for embedding implementation (see [ADR 0055](./0054-embedding-implementation-approach.md)), we must select a specific Java library. + +The features that the library must support are described in "Context and Problem Statement" section of the [ADR 0055](./0054-embedding-implementation-approach.md). + +The Java AI ecosystem is not as diverse as the Python AI ecosystem, so the choice must be careful to ensure stability and ease of use for end users. + +## Decision Drivers + +* The library should not require additional setup from the user side +* It should be cross-platform +* It should support a wide variety of model architectures +* It should have an easy-to-use API +* The request that the library makes should be known and controlled +* We should know how and where the library downloads and stores models + +## Considered Options + +* LangChain4j +* ONNX Runtime +* Deep Java Library (DJL) +* DeepLearning4j + +## Decision Outcome + +Chosen option: "Deep Java Library (DJL)", because it satisfies all our requirements for an all-in-one solution that handles model management and inference. + +However, users have reported problems with the PyTorch engine integration and unstable behavior. Moreover, its API is a bit complex. + +### Consequences + +* Good, because it has an API to show available models +* Good, because it handles model downloading automatically +* Neutral, because the API is complex +* Bad, because users have reported problems with the PyTorch engine integration and unstable behavior + +## Pros and Cons of the Options + +### LangChain4j + +* Good, because it offers a high-level abstraction for LLM workflows +* Neutral, because it actually wraps other libraries like DJL or ONNX Runtime for the embeddings +* Bad, because it is a general LLM framework + +### ONNX Runtime + +* Good, because it is fast and efficient +* Bad, because it is a low-level inference engine and does not provide model management or downloading features out of the box +* Bad, because it supplies all binaries for different platforms at once and also supply debugging symbols, which makes it larger than necessary (see [this issue in LangChain4j repository](https://github.com/langchain4j/langchain4j/issues/1492) and [this issue in ONNX repository](https://github.com/langchain4j/langchain4j/issues/1492)) + +### Deep Java Library (DJL) + +* Good, because it supports multiple engines including PyTorch and ONNX +* Good, because it has a built-in model zoo for downloading models +* Neutral, because its API is a bit complex +* Bad, because of reported stability issues with certain engines + diff --git a/docs/decisions/0058-ai-chat-messages-data.md b/docs/decisions/0058-ai-chat-messages-data.md new file mode 100644 index 000000000000..80079c7ecc19 --- /dev/null +++ b/docs/decisions/0058-ai-chat-messages-data.md @@ -0,0 +1,92 @@ +--- +nav_order: 0058 +parent: Decision Records +--- +# AI chat messages serialization and deserialization + + + +## Context and Problem Statement + +We need to choose the serialization and deserialization method for AI chat messages, as the chat history is persisted. + +## Decision Drivers + +* The API should be simple and easy to use +* It should be easy to perform migrations +* Preferably, use methods that are already used in JabRef + +## Considered Options + +* Use Java native serialization +* Use JSON format +* Use XML format +* Use binary format +* Use YAML +* Use database +* Use a custom format + +## Decision Outcome + +Chosen option: "Use JSON format" with Jackson, because JSON is a simple and widely used format, and it is easy to use with Jackson. Jackson is already used in JabRef, and it supports polymorphic types. + +However, if a database is integrated to AI features, then preferably a database will be used. + +## Pros and Cons of the Options + +### Use Java native serialization + +* Good, because it is simple and already builtin to Java +* Bad, because it is not easily extensible +* Bad, because it is not easily migratable + +### Use JSON format + +* Good, because it is widely used +* Good, because it is easy to use with Jackson +* Good, because it is easy to migrate via a custom script/function +* Good, because it is already used in JabRef +* Bad, because JSON is a dynamic format +* Bad, because JSON does not utilize the space as effectively as binary formats + +### Use XML format + +* Good, because it has a well-defined structure +* Good, because it is easily migratable via a custom script/function +* Good, because there are some builtin libraries for XML in Java +* Bad, because it is old-fashioned +* Bad, because XML does not utilize the space as effectively as binary formats + +### Use binary format + +* Good, because it is highly efficient +* Neutral, because it is not easily readable +* Bad, because it is not easily migratable + +### Use YAML + +* Good, because it is widely used +* Good, because it is easy to migrate via a custom script/function +* Good, because it is already used in JabRef +* Bad, because YAML is a dynamic format +* Bad, because YAML does not utilize the space as effectively as binary formats + +### Use database + +* Good, because it is structured +* Good, because it is highly efficient +* Good, because it is (highly probably) used for other purposes as well +* Good, because it is easy to migrate +* Bad, because it is available only through a database (a custom export feature should be implemented into some other format) +* Bad, because it required running or connecting to a database + +### Use a custom format + +* Good, because we are in full control of the data +* Bad, because it requires a lot of effort to implement + +## More information + +It is hard to decide between JSON and YAML, as they both suffit well. But because JSON is more used generally, more used in AI applications, it was chosen. + +Previously, Java native serialization was used, but it caused too many problems. It was a mistake that happened because of time constraints. diff --git a/docs/requirements/ai.md b/docs/requirements/ai.md index dfe6be9f0044..399667787cd1 100644 --- a/docs/requirements/ai.md +++ b/docs/requirements/ai.md @@ -1,16 +1,25 @@ --- parent: Requirements --- + # AI -## User Interface +## Features -### Chatting with AI -`req~ai.chat.new-message-based-on-previous~1` +- [Chatting](ai/chatting.md) +- [Answer engines](ai/answer-engines.md) +- [Summarization](ai/summarization.md) +- [Citation parsing](ai/citation-parsing.md) +- [Ingestion](ai/ingestion.md) +- [Expert settings](ai/expert-settings.md) +- [LLMs](ai/llms.md) +- [Future features](ai/future-features.md) -To enable simple editing and resending of previous messages, Cursor Up should show last message. -This should only happen if the current text field is empty. +## How to write AI Requirements -Needs: impl +Currently these rules are only applied in the AI features and a bit experimental: - +1. For a "big" AI feature, create a separate file (for example, chatting and summarization). +2. Use `feat` type to group requirements. +3. The requirement title should be a full sentence starting from a verb with all context (rationale: all requirements are displayed by their title in OFT reports, so even if the requirement is "grouped" under some feature using a `Tags:` or `Covers:` fields, they are still displayed separately. Full sentences allow to quickly understand what is this requirement and to which part of JabRef it is related to). +4. At the moment of writing (21-04-2024) OFT does not support linking in FXML files, so for such requirements write a link in the Java file of the FXML controller (which corresponds to the `View` in MVVM). \ No newline at end of file diff --git a/docs/requirements/ai/answer-engines.md b/docs/requirements/ai/answer-engines.md new file mode 100644 index 000000000000..328092738e05 --- /dev/null +++ b/docs/requirements/ai/answer-engines.md @@ -0,0 +1,65 @@ +--- +parent: ai +--- + +# Different answer engines for AI chat +`feat~ai.answer-engines~1` + +Description: answer engine is an algorithm that supplies the context for LLM + +Rationale: different answer engines are suitable for different tasks + +Needs: impl + +Covers: `feat~ai.chatting~1` + + +## Allow users to select a default summarization algorithm +`feat~ai.answer-engines.default~1` + +Needs: impl + +Covers: `feat~ai.answer-engines~1` + + +## "Embedding search" AI answer engine +`feat~ai.answer-engines.embeddings-search~1` + +Rationale: this answer engine is suitable when the user wants to perform a semantic search + +Reference: + +Needs: impl, dsn + +Covers: `feat~ai.answer-engines~1` + +### Allow users to customize injection prompt for "embedding search" AI answer engine +`req~ai.answer-engines.embeddings-search.prompt~1` + +Rationale: different prompts are suited for different tasks and affect the LLM output + +Needs: impl + +Covers: `feat~ai.answer-engines.embeddings-search~1`, `feat~ai.expert-settings~1` + +## "Full document" AI answer engine +`feat~ai.answer-engines.full-document~1` + +Rationale: this answer engine is suitable when the user wants to get information that depends on the full content of a document + +Needs: impl + +Reference: + +Covers: `feat~ai.answer-engines~1` + +### Allow users to customize injection prompt for "full document" AI answer engine +`req~ai.answer-engines.full-document.prompt~1` + +Rationale: different prompts are suited for different tasks and affect the LLM output + +Needs: impl + +Covers: `feat~ai.answer-engines.full-document~1`, `feat~ai.expert-settings~1` + + \ No newline at end of file diff --git a/docs/requirements/ai/chatting.md b/docs/requirements/ai/chatting.md new file mode 100644 index 000000000000..21d0a22809f1 --- /dev/null +++ b/docs/requirements/ai/chatting.md @@ -0,0 +1,190 @@ +--- +parent: ai +--- + +# Chat with AI +`feat~ai.chatting~1` + +Description: this feature represents the AI chat, which can be a chat with an entry or a group. + +Needs: impl + +Covers: `feat~ai~1` + +## General AI chat requirements +`feat~ai.chat.general~1` + +Rationale: common functionalities are required across all chat modes (single entry or group) to ensure a standard user experience + +Covers: `feat~ai.chatting~1` + +### Support deletion of messages in AI chat +`req~ai.chat.delete-messages~1` + +Rationale: users should be able to remove specific messages to clean up the conversation or correct context + +Needs: impl, utest + +Covers: `feat~ai.chat.general~1` + +### Support regeneration of AI responses in AI chat +`req~ai.chat.regenerate-response~1` + +Rationale: users may want a different answer if the previous one was unsatisfactory or hallucinated + +Needs: impl, utest + +Covers: `feat~ai.chat.general~1` + +### Provide a smart prompt input field in AI chat +`req~ai.chat.smart-prompt-field~1` + +Rationale: the input field should support multi-line input, auto-resizing, keyboard shortcuts, and history + +Needs: impl + +Covers: `feat~ai.chat.general~1` + +### Support clearing of chat history in AI chat +`req~ai.chat.clear-history~1` + +Rationale: allows the user to reset the context completely and start a fresh conversation without previous biases + +Needs: impl, guard, utest + +### Display the status of ingested files in AI chat +`req~ai.chat.ingestion-status~1` + +Rationale: the user needs to know if the context files are fully indexed/embedded + +Needs: impl + +Covers: `feat~ai.chat.general~1` + +### Display the currently used AI model in AI chat +`req~ai.chat.model-visibility~1` + +Rationale: provides transparency regarding which LLM is generating the text + +Needs: impl + +Covers: `feat~ai.chat.general~1` + +### Allow user to cancel AI response generation in AI chat +`req~ai.chat.cancel-generation~1` + +Rationale: saves resources/tokens and time if the user realizes the prompt was incorrect while the answer is streaming + +Needs: impl + +Covers: `feat~ai.chat.general~1` + +### Display errors in AI chat +`req~ai.chat.show-errors~1` + +Rationale: feedback must be provided within the chat interface if the API fails, the network drops, or rate limits are hit + +Needs: impl + +Covers: `feat~ai.chat.general~1` + +### Support retry of AI response generation after error in AI chat +`req~ai.chat.retry-error~1` + +Rationale: provides a quick way to re-attempt the request without re-typing the prompt if the failure was transient + +Needs: impl + +Covers: `feat~ai.chat.general~1` + +### Allow user to cancel AI response generation after an error in AI chat +`req~ai.chat.cancel-error-state~1` + +Rationale: allows the user to dismiss the error state or stop a retry loop to regain control of the interface + +Needs: impl + +Covers: `feat~ai.chat.general~1` + +### Support customization of the system prompt in AI chat +`req~ai.chat.customize-system-prompt~1` + +Rationale: users should be able to modify the AI behavior by changing the system prompt to better suit their needs + +Needs: impl + +Covers: `feat~ai.chat.general~1`, `feat~ai.expert-settings~1` + +### Ensure that an answer engine is used in AI chat +`req~ai.chat.uses-answer-engine~1` + +Rationale: this requirement ensures that the AI has context to answer a question + +Needs: impl + +Covers: `feat~ai-answer-engines~1` + +## AI chat with entries +`feat~ai.chatting.entries~1` + +Rationale: specific requirements for chatting with a single bibliography entry + +Needs: impl, pp + +Covers: `feat~ai.chatting~1` + +### Support hiding of the AI chat tab +`req~ai.chat.entries.hide-tab~1` + +Rationale: users who do not use AI features should be able to declutter their interface + +Needs: impl + +Covers: `feat~ai.chatting.entries~1` + +### Persist AI chat history for AI chat with entries +`req~ai.chat.entries.history-storage~1` + +Rationale: history must be persisted per entry, so the user can resume the conversation later + +Needs: dsn, model, impl, utest + +Covers: `feat~ai.chatting.entries~1` + +## AI chat with groups +`feat~ai.chatting.groups~1` + +Rationale: specific requirements for chatting with a collection/group of entries simultaneously + +Needs: impl, pp + +Covers: `feat~ai.chatting~1` + +### Support hiding of the context menu entry for AI chat with group +`req~ai.chat.groups.hide-context-menu~1` + +Rationale: allows customization of the context menu to remove "Chat with group" if the user does not use it + +Needs: impl + +Covers: `feat~ai.chatting.groups~1` + +### Persist AI chat history for AI chat with groups +`req~ai.chat.groups.history-storage~1` + +Rationale: history must be persisted per group, so the conversation context is preserved across sessions + +Needs: dsn, model, impl, utest + +Covers: `feat~ai.chatting.groups~1` + +### Display library name and group name in AI group chat +`req~ai.chat.groups.display-names~1` + +Rationale: essential for user orientation, ensuring that users can distinguish between different chats of a group that has the same name in different libraries + +Needs: impl + +Covers: `feat~ai.chatting.groups~1` + + \ No newline at end of file diff --git a/docs/requirements/ai/citation-parsing.md b/docs/requirements/ai/citation-parsing.md new file mode 100644 index 000000000000..332fad7fd0c4 --- /dev/null +++ b/docs/requirements/ai/citation-parsing.md @@ -0,0 +1,23 @@ +--- +parent: ai +--- + +# Citation parsing with LLMs +`feat~ai.citation-parsing~1` + +Rationale: to enable the automatic extraction and identification of references within text using AI capabilities + +Needs: impl, pp + +Covers: `feat~ai~1` + +## Allow customization of the system prompt for LLM citation parsing +`req~ai.citation-parsing.system-prompt-config~1` + +Rationale: different citation styles or strictness levels require adjusting the baseline instructions (system prompt) given to the AI + +Needs: impl + +Covers: `feat~ai.citation-parsing~1`, `feat~ai.expert-settings~1` + + diff --git a/docs/requirements/ai/expert-features.md b/docs/requirements/ai/expert-features.md new file mode 100644 index 000000000000..0edcfef1d228 --- /dev/null +++ b/docs/requirements/ai/expert-features.md @@ -0,0 +1,69 @@ +--- +parent: ai +--- + +# AI expert settings +`feat~ai.expert-settings~1` + +Rationale: to provide advanced configuration options for controlling AI behavior and defaults across the application + +Covers: `feat~ai~1` + +## Allow modification of AI templates in AI expert settings +`req~ai.expert-settings.templates~1` + +Rationale: users need to adjust the underlying prompt structures to refine AI outputs and behavior patterns + +Needs: impl + +Covers: `feat~ai.expert-settings~1` + +## Allow modification of global chat inference parameters in AI expert settings +`req~ai.expert-settings.chat-inference-global~1` + +Rationale: users need to adjust the underlying settings of the inference to refine AI outputs and behavior patterns + +Needs: impl + +Covers: `feat~ai.expert-settings~1` + +## Allow modification of global RAG parameters in AI expert settings +`req~ai.expert-settings.rag-global~1` + +Rationale: users need to adjust the RAG parameters to refine AI outputs + +Needs: impl + +Covers: `feat~ai.expert-settings~1` + + +## Allow modification of local RAG parameters in AI expert settings +`req~ai.expert-settings.rag-local~1` + +Rationale: users need to adjust the RAG parameters to refine AI outputs + +Needs: impl + +Covers: `feat~ai.expert-settings~1` + + +## Allow modification of global summarization parameters in AI expert settings +`req~ai.expert-settings.summarization-global~1` + +Rationale: users need to adjust the underlying prompt structures to refine AI outputs + +Needs: impl + +Covers: `feat~ai.expert-settings~1` + + +## Allow modification of local summarization parameters in AI expert settings +`req~ai.expert-settings.summarization-local~1` + +Rationale: users need to adjust the underlying prompt structures to refine AI outputs + +Needs: impl + +Covers: `feat~ai.expert-settings~1` + + diff --git a/docs/requirements/ai/future.md b/docs/requirements/ai/future.md new file mode 100644 index 000000000000..1426241c0a9b --- /dev/null +++ b/docs/requirements/ai/future.md @@ -0,0 +1,54 @@ +--- +parent: ai +--- + + + +# Future features +`feat~ai.future~1` + +Rationale: to capture upcoming enhancements and architectural refactoring for the AI system + +Needs: impl + +Covers: `feat~ai~1` + +## Allow modification of the LLM in AI chat +`req~ai.chatting.llm-selection~1` + +Rationale: users may prefer specific models for conversation based on cost, speed, or reasoning capability + +Needs: impl + +Covers: `feat~ai.chatting~1` + +## Allow modification of the LLM in AI summary +`req~ai.summarization.llm-selection~1` + +Rationale: summarization tasks may require different model strengths or token limits compared to interactive chat + +Needs: impl + +Covers: `feat~ai.summarization~1` + +## Support editing of user messages in AI chat +`req~ai.chatting.user-message-editing~1` + +Rationale: users need to correct typos or refine their queries without restarting the entire conversation context + +Needs: impl + +Covers: `feat~ai.chatting~1` + +## Introduce AI profiles +`req~ai.chatting.ai-profiles~1` + +Rationale: currently it is hard to test other chat model in an AI chat, because the model setting is global and only one. + +Needs: impl, dsn, utest + +Covers: `feat~ai.chatting~1` + + + + \ No newline at end of file diff --git a/docs/requirements/ai/ingestion.md b/docs/requirements/ai/ingestion.md new file mode 100644 index 000000000000..48de4b8b7f88 --- /dev/null +++ b/docs/requirements/ai/ingestion.md @@ -0,0 +1,48 @@ +--- +parent: ai +--- + +# Ingestion +`feat~ai.ingestion~1` + +Rationale: to process and index document content into a format suitable for retrieval and AI context generation + +Covers: `feat~ai~1` + +## Support handling of PDF files during ingestion +`req~ai.ingestion.pdf-handling~1` + +Rationale: PDF is a de-facto standard for academic documents + +Needs: impl + +Covers: `feat~ai.ingestion~1` + +## Trigger ingestion of files on demand +`req~ai.ingestion.trigger-on-demand~1` + +Rationale: when a person chats with an entry or group, the system must ensure the linked files are processed immediately to provide up-to-date context + +Needs: impl, pp + +Covers: `feat~ai.ingestion~1` + +## Add automatic ingestion of files +`req~ai.ingestion.automatic-trigger~1` + +Rationale: users may prefer files to be indexed in the background immediately upon upload to reduce wait times during chat interactions + +Needs: impl + +Covers: `feat~ai.ingestion~1` + +## Allow clearing of the embedding cache +`req~ai.ingestion.clear-cache~1` + +Rationale: users need to force a re-ingestion of documents if parsing logic changes or to free up storage space + +Needs: impl + +Covers: `feat~ai.ingestion~1` + + \ No newline at end of file diff --git a/docs/requirements/ai/llms.md b/docs/requirements/ai/llms.md new file mode 100644 index 000000000000..d710cc6799dc --- /dev/null +++ b/docs/requirements/ai/llms.md @@ -0,0 +1,86 @@ +--- +parent: ai +--- + +# LLMs in AI features +`feat~ai.llms~1` + +Rationale: to provide the core connectivity and abstraction layer for interacting with various Large Language Model backends + +Needs: impl + +Covers: `feat~ai~1` + +## Support different LLM providers +`feat~ai.llms.providers~1` + +Rationale: different providers offer varying trade-offs between cost, performance, privacy, and reasoning capabilities + +Needs: impl, uman + +Covers: `feat~ai.llms~1` + +### Support OpenAI LLM provider +`req~ai.llms.providers.openai~1` + +Rationale: popular and widely used LLM provider + +Needs: impl + +Covers: `feat~ai.llms.providers~1` + +### Support HuggingFace LLM provider +`req~ai.llms.providers.huggingface~1` + +Rationale: access to a wide variety of open-weight models and community contributions + +Needs: impl + +Covers: `feat~ai.llms.providers~1` + +### Support Google Gemini LLM provider +`req~ai.llms.providers.gemini~1` + +Rationale: popular and widely used LLM provider + +Needs: impl + +Covers: `feat~ai.llms.providers~1` + +### Support Mistral LLM provider +`req~ai.llms.providers.mistral~1` + +Rationale: popular LLM provider + +Needs: impl + +Covers: `feat~ai.llms.providers~1` + +## Support local and custom LLM connections +`feat~ai.llms.custom~1` + +Rationale: allows users to connect to self-hosted models or proxy services, ensuring data privacy and cost control + +Needs: impl, uman + +Covers: `feat~ai.llms~1`, `feat~ai.expert-settings~1` + +### Add OpenAI-compatible provider +`req~ai.llms.custom.openai-compatible~1` + +Rationale: many local inference servers (e.g., vLLM, Ollama) use the OpenAI API schema, making this a universal connector for local AI + +Needs: impl + +Covers: `feat~ai.llms.custom~1` + +### Add customizable API base URL for OpenAI-compatible provider +`req~ai.llms.custom.base-url~1` + +Rationale: users need to point the client to their specific local server address (e.g., `localhost:8000`) or a private enterprise proxy + +Needs: impl + +Covers: `feat~ai.llms.custom~1` + + \ No newline at end of file diff --git a/docs/requirements/ai/summarization.md b/docs/requirements/ai/summarization.md new file mode 100644 index 000000000000..7a92e8606c0d --- /dev/null +++ b/docs/requirements/ai/summarization.md @@ -0,0 +1,129 @@ +--- +parent: ai +--- + +# Summarization with LLMs +`feat~ai.summarization~1` + +Rationale: to provide capabilities for distilling large amounts of text into concise summaries using LLMs + +Needs: model + +Covers: `feat~ai~1` + +## General AI summarization requirements +`feat~ai.summarization.general~1` + +Rationale: basic functional requirements that apply to all summarization activities regardless of the specific algorithm + +Covers: `feat~ai.summarization~1` + +### Handle documents of any size for AI summarization +`req~ai.summarization.general.unlimited-size~1` + +Rationale: users upload documents of varying lengths, from single pages to books, and the system must process them without hitting context window limits + +Needs: impl + +Covers: `feat~ai.summarization.general~1` + +### Allow export of AI summaries +`req~ai.summarization.general.export~1` + +Rationale: users would want to access a summary offline, or use it in some other program + +Needs: impl + +Covers: `feat~ai.summarization.general~1` + +### AI summaries should be preserved +`req~ai.summarization.general.storage~1` + +Needs: impl, utest, dsn + +Covers: `feat~ai.summarization.general~1` + +## AI summarization of entries +`feat~ai.summarization.entries~1` + +Rationale: specific functionality related to the summarization of database entries or document records + +Needs: impl, pp + +Covers: `feat~ai.summarization~1` + +### Add ability for automatic AI summarization of new entries +`req~ai.summarization.entries.auto~1` + +Rationale: users may wish to automatically generate the summaries for new entries in a library + +Needs: impl, pp + +Covers: `feat~ai.summarization.entries~1` + +## AI summarization algorithms +`feat~ai.summarization.algorithms~1` + +Rationale: distinct strategies for processing text, necessary because different document lengths require different architectural approaches (e.g. single pass vs map-reduce) + +Needs: impl + +Covers: `feat~ai.summarization~1` + +### Allow users to select a default summarization algorithm +`req~ai.summarization.algorithm.default~1` + +Needs: impl + +Covers: `feat~ai.summarization.algorithms~1` + +### "Chunked" AI summarization algorithm +`feat~ai.summarization.algorithms.chunked~1` + +Rationale: a strategy for large documents that splits text into pieces, summarizes them individually, and then combines the results + +Needs: impl + +Reference: simplified version of the algorithm described in + +Covers: `feat~ai.summarization.algorithms~1` + +#### Allow customization of the system prompt for chunk task in "chunked" AI summarization +`req~ai.summarization.algorithms.chunked.system-prompt-chunk~1` + +Rationale: users need to adjust the underlying prompt structures to refine AI outputs + +Needs: impl + +Covers: `feat~ai.summarization.algorithms.chunked~1`, `feat~ai.expert-settings~1` + +#### Allow customization of the system prompt for combination task in "chunked" AI summarization +`req~ai.summarization.algorithms.chunked.system-prompt-combine~1` + +Rationale: users need to adjust the underlying prompt structures to refine AI outputs + +Needs: impl + +Covers: `feat~ai.summarization.algorithms.chunked~1`, `feat~ai.expert-settings~1` + +### "Full document" AI summarization algorithm +`feat~ai.summarization.algorithms.full~1` + +Rationale: a strategy for short documents that fit entirely within the LLM's context window, allowing for a single-pass summary + +Needs: impl + +Reference: + +Covers: `feat~ai.summarization.algorithms~1` + +#### Allow customization of the system prompt for "full document" AI summarization +`req~ai.summarization.algorithms.full.system-prompt~1` + +Rationale: users need to adjust the underlying prompt structures to refine AI outputs + +Needs: impl + +Covers: `feat~ai.summarization.algorithms.full~1`, `feat~ai.expert-settings~1` + + \ No newline at end of file diff --git a/docs/requirements/index.md b/docs/requirements/index.md index e59269da8ebe..dfdffe816d22 100644 --- a/docs/requirements/index.md +++ b/docs/requirements/index.md @@ -47,12 +47,23 @@ After writing the requirement, at the implementation, a comment is added that th ## Automated checks -When executing the gradle task `traceRequirements`, `build/tracing.txt` is generated. +When executing the gradle task `traceRequirements`, `build/reports/tracing.txt` is generated. It captures the links between the artifacts (requirement, implementation, ...) In case of a tracing error, one can inspect `build/tracing.txt` to see which requirements were not covered. +## Custom Artifact Types + +- `pp`: means that this requirement should be guarded with a privacy policy banner. The feature should not do anything if the user does not accept the privacy policy. The specific privacy policy depends on the feature. +- `guard`: means that this action might be dangerous and should be guarded with a confirmation dialog. + +While not a custom artifact, but we interpret these OpenFastTrace artifact types as follows: + +- `dsn`: means writing an ADR. + ## More Information - [General reading on traceability](https://www.sodiuswillert.com/en/blog/implementing-requirements-traceability-in-systems-software-engineering) - [User manual of OpenFastTrace](https://github.com/itsallcode/openfasttrace/blob/main/doc/user_guide.md) + +We recommend using VS Code with `markdownlint` extension to edit requirement files, and not IntelliJ, as VS Code understands `markdownlintdisable` directives. diff --git a/jabgui/src/main/java/org/jabref/gui/DialogService.java b/jabgui/src/main/java/org/jabref/gui/DialogService.java index 401141595b92..82b693b745f0 100644 --- a/jabgui/src/main/java/org/jabref/gui/DialogService.java +++ b/jabgui/src/main/java/org/jabref/gui/DialogService.java @@ -18,7 +18,6 @@ import javafx.util.StringConverter; import org.jabref.gui.util.BaseDialog; -import org.jabref.gui.util.BaseWindow; import org.jabref.gui.util.DirectoryDialogConfiguration; import org.jabref.gui.util.FileDialogConfiguration; import org.jabref.logic.importer.FetcherException; @@ -151,10 +150,10 @@ boolean showConfirmationDialogWithOptOutAndWait(String title, String content, /// @param dialog dialog to show void showCustomDialog(BaseDialog dialog); - /// Shows a custom window. + /// Shows a custom dialog as a window: does not block the main window. /// - /// @param window window to show - void showCustomWindow(BaseWindow window); + /// @param dialog dialog to show + void showCustomDialogModal(BaseDialog dialog); /// This will create and display a new dialog of the specified /// {@link Alert.AlertType} but with user defined buttons as optional diff --git a/jabgui/src/main/java/org/jabref/gui/JabRefDialogService.java b/jabgui/src/main/java/org/jabref/gui/JabRefDialogService.java index 94e662974d86..6be03a637d59 100644 --- a/jabgui/src/main/java/org/jabref/gui/JabRefDialogService.java +++ b/jabgui/src/main/java/org/jabref/gui/JabRefDialogService.java @@ -35,15 +35,16 @@ import javafx.scene.layout.VBox; import javafx.stage.DirectoryChooser; import javafx.stage.FileChooser; +import javafx.stage.Modality; import javafx.stage.Stage; import javafx.stage.Window; +import javafx.stage.WindowEvent; import javafx.util.Duration; import javafx.util.StringConverter; import org.jabref.gui.icon.IconTheme; import org.jabref.gui.theme.ThemeManager; import org.jabref.gui.util.BaseDialog; -import org.jabref.gui.util.BaseWindow; import org.jabref.gui.util.DirectoryDialogConfiguration; import org.jabref.gui.util.FileDialogConfiguration; import org.jabref.gui.util.UiTaskExecutor; @@ -541,11 +542,22 @@ public void showCustomDialog(BaseDialog dialogView) { } @Override - public void showCustomWindow(BaseWindow window) { - if (window.getOwner() == null) { - window.initOwner(mainWindow); + public void showCustomDialogModal(BaseDialog dialog) { + if (dialog.getOwner() == null) { + dialog.initOwner(mainWindow); } - window.show(); + + dialog.initModality(Modality.NONE); + + // Using answer: . + Window dialogWindow = dialog.getDialogPane().getScene().getWindow(); + // Using `addEventHandler` in case someone already used `setOnCloseRequest`. + dialogWindow.addEventHandler( + WindowEvent.WINDOW_CLOSE_REQUEST, + _ -> dialogWindow.hide() + ); + + dialog.show(); } private String getContentByCode(int statusCode) { diff --git a/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java b/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java index 211647b8ae6b..718268b99299 100644 --- a/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java +++ b/jabgui/src/main/java/org/jabref/gui/JabRefGUI.java @@ -231,7 +231,6 @@ public void initialize() { JabRefGUI.aiService = new AiService( preferences.getAiPreferences(), preferences.getFilePreferences(), - preferences.getCitationKeyPatternPreferences(), dialogService, taskExecutor); Injector.setModelOrService(AiService.class, aiService); @@ -244,7 +243,8 @@ public void initialize() { preferences.getEntryEditorPreferences().citationCountFetcherTypeProperty(), preferences.getCitationKeyPatternPreferences(), preferences.getGrobidPreferences(), - JabRefGUI.aiService, + preferences.getAiPreferences(), + aiService.getCurrentChatModel(), entryTypesManager, dialogService ); diff --git a/jabgui/src/main/java/org/jabref/gui/JabRefGuiStateManager.java b/jabgui/src/main/java/org/jabref/gui/JabRefGuiStateManager.java index e5da6c06046d..4d6d2bd54821 100644 --- a/jabgui/src/main/java/org/jabref/gui/JabRefGuiStateManager.java +++ b/jabgui/src/main/java/org/jabref/gui/JabRefGuiStateManager.java @@ -1,7 +1,9 @@ package org.jabref.gui; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.function.Function; @@ -21,7 +23,7 @@ import javafx.scene.Node; import javafx.util.Pair; -import org.jabref.gui.ai.components.aichat.AiChatWindow; +import org.jabref.gui.ai.chat.AiGroupChatWindow; import org.jabref.gui.search.SearchType; import org.jabref.gui.sidepane.SidePaneType; import org.jabref.gui.util.CustomLocalDragboard; @@ -77,7 +79,7 @@ public class JabRefGuiStateManager implements StateManager { private final ObservableMap dialogWindowStates = FXCollections.observableHashMap(); private final ObservableList visibleSidePanes = FXCollections.observableArrayList(); private final ObservableList searchHistory = FXCollections.observableArrayList(); - private final List aiChatWindows = new ArrayList<>(); + private final Map> groupAiChatWindows = new HashMap<>(); private final BooleanProperty editorShowing = new SimpleBooleanProperty(false); private final OptionalObjectProperty activeWalkthrough = OptionalObjectProperty.empty(); private final BooleanProperty canGoBack = new SimpleBooleanProperty(false); @@ -260,8 +262,26 @@ public void clearSearchHistory() { } @Override - public List getAiChatWindows() { - return aiChatWindows; + public Optional getAiChatWindowForGroup(BibDatabaseContext context, String groupName) { + return Optional.ofNullable(groupAiChatWindows.get(context)) + .flatMap(innerMap -> Optional.ofNullable(innerMap.get(groupName))); + } + + @Override + public void setAiChatWindowForGroup(BibDatabaseContext context, String groupName, AiGroupChatWindow aiGroupChatWindow) { + groupAiChatWindows.computeIfAbsent(context, k -> new HashMap<>()) + .put(groupName, aiGroupChatWindow); + } + + @Override + public void removeAiChatWindowForGroup(BibDatabaseContext context, String groupName) { + Map innerMap = groupAiChatWindows.get(context); + if (innerMap != null) { + innerMap.remove(groupName); + if (innerMap.isEmpty()) { + groupAiChatWindows.remove(context); + } + } } @Override diff --git a/jabgui/src/main/java/org/jabref/gui/LibraryTab.java b/jabgui/src/main/java/org/jabref/gui/LibraryTab.java index c27188a498fb..ddf50b1bc563 100644 --- a/jabgui/src/main/java/org/jabref/gui/LibraryTab.java +++ b/jabgui/src/main/java/org/jabref/gui/LibraryTab.java @@ -122,6 +122,7 @@ public class LibraryTab extends Tab implements CommandSelectionTab { private final NavigationHistory navigationHistory = new NavigationHistory(); private final BooleanProperty canGoBackProperty = new SimpleBooleanProperty(false); private final BooleanProperty canGoForwardProperty = new SimpleBooleanProperty(false); + private boolean backOrForwardNavigationActionTriggered = false; private BibDatabaseContext bibDatabaseContext; @@ -160,11 +161,11 @@ public class LibraryTab extends Tab implements CommandSelectionTab { private final ClipBoardManager clipBoardManager; private final TaskExecutor taskExecutor; + private final AiService aiService; + private ImportHandler importHandler; private IndexManager indexManager; - private final AiService aiService; - private Runnable autoCompleterChangedListener; /// If the context is a dummy, the Lucene index should not be created, as both the dummy context and the actual context share the same index path {@link BibDatabaseContext#getFulltextIndexPath()}. @@ -255,7 +256,7 @@ private void initializeComponentsAndListeners(boolean isDummyContext) { autoRenameFileOnEntryChange = new AutoRenameFileOnEntryChange(bibDatabaseContext, preferences.getFilePreferences()); coarseChangeFilter.registerListener(autoRenameFileOnEntryChange); - aiService.setupDatabase(bibDatabaseContext); + aiService.setupDatabase(bibDatabaseContext, isDummyContext); Platform.runLater(() -> { EasyBind.subscribe(changedProperty, this::updateTabTitle); diff --git a/jabgui/src/main/java/org/jabref/gui/StateManager.java b/jabgui/src/main/java/org/jabref/gui/StateManager.java index aa58f7112925..d0613d8b2bac 100644 --- a/jabgui/src/main/java/org/jabref/gui/StateManager.java +++ b/jabgui/src/main/java/org/jabref/gui/StateManager.java @@ -10,7 +10,7 @@ import javafx.concurrent.Task; import javafx.scene.Node; -import org.jabref.gui.ai.components.aichat.AiChatWindow; +import org.jabref.gui.ai.chat.AiGroupChatWindow; import org.jabref.gui.search.SearchType; import org.jabref.gui.sidepane.SidePaneType; import org.jabref.gui.util.CustomLocalDragboard; @@ -88,7 +88,11 @@ public interface StateManager extends SrvStateManager { void clearSearchHistory(); - List getAiChatWindows(); + Optional getAiChatWindowForGroup(BibDatabaseContext context, String groupName); + + void setAiChatWindowForGroup(BibDatabaseContext context, String groupName, AiGroupChatWindow aiGroupChatWindow); + + void removeAiChatWindowForGroup(BibDatabaseContext context, String groupName); BooleanProperty getEditorShowing(); diff --git a/jabgui/src/main/java/org/jabref/gui/ai/AiPrivacyNoticeView.java b/jabgui/src/main/java/org/jabref/gui/ai/AiPrivacyNoticeView.java new file mode 100644 index 000000000000..b3b1bd0fc4ae --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/AiPrivacyNoticeView.java @@ -0,0 +1,102 @@ +package org.jabref.gui.ai; + +import javafx.beans.binding.Bindings; +import javafx.beans.binding.DoubleBinding; +import javafx.fxml.FXML; +import javafx.scene.control.Hyperlink; +import javafx.scene.control.Label; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.GridPane; +import javafx.scene.layout.VBox; +import javafx.scene.text.Text; + +import org.jabref.gui.DialogService; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.gui.util.URLs; +import org.jabref.model.ai.llm.AiProvider; + +import com.airhacks.afterburner.views.ViewLoader; +import jakarta.inject.Inject; + +public class AiPrivacyNoticeView extends ScrollPane { + @FXML private VBox text; + @FXML private GridPane aiPolicies; + @FXML private Text embeddingModelText; + + @Inject private GuiPreferences preferences; + @Inject private DialogService dialogService; + + private AiPrivacyNoticeViewModel viewModel; + + public AiPrivacyNoticeView() { + ViewLoader.view(this) + .root(this) + .load(); + } + + @FXML + private void initialize() { + viewModel = new AiPrivacyNoticeViewModel( + preferences.getAiPreferences(), + preferences.getExternalApplicationsPreferences(), + preferences.getEntryEditorPreferences(), + preferences.getGroupsPreferences(), + dialogService + ); + + setupBindings(); + setupUi(); + } + + private void setupBindings() { + DoubleBinding textWidth = Bindings.subtract(this.widthProperty(), 88d); + text.getChildren().forEach(child -> { + if (child instanceof Text line) { + line.wrappingWidthProperty().bind(textWidth); + } + }); + + aiPolicies.prefWidthProperty().bind(textWidth); + embeddingModelText.wrappingWidthProperty().bind(textWidth); + } + + private void setupUi() { + addPrivacyHyperlink(aiPolicies, AiProvider.OPEN_AI); + addPrivacyHyperlink(aiPolicies, AiProvider.MISTRAL_AI); + addPrivacyHyperlink(aiPolicies, AiProvider.GEMINI); + addPrivacyHyperlink(aiPolicies, AiProvider.HUGGING_FACE); + + // Note: Ideally, this should be bound to update automatically if the size changes but keeping the original logic for text replacement here. + String embeddingTemplate = embeddingModelText.getText(); + String replaced = embeddingTemplate.replaceAll("%0", viewModel.embeddingModelSizeProperty().get()); + embeddingModelText.setText(replaced); + } + + private void addPrivacyHyperlink(GridPane gridPane, AiProvider aiProvider) { + int row = gridPane.getRowCount(); + Label aiName = new Label(aiProvider.getDisplayName()); + gridPane.add(aiName, 0, row); + + Hyperlink hyperlink = new Hyperlink(aiProvider.getPrivacyPolicyUrl()); + hyperlink.setWrapText(true); + hyperlink.setOnAction(_ -> viewModel.openBrowser(aiProvider.getApiUrl())); + gridPane.add(hyperlink, 1, row); + } + + @FXML + private void onDjlLinkClick() { + viewModel.openBrowser(URLs.DJL_PRIVACY_POLICY_URL); + } + + @FXML + private void onPrivacyAgree() { + viewModel.onPrivacyAgree(); + } + + // [impl->req~ai.chat.entries.hide-tab~1] + // [impl->req~ai.chat.groups.hide-context-menu~1] + @FXML + private void onPrivacyDisagree() { + viewModel.privacyDisagree(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/AiPrivacyNoticeViewModel.java b/jabgui/src/main/java/org/jabref/gui/ai/AiPrivacyNoticeViewModel.java new file mode 100644 index 000000000000..545e0365716e --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/AiPrivacyNoticeViewModel.java @@ -0,0 +1,76 @@ +package org.jabref.gui.ai; + +import java.io.IOException; + +import javafx.beans.property.ReadOnlyStringProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.gui.DialogService; +import org.jabref.gui.desktop.os.NativeDesktop; +import org.jabref.gui.entryeditor.EntryEditorPreferences; +import org.jabref.gui.frame.ExternalApplicationsPreferences; +import org.jabref.gui.groups.GroupsPreferences; +import org.jabref.logic.ai.preferences.AiPreferences; +import org.jabref.model.ai.embeddings.PredefinedEmbeddingModel; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AiPrivacyNoticeViewModel extends AbstractViewModel { + + private static final Logger LOGGER = LoggerFactory.getLogger(AiPrivacyNoticeViewModel.class); + + private final StringProperty embeddingModelSize = new SimpleStringProperty(""); + + private final AiPreferences aiPreferences; + private final ExternalApplicationsPreferences externalApplicationsPreferences; + private final EntryEditorPreferences entryEditorPreferences; + private final GroupsPreferences groupsPreferences; + private final DialogService dialogService; + + public AiPrivacyNoticeViewModel( + AiPreferences aiPreferences, + ExternalApplicationsPreferences externalApplicationsPreferences, + EntryEditorPreferences entryEditorPreferences, + GroupsPreferences groupsPreferences, + DialogService dialogService + ) { + this.aiPreferences = aiPreferences; + this.externalApplicationsPreferences = externalApplicationsPreferences; + this.entryEditorPreferences = entryEditorPreferences; + this.groupsPreferences = groupsPreferences; + this.dialogService = dialogService; + + setupBindings(); + } + + private void setupBindings() { + embeddingModelSize.bind(aiPreferences.embeddingModelProperty().map(PredefinedEmbeddingModel::sizeInfo)); + } + + public void onPrivacyAgree() { + aiPreferences.setEnableAi(true); + } + + public void openBrowser(String link) { + try { + NativeDesktop.openBrowser(link, externalApplicationsPreferences); + } catch (IOException e) { + LOGGER.error("Error opening the browser to the Privacy Policy page of the AI provider.", e); + dialogService.showErrorDialogAndWait(e); + } + } + + public void privacyDisagree() { + entryEditorPreferences.setShouldShowAiChatTab(false); + entryEditorPreferences.setShouldShowAiSummaryTab(false); + groupsPreferences.setShowAiChatButton(false); + aiPreferences.setEnableAi(false); + } + + public ReadOnlyStringProperty embeddingModelSizeProperty() { + return embeddingModelSize; + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/ClearEmbeddingsAction.java b/jabgui/src/main/java/org/jabref/gui/ai/ClearEmbeddingsAction.java index 91b3e926269b..fb1e0fb074a4 100644 --- a/jabgui/src/main/java/org/jabref/gui/ai/ClearEmbeddingsAction.java +++ b/jabgui/src/main/java/org/jabref/gui/ai/ClearEmbeddingsAction.java @@ -5,28 +5,34 @@ import org.jabref.gui.DialogService; import org.jabref.gui.StateManager; import org.jabref.gui.actions.SimpleCommand; +import org.jabref.logic.FilePreferences; import org.jabref.logic.ai.AiService; import org.jabref.logic.l10n.Localization; import org.jabref.logic.util.BackgroundTask; import org.jabref.logic.util.TaskExecutor; +import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.LinkedFile; import static org.jabref.gui.actions.ActionHelper.needsDatabase; +// [impl->req~ai.ingestion.clear-cache~1] public class ClearEmbeddingsAction extends SimpleCommand { private final StateManager stateManager; private final DialogService dialogService; private final AiService aiService; private final TaskExecutor taskExecutor; + private final FilePreferences filePreferences; public ClearEmbeddingsAction(StateManager stateManager, DialogService dialogService, AiService aiService, - TaskExecutor taskExecutor) { + TaskExecutor taskExecutor, + FilePreferences filePreferences) { this.stateManager = stateManager; this.dialogService = dialogService; this.taskExecutor = taskExecutor; this.aiService = aiService; + this.filePreferences = filePreferences; this.executable.bind(needsDatabase(stateManager)); } @@ -46,16 +52,19 @@ public void execute() { dialogService.notify(Localization.lang("Clearing embeddings cache...")); - List linkedFiles = stateManager - .getActiveDatabase() - .get() + BibDatabaseContext bibDatabaseContext = stateManager.getActiveDatabase().get(); + + List linkedFiles = bibDatabaseContext .getDatabase() .getEntries() .stream() .flatMap(entry -> entry.getFiles().stream()) .toList(); - BackgroundTask.wrap(() -> aiService.getIngestionService().clearEmbeddingsFor(linkedFiles)) + BackgroundTask.wrap(() -> + aiService + .getEmbeddingsCleaner() + .clearEmbeddingsFor(linkedFiles, bibDatabaseContext, filePreferences)) .executeWith(taskExecutor); } } diff --git a/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatMessageView.java b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatMessageView.java new file mode 100644 index 000000000000..6bd7d83f204d --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatMessageView.java @@ -0,0 +1,189 @@ +package org.jabref.gui.ai.chat; + +import java.util.Optional; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.value.ObservableValue; +import javafx.css.PseudoClass; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.fxml.FXML; +import javafx.geometry.Pos; +import javafx.scene.control.Button; +import javafx.scene.control.ContextMenu; +import javafx.scene.control.Label; +import javafx.scene.control.Tooltip; +import javafx.scene.layout.HBox; +import javafx.scene.layout.StackPane; +import javafx.scene.layout.VBox; + +import org.jabref.gui.clipboard.ClipBoardManager; +import org.jabref.gui.util.BindingsHelper; +import org.jabref.gui.util.LocaleUtil; +import org.jabref.gui.util.component.MarkdownTextFlow; +import org.jabref.model.ai.chatting.ChatMessage; + +import com.airhacks.afterburner.views.ViewLoader; +import jakarta.inject.Inject; + +public class AiChatMessageView extends HBox { + private static final PseudoClass USER_PSEUDO_CLASS = PseudoClass.getPseudoClass("user"); + private static final PseudoClass AI_PSEUDO_CLASS = PseudoClass.getPseudoClass("ai"); + private static final PseudoClass ERROR_PSEUDO_CLASS = PseudoClass.getPseudoClass("error"); + + @FXML private VBox vBox; + + @FXML private Label sourceLabel; + @FXML private StackPane markdownContentPane; + @FXML private ContextMenu contextMenu; + + @FXML private VBox buttonsVBox; + // [impl->req~ai.chat.regenerate-response~1] + @FXML private Button regenerateButton; + // [impl->req~ai.chat.delete-messages~1] + @FXML private Button deleteButton; + + @Inject private ClipBoardManager clipboardManager; + + // Tooltip for the whole component. + private Tooltip tooltip = new Tooltip(); + private MarkdownTextFlow markdownTextFlow; + + private AiChatMessageViewModel viewModel; + + public AiChatMessageView() { + ViewLoader.view(this) + .root(this) + .load(); + } + + @FXML + private void initialize() { + this.viewModel = new AiChatMessageViewModel(clipboardManager); + + markdownTextFlow = new MarkdownTextFlow(markdownContentPane); + markdownContentPane.getChildren().add(markdownTextFlow); + + Tooltip.install(vBox, tooltip); + + vBox.setOnContextMenuRequested(event -> + contextMenu.show(vBox, event.getScreenX(), event.getScreenY())); + + setupBindings(); + setupListeners(); + } + + private void setupBindings() { + regenerateButton.managedProperty().bind(regenerateButton.visibleProperty()); + deleteButton.managedProperty().bind(deleteButton.visibleProperty()); + + sourceLabel.textProperty().bind(viewModel.sourceProperty()); + tooltip.textProperty().bind(viewModel.timestampProperty().map(LocaleUtil::formatInstant)); + + this.alignmentProperty().bind(viewModel.chatMessageProperty().map(AiChatMessageView::determineAlignment)); + buttonsVBox.visibleProperty().bind(this.hoverProperty()); + + regenerateButton.visibleProperty().bind(viewModel.showRegenerateProperty()); + deleteButton.visibleProperty().bind(viewModel.showDeleteProperty()); + + setupPseudoClasses(); + } + + private void setupPseudoClasses() { + ObservableValue messageRole = chatMessageProperty().map(ChatMessage::role); + + ObservableValue isUser = messageRole.map(v -> v == ChatMessage.Role.USER); + ObservableValue isAi = messageRole.map(v -> v == ChatMessage.Role.AI); + ObservableValue isError = messageRole.map(v -> v == ChatMessage.Role.ERROR); + + BindingsHelper.includePseudoClassWhen(vBox, USER_PSEUDO_CLASS, isUser.orElse(false)); + BindingsHelper.includePseudoClassWhen(vBox, AI_PSEUDO_CLASS, isAi.orElse(false)); + BindingsHelper.includePseudoClassWhen(vBox, ERROR_PSEUDO_CLASS, isError.orElse(false)); + } + + private void setupListeners() { + BindingsHelper.listen(viewModel.chatMessageProperty(), this::updateOrder); + BindingsHelper.listen(viewModel.chatMessageProperty(), this::updateContent); + } + + private void updateOrder(ChatMessage chatMessage) { + if (chatMessage == null) { + return; + } + + this.getChildren().clear(); + + if (chatMessage.role() == ChatMessage.Role.USER) { + this.getChildren().addAll(buttonsVBox, vBox); + } else { + this.getChildren().addAll(vBox, buttonsVBox); + } + } + + private void updateContent(ChatMessage chatMessage) { + if (chatMessage == null) { + return; + } + + markdownTextFlow.setMarkdown(Optional.ofNullable(chatMessage.content()).orElse("")); + } + + private static Pos determineAlignment(ChatMessage chatMessage) { + if (chatMessage.role() == ChatMessage.Role.USER) { + return Pos.TOP_RIGHT; + } else { + return Pos.TOP_LEFT; + } + } + + @FXML + private void onDeleteClick() { + viewModel.delete(); + } + + @FXML + private void onRegenerateClick() { + viewModel.regenerate(); + } + + @FXML + private void onCopyContextMenuClick() { + viewModel.copyToClipboard(); + } + + public ObjectProperty chatMessageProperty() { + return viewModel.chatMessageProperty(); + } + + public ChatMessage getChatMessage() { + return viewModel.chatMessageProperty().get(); + } + + public void setChatMessage(ChatMessage chatMessage) { + viewModel.chatMessageProperty().set(chatMessage); + } + + public ObjectProperty> onDeleteProperty() { + return viewModel.onDeleteProperty(); + } + + public EventHandler getOnDelete() { + return viewModel.onDeleteProperty().get(); + } + + public void setOnDelete(EventHandler onDelete) { + viewModel.onDeleteProperty().set(onDelete); + } + + public ObjectProperty> onRegenerateProperty() { + return viewModel.onRegenerateProperty(); + } + + public EventHandler getOnRegenerate() { + return viewModel.onRegenerateProperty().get(); + } + + public void setOnRegenerate(EventHandler onRegenerate) { + viewModel.onRegenerateProperty().set(onRegenerate); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatMessageViewModel.java b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatMessageViewModel.java new file mode 100644 index 000000000000..847c4231667f --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatMessageViewModel.java @@ -0,0 +1,107 @@ +package org.jabref.gui.ai.chat; + +import java.time.Instant; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyBooleanProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyStringProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.scene.input.ClipboardContent; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.gui.clipboard.ClipBoardManager; +import org.jabref.gui.util.BindingsHelper; +import org.jabref.logic.util.strings.StringUtil; +import org.jabref.model.ai.chatting.ChatMessage; + +public class AiChatMessageViewModel extends AbstractViewModel { + private final ObjectProperty chatMessage = new SimpleObjectProperty<>(); + + private final StringProperty id = new SimpleStringProperty(""); + private final StringProperty source = new SimpleStringProperty(""); + private final StringProperty messageContent = new SimpleStringProperty(""); + private final ObjectProperty timestamp = new SimpleObjectProperty<>(Instant.now()); + + private final BooleanProperty showDelete = new SimpleBooleanProperty(true); + private final BooleanProperty showRegenerate = new SimpleBooleanProperty(false); + + private final ObjectProperty> onDelete = new SimpleObjectProperty<>(); + private final ObjectProperty> onRegenerate = new SimpleObjectProperty<>(); + + private final ClipBoardManager clipBoardManager; + + public AiChatMessageViewModel(ClipBoardManager clipBoardManager) { + this.clipBoardManager = clipBoardManager; + + setupBindings(); + } + + private void setupBindings() { + id.bind(chatMessage.map(ChatMessage::id)); + source.bind(chatMessage + .map(ChatMessage::role) + .map(ChatMessage.Role::getDisplayName)); + messageContent.bind(chatMessage.map(ChatMessage::content).map(StringUtil::makeSafe)); + timestamp.bind(chatMessage.map(ChatMessage::timestamp)); + + showRegenerate.bind(chatMessage.map(ChatMessage::role).map(ChatMessage.Role::canRegenerate)); + } + + public void delete() { + BindingsHelper.handle(onDelete); + } + + public void regenerate() { + BindingsHelper.handle(onRegenerate); + } + + public void copyToClipboard() { + ClipboardContent content = new ClipboardContent(); + content.putString(messageContent.get()); + + clipBoardManager.setContent(content); + } + + public ObjectProperty chatMessageProperty() { + return chatMessage; + } + + public ReadOnlyStringProperty idProperty() { + return id; + } + + public ReadOnlyStringProperty sourceProperty() { + return source; + } + + public ReadOnlyStringProperty messageContentProperty() { + return messageContent; + } + + public ReadOnlyObjectProperty timestampProperty() { + return timestamp; + } + + public ReadOnlyBooleanProperty showDeleteProperty() { + return showDelete; + } + + public ReadOnlyBooleanProperty showRegenerateProperty() { + return showRegenerate; + } + + public ObjectProperty> onDeleteProperty() { + return onDelete; + } + + public ObjectProperty> onRegenerateProperty() { + return onRegenerate; + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatStatusView.java b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatStatusView.java new file mode 100644 index 000000000000..f4c4c5f2f945 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatStatusView.java @@ -0,0 +1,218 @@ +package org.jabref.gui.ai.chat; + +import java.nio.file.Path; + +import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; +import javafx.beans.property.ReadOnlyStringWrapper; +import javafx.fxml.FXML; +import javafx.scene.control.Button; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.control.TableColumn; +import javafx.scene.control.TableView; +import javafx.scene.layout.VBox; + +import org.jabref.gui.DialogService; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.gui.util.ValueTableCellFactory; +import org.jabref.gui.util.ViewModelListCellFactory; +import org.jabref.logic.ai.AiService; +import org.jabref.logic.ai.chatting.ChatModel; +import org.jabref.logic.ai.ingestion.tasks.generateembeddings.GenerateEmbeddingsTask; +import org.jabref.logic.ai.rag.logic.AnswerEngine; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.ai.chatting.ChatMessage; +import org.jabref.model.ai.identifiers.FullBibEntry; +import org.jabref.model.ai.pipeline.AnswerEngineKind; +import org.jabref.model.entry.BibEntryTypesManager; + +import com.airhacks.afterburner.views.ViewLoader; +import jakarta.inject.Inject; + +/// Displays status and metadata for an AI chat session. +/// +/// This component provides information about: +/// - The currently selected chat model +/// - The answer engine in use +/// - Entries included in the chat context +/// - The ingestion status of linked files, including any errors encountered +/// +/// It also offers actions to: +/// - Export the chat history +/// - Clear the chat history +/// +/// Typical usage: +/// This component is primarily used within the AiChatView, where: +/// - The chat model and answer engine are bound to this component +/// - The chat history is provided by the AI chat and displayed here +/// +/// Future plans: +/// The component is intended to support configuration of chat parameters, +/// such as selecting a different chat model per session instead of relying +/// on global preferences. Currently, only the answer engine can be modified. +// [impl->req~ai.chat.ingestion-status~1] +public class AiChatStatusView extends VBox { + // [impl->req~ai.chat.model-visibility~1] + @FXML private Label chatModelLabel; + + @FXML private TableView entriesTable; + @FXML private TableColumn libraryColumn; + @FXML private TableColumn citationKeyColumn; + + @FXML private TableView ingestionTable; + @FXML private TableColumn fileColumn; + @FXML private TableColumn statusColumn; + @FXML private TableColumn actionColumn; + + @FXML private ComboBox answerEngineComboBox; + + @Inject private GuiPreferences preferences; + @Inject private AiService aiService; + @Inject private DialogService dialogService; + @Inject private BibEntryTypesManager entryTypesManager; + + private AiChatStatusViewModel viewModel; + + public AiChatStatusView() { + ViewLoader.view(this) + .root(this) + .load(); + } + + @FXML + private void initialize() { + viewModel = new AiChatStatusViewModel( + preferences.getAiPreferences(), + preferences.getFilePreferences(), + preferences.getFieldPreferences(), + entryTypesManager, + dialogService, + aiService.getEmbeddingModelCache(), + aiService.getEmbeddingsStore() + ); + + setupEntriesTable(); + setupIngestionTable(); + setupRest(); + } + + private void setupEntriesTable() { + entriesTable.itemsProperty().bind(viewModel.entriesProperty()); + + citationKeyColumn.setCellValueFactory(cellData -> + new ReadOnlyStringWrapper( + cellData.getValue().entry().getCitationKey().orElse("") + ) + ); + new ValueTableCellFactory() + .withText(text -> text) + .install(citationKeyColumn); + + libraryColumn.setCellValueFactory(cellData -> + new ReadOnlyStringWrapper( + cellData.getValue().databaseContext() + .getDatabasePath() + .map(Path::toString) + .orElse("") + ) + ); + new ValueTableCellFactory() + .withText(text -> text) + .install(libraryColumn); + } + + private void setupIngestionTable() { + ingestionTable.setItems(viewModel.getIngestionStatuses()); + + fileColumn.setCellValueFactory(cellData -> cellData.getValue().nameProperty()); + new ValueTableCellFactory() + .withText(text -> text) + .install(fileColumn); + + statusColumn.setCellValueFactory(cellData -> cellData.getValue().statusProperty()); + new ValueTableCellFactory() + .withText(AiChatStatusViewModel.FileStatus::getDisplayName) + .install(statusColumn); + + actionColumn.setCellValueFactory(cellData -> new ReadOnlyObjectWrapper<>(cellData.getValue())); + new ValueTableCellFactory() + .withGraphic(row -> { + if (row.getStatus() == AiChatStatusViewModel.FileStatus.ERROR_WHILE_PROCESSING) { + return constructErrorButton(row); + } + return null; + }) + .install(actionColumn); + } + + private Button constructErrorButton(AiChatStatusViewModel.IngestionStatusRow row) { + Button errorButton = new Button(Localization.lang("Show Error")); + errorButton.getStyleClass().add("text-button"); + errorButton.setOnAction(event -> + dialogService.showErrorDialogAndWait( + Localization.lang("Ingestion Error"), + row.getError() + ) + ); + return errorButton; + } + + private void setupRest() { + chatModelLabel.textProperty().bind(viewModel.chatModelProperty().map(AiChatStatusView::formatChatModelLabel)); + + new ViewModelListCellFactory() + .withText(AnswerEngineKind::getDisplayName) + .install(answerEngineComboBox); + answerEngineComboBox.setItems(viewModel.answerEngineKindsProperty()); + answerEngineComboBox.valueProperty().bindBidirectional(viewModel.selectedAnswerEngineKindProperty()); + } + + private static String formatChatModelLabel(ChatModel model) { + if (model == null) { + return ""; + } + return Localization.lang("%0 %1", model.getAiProvider().getDisplayName(), model.getName()); + } + + public void setAnswerEngine(AnswerEngine answerEngine) { + viewModel.setAnswerEngine(answerEngine); + } + + @FXML + private void exportJson() { + viewModel.exportJson(); + } + + @FXML + private void exportMarkdown() { + viewModel.exportMarkdown(); + } + + // [impl->req~ai.chat.clear-history~1] + @FXML + private void clearChatHistory() { + viewModel.clearChatHistory(); + } + + public ObjectProperty chatModelProperty() { + return viewModel.chatModelProperty(); + } + + public ListProperty chatHistoryProperty() { + return viewModel.chatHistoryProperty(); + } + + public ObjectProperty answerEngineProperty() { + return viewModel.answerEngineProperty(); + } + + public ListProperty entriesProperty() { + return viewModel.entriesProperty(); + } + + public ListProperty generateEmbeddingsTasksProperty() { + return viewModel.generateEmbeddingsTasksProperty(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatStatusViewModel.java b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatStatusViewModel.java new file mode 100644 index 000000000000..ebe8cd9db4b3 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatStatusViewModel.java @@ -0,0 +1,387 @@ +package org.jabref.gui.ai.chat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import javafx.beans.Observable; +import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyStringWrapper; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.value.ChangeListener; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.gui.DialogService; +import org.jabref.gui.util.BindingsHelper; +import org.jabref.gui.util.FileDialogConfiguration; +import org.jabref.gui.util.UiTaskExecutor; +import org.jabref.logic.FilePreferences; +import org.jabref.logic.ai.chatting.ChatModel; +import org.jabref.logic.ai.chatting.exporters.AiChatJsonExporter; +import org.jabref.logic.ai.chatting.exporters.AiChatMarkdownExporter; +import org.jabref.logic.ai.chatting.util.ChatModelFactory; +import org.jabref.logic.ai.embedding.AsyncEmbeddingModel; +import org.jabref.logic.ai.embedding.EmbeddingModelCache; +import org.jabref.logic.ai.embedding.EmbeddingModelFactory; +import org.jabref.logic.ai.ingestion.tasks.generateembeddings.GenerateEmbeddingsTask; +import org.jabref.logic.ai.preferences.AiPreferences; +import org.jabref.logic.ai.rag.logic.AnswerEngine; +import org.jabref.logic.ai.rag.util.AnswerEngineFactory; +import org.jabref.logic.ai.util.TrackedBackgroundTask; +import org.jabref.logic.bibtex.FieldPreferences; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.ObservablesHelper; +import org.jabref.logic.util.StandardFileType; +import org.jabref.model.ai.AiMetadata; +import org.jabref.model.ai.chatting.ChatMessage; +import org.jabref.model.ai.identifiers.FullBibEntry; +import org.jabref.model.ai.pipeline.AnswerEngineKind; +import org.jabref.model.database.BibDatabaseMode; +import org.jabref.model.entry.BibEntry; +import org.jabref.model.entry.BibEntryTypesManager; +import org.jabref.model.entry.LinkedFile; + +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.store.embedding.EmbeddingStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AiChatStatusViewModel extends AbstractViewModel { + private static final Logger LOGGER = LoggerFactory.getLogger(AiChatStatusViewModel.class); + + public enum FileStatus { + PENDING, + PROCESSING, + ERROR_WHILE_PROCESSING, + INGESTED, + CANCELLED; + + public String getDisplayName() { + return switch (this) { + case PENDING -> + "Pending"; + case PROCESSING -> + "Processing"; + case ERROR_WHILE_PROCESSING -> + "Error"; + case INGESTED -> + "Ingested"; + case CANCELLED -> + "Cancelled"; + }; + } + } + + private final ListProperty generateEmbeddingsTasks = new SimpleListProperty<>(FXCollections.observableArrayList()); + private final Map> taskListeners = new HashMap<>(); + private final ListProperty entries = new SimpleListProperty<>(FXCollections.observableArrayList()); + private final ObservableList ingestionStatuses = FXCollections.observableArrayList(row -> + new Observable[] {row.statusProperty(), row.errorProperty()} + ); + + private final ListProperty answerEngineKinds = new SimpleListProperty<>(FXCollections.observableArrayList(AnswerEngineKind.values())); + private final ObjectProperty selectedAnswerEngineKind = new SimpleObjectProperty<>(); + + private final ObjectProperty answerEngine = new SimpleObjectProperty<>(); + private final ObjectProperty chatModel = new SimpleObjectProperty<>(); + private final ObjectProperty embeddingModel = new SimpleObjectProperty<>(); + + private final ListProperty chatHistory = new SimpleListProperty<>(FXCollections.observableArrayList()); + + private final AiPreferences aiPreferences; + private final FilePreferences filePreferences; + private final FieldPreferences fieldPreferences; + private final BibEntryTypesManager entryTypesManager; + private final DialogService dialogService; + private final EmbeddingModelCache embeddingModelCache; + private final EmbeddingStore embeddingStore; + + public AiChatStatusViewModel( + AiPreferences aiPreferences, + FilePreferences filePreferences, + FieldPreferences fieldPreferences, + BibEntryTypesManager entryTypesManager, + DialogService dialogService, + EmbeddingModelCache embeddingModelCache, + EmbeddingStore embeddingStore + ) { + this.aiPreferences = aiPreferences; + this.filePreferences = filePreferences; + this.fieldPreferences = fieldPreferences; + this.entryTypesManager = entryTypesManager; + this.dialogService = dialogService; + this.embeddingModelCache = embeddingModelCache; + this.embeddingStore = embeddingStore; + + setupValues(); + setupBindings(); + setupListeners(); + } + + private void setupValues() { + selectedAnswerEngineKind.set(aiPreferences.getAnswerEngineKind()); + } + + private void setupBindings() { + this.chatModel.bind(ObservablesHelper.createClosableObjectBinding( + () -> ChatModelFactory.create(aiPreferences), + aiPreferences.getChatProperties() + )); + + this.embeddingModel.bind(ObservablesHelper.createClosableObjectBinding( + () -> EmbeddingModelFactory.create(aiPreferences, embeddingModelCache), + aiPreferences.getEmbeddingsProperties() + )); + + this.answerEngine.bind(ObservablesHelper.createObjectBinding( + () -> AnswerEngineFactory.create( + selectedAnswerEngineKind.get(), + filePreferences, + embeddingModel.get(), + embeddingStore, + aiPreferences.getRagMinScore(), + aiPreferences.getRagMaxResultsCount() + ), + Stream.concat( + aiPreferences.getAnswerEngineProperties().stream(), + Stream.of(selectedAnswerEngineKind) + ).toList() + )); + } + + private void setupListeners() { + BindingsHelper.listenToListContentChanges(generateEmbeddingsTasks, this::wireTask, this::unwireTask); + } + + private void wireTask(GenerateEmbeddingsTask task) { + if (taskListeners.containsKey(task)) { + return; + } + + UiTaskExecutor.runInJavaFXThread(() -> getOrCreateRow(task.getLinkedFile())); + + ChangeListener statusListener = (_, _, _) -> processTask(task); + taskListeners.put(task, statusListener); + task.statusProperty().addListener(statusListener); + + processTask(task); + } + + private void unwireTask(GenerateEmbeddingsTask task) { + ChangeListener listener = taskListeners.remove(task); + if (listener != null) { + task.statusProperty().removeListener(listener); + } + + UiTaskExecutor.runInJavaFXThread(() -> + ingestionStatuses.removeIf(row -> row.getLinkedFile().equals(task.getLinkedFile())) + ); + } + + private IngestionStatusRow getOrCreateRow(LinkedFile file) { + return ingestionStatuses.stream() + .filter(row -> row.getLinkedFile().equals(file)) + .findFirst() + .orElseGet(() -> { + IngestionStatusRow newRow = new IngestionStatusRow(file); + ingestionStatuses.add(newRow); + return newRow; + }); + } + + private void processTask(GenerateEmbeddingsTask task) { + UiTaskExecutor.runInJavaFXThread(() -> { + IngestionStatusRow row = getOrCreateRow(task.getLinkedFile()); + + switch (task.getStatus()) { + case SUCCESS -> { + row.errorProperty().set(null); + row.statusProperty().set(FileStatus.INGESTED); + } + case ERROR -> { + row.errorProperty().set(task.getException()); + row.statusProperty().set(FileStatus.ERROR_WHILE_PROCESSING); + } + case PENDING -> + row.statusProperty().set(FileStatus.PENDING); + case PROCESSING -> + row.statusProperty().set(FileStatus.PROCESSING); + case CANCELLED -> + row.statusProperty().set(FileStatus.CANCELLED); + } + }); + } + + public void exportMarkdown() { + List messages = chatHistory.get(); + + if (messages == null || messages.isEmpty()) { + dialogService.notify(Localization.lang("No chat history available to export")); + return; + } + + FileDialogConfiguration fileDialogConfiguration = new FileDialogConfiguration.Builder() + .addExtensionFilter(StandardFileType.MARKDOWN) + .withDefaultExtension(StandardFileType.MARKDOWN) + .withInitialDirectory(Path.of(System.getProperty("user.home"))) + .build(); + + dialogService.showFileSaveDialog(fileDialogConfiguration) + .ifPresent(path -> { + try { + AiChatMarkdownExporter exporter = new AiChatMarkdownExporter(entryTypesManager, fieldPreferences, aiPreferences.getMarkdownChatExportTemplate()); + String content = exporter.export(buildMetadata(), getBibEntriesFromFullEntries(), getDatabaseModeOrDefault(), messages); + Files.writeString(path, content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + dialogService.notify(Localization.lang("Export operation finished successfully.")); + } catch (IOException e) { + LOGGER.error("Problem occurred while writing the export file", e); + dialogService.showErrorDialogAndWait(Localization.lang("Problem occurred while writing the export file"), e); + } + }); + } + + public void exportJson() { + List messages = chatHistory.get(); + + if (messages == null || messages.isEmpty()) { + dialogService.notify(Localization.lang("No chat history available to export")); + return; + } + + FileDialogConfiguration fileDialogConfiguration = new FileDialogConfiguration.Builder() + .addExtensionFilter(StandardFileType.JSON) + .withDefaultExtension(StandardFileType.JSON) + .withInitialDirectory(Path.of(System.getProperty("user.home"))) + .build(); + + dialogService.showFileSaveDialog(fileDialogConfiguration) + .ifPresent(path -> { + try { + AiChatJsonExporter exporter = new AiChatJsonExporter(entryTypesManager, fieldPreferences); + String content = exporter.export(buildMetadata(), getBibEntriesFromFullEntries(), getDatabaseModeOrDefault(), messages); + Files.writeString(path, content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + dialogService.notify(Localization.lang("Export operation finished successfully.")); + } catch (IOException e) { + LOGGER.error("Problem occurred while writing the export file", e); + dialogService.showErrorDialogAndWait(Localization.lang("Problem occurred while writing the export file"), e); + } + }); + } + + public void setAnswerEngine(AnswerEngine answerEngine) { + selectedAnswerEngineKind.set(answerEngine.getKind()); + } + + public void clearChatHistory() { + // [guard->req~ai.chat.clear-history~1] + boolean confirmed = dialogService.showConfirmationDialogAndWait( + Localization.lang("Clear chat history"), + Localization.lang("Are you sure you want to clear the chat history?") + ); + + if (confirmed) { + chatHistory.clear(); + } + } + + private AiMetadata buildMetadata() { + ChatModel model = chatModel.get(); + if (model == null) { + return new AiMetadata(null, "", Instant.now()); + } + + return new AiMetadata(model.getAiProvider(), model.getName(), Instant.now()); + } + + private List getBibEntriesFromFullEntries() { + return entries.stream() + .map(FullBibEntry::entry) + .toList(); + } + + private BibDatabaseMode getDatabaseModeOrDefault() { + return entries.isEmpty() + ? BibDatabaseMode.BIBTEX + : entries.getFirst().databaseContext().getMode(); + } + + public ObservableList getIngestionStatuses() { + return ingestionStatuses; + } + + public ListProperty generateEmbeddingsTasksProperty() { + return generateEmbeddingsTasks; + } + + public ListProperty entriesProperty() { + return entries; + } + + public ObjectProperty answerEngineProperty() { + return answerEngine; + } + + public ListProperty answerEngineKindsProperty() { + return answerEngineKinds; + } + + public ObjectProperty selectedAnswerEngineKindProperty() { + return selectedAnswerEngineKind; + } + + public ObjectProperty chatModelProperty() { + return chatModel; + } + + public ListProperty chatHistoryProperty() { + return chatHistory; + } + + public static class IngestionStatusRow { + private final LinkedFile linkedFile; + private final ReadOnlyStringWrapper name; + private final ObjectProperty status; + private final ObjectProperty error; + + public IngestionStatusRow(LinkedFile linkedFile) { + this.linkedFile = linkedFile; + this.name = new ReadOnlyStringWrapper(linkedFile.getLink()); + this.status = new SimpleObjectProperty<>(FileStatus.PENDING); + this.error = new SimpleObjectProperty<>(); + } + + public ReadOnlyStringWrapper nameProperty() { + return name; + } + + public ObjectProperty statusProperty() { + return status; + } + + public FileStatus getStatus() { + return status.get(); + } + + public ObjectProperty errorProperty() { + return error; + } + + public Exception getError() { + return error.get(); + } + + public LinkedFile getLinkedFile() { + return linkedFile; + } + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatStatusWindow.java b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatStatusWindow.java new file mode 100644 index 000000000000..ef281ddc2323 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatStatusWindow.java @@ -0,0 +1,54 @@ +package org.jabref.gui.ai.chat; + +import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; +import javafx.fxml.FXML; + +import org.jabref.gui.util.BaseDialog; +import org.jabref.logic.ai.chatting.ChatModel; +import org.jabref.logic.ai.ingestion.tasks.generateembeddings.GenerateEmbeddingsTask; +import org.jabref.logic.ai.rag.logic.AnswerEngine; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.ai.chatting.ChatMessage; +import org.jabref.model.ai.identifiers.FullBibEntry; + +import com.airhacks.afterburner.views.ViewLoader; + +public class AiChatStatusWindow extends BaseDialog { + @FXML private AiChatStatusView aiChatStatusView; + + public AiChatStatusWindow() { + super(); + + this.setTitle(Localization.lang("AI Chat Status")); + this.getDialogPane().getScene().getWindow().setOnCloseRequest(_ -> this.hide()); + + ViewLoader.view(this) + .load() + .setAsDialogPane(this); + } + + public void setAnswerEngine(AnswerEngine answerEngine) { + aiChatStatusView.setAnswerEngine(answerEngine); + } + + public ObjectProperty chatModelProperty() { + return aiChatStatusView.chatModelProperty(); + } + + public ListProperty chatHistoryProperty() { + return aiChatStatusView.chatHistoryProperty(); + } + + public ObjectProperty answerEngineProperty() { + return aiChatStatusView.answerEngineProperty(); + } + + public ListProperty entriesProperty() { + return aiChatStatusView.entriesProperty(); + } + + public ListProperty generateEmbeddingsTasksProperty() { + return aiChatStatusView.generateEmbeddingsTasksProperty(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatView.java b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatView.java new file mode 100644 index 000000000000..cf0eafd1e078 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatView.java @@ -0,0 +1,200 @@ +package org.jabref.gui.ai.chat; + +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.ListProperty; +import javafx.fxml.FXML; +import javafx.scene.Node; +import javafx.scene.control.Button; +import javafx.scene.control.Label; +import javafx.scene.control.ProgressIndicator; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Pane; +import javafx.scene.layout.StackPane; + +import org.jabref.gui.DialogService; +import org.jabref.gui.ai.AiPrivacyNoticeView; +import org.jabref.gui.ai.statuspane.UniversalStatusPaneView; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.gui.util.component.HistoryTextArea; +import org.jabref.gui.util.component.ListScrollPane; +import org.jabref.gui.util.component.SimpleListView; +import org.jabref.logic.ai.AiService; +import org.jabref.logic.ai.chatting.ChatModel; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.TaskExecutor; +import org.jabref.logic.util.strings.StringUtil; +import org.jabref.model.ai.chatting.ChatMessage; +import org.jabref.model.ai.identifiers.FullBibEntry; + +import com.airhacks.afterburner.views.ViewLoader; +import jakarta.inject.Inject; + +/// General AI chat component with [org.jabref.model.entry.BibEntry]. Can be used for chatting with one entry ([AiEntryChatView]) or with several entries (like [AiGroupChatView]). +/// +/// To set up this component, set or bind the [#entriesProperty()] and [#chatHistoryProperty()] properties. +// [impl->feat~ai.chatting~1] +public class AiChatView extends StackPane { + @FXML private AiPrivacyNoticeView privacyNotice; + @FXML private UniversalStatusPaneView noFilesErrorPane; + @FXML private BorderPane mainContainer; + + @FXML private ListScrollPane chatHistoryScrollPane; + + @FXML private Pane transparentPane; + @FXML private ProgressIndicator loadingIndicator; + + @FXML private HBox followUpQuestionsArea; + @FXML private SimpleListView followUpQuestionsSimpleListView; + + @FXML private Button infoButton; + // [impl->req~ai.chat.smart-prompt-field~1] + @FXML private HistoryTextArea userMessageTextArea; + @FXML private Button sendButton; + @FXML private Button retryButton; + @FXML private Button cancelButton; + + @FXML private Label noticeText; + + @Inject private GuiPreferences preferences; + @Inject private AiService aiService; + @Inject private DialogService dialogService; + @Inject private TaskExecutor taskExecutor; + + private AiChatViewModel viewModel; + + public AiChatView() { + ViewLoader.view(this) + .root(this) + .load(); + } + + @FXML + private void initialize() { + viewModel = new AiChatViewModel( + preferences.getAiPreferences(), + preferences.getFilePreferences(), + dialogService, + aiService.getIngestionTaskAggregator(), + aiService.getIngestedDocumentsRepository(), + aiService.getEmbeddingsStore(), + aiService.getEmbeddingModelCache(), + taskExecutor + ); + + setupBindings(); + setupValues(); + setupFollowUpQuestions(); + } + + private void setupBindings() { + chatHistoryScrollPane.itemsProperty().bind(viewModel.chatHistoryProperty()); + chatHistoryScrollPane.setRenderer(this::renderChatMessage); + chatHistoryScrollPane.setAutoScrollToBottom(true); + + // [pp->req~ai.ingestion.trigger-on-demand~1] + privacyNotice.managedProperty().bind(privacyNotice.visibleProperty()); + noFilesErrorPane.managedProperty().bind(noFilesErrorPane.visibleProperty()); + mainContainer.managedProperty().bind(mainContainer.visibleProperty()); + loadingIndicator.managedProperty().bind(loadingIndicator.visibleProperty()); + transparentPane.managedProperty().bind(transparentPane.visibleProperty()); + infoButton.managedProperty().bind(infoButton.visibleProperty()); + userMessageTextArea.managedProperty().bind(userMessageTextArea.visibleProperty()); + sendButton.managedProperty().bind(sendButton.visibleProperty()); + retryButton.managedProperty().bind(retryButton.visibleProperty()); + cancelButton.managedProperty().bind(cancelButton.visibleProperty()); + followUpQuestionsArea.managedProperty().bind(followUpQuestionsArea.visibleProperty()); + + BooleanBinding isAiTurnedOff = viewModel.stateProperty().isEqualTo(AiChatViewModel.State.AI_TURNED_OFF); + BooleanBinding isNoFiles = viewModel.stateProperty().isEqualTo(AiChatViewModel.State.NO_FILES); + BooleanBinding isWaiting = viewModel.stateProperty().isEqualTo(AiChatViewModel.State.WAITING_FOR_MESSAGE); + BooleanBinding isError = viewModel.stateProperty().isEqualTo(AiChatViewModel.State.ERROR); + BooleanBinding isIdle = viewModel.stateProperty().isEqualTo(AiChatViewModel.State.IDLE); + + privacyNotice.visibleProperty().bind(isAiTurnedOff); + noFilesErrorPane.visibleProperty().bind(isNoFiles); + mainContainer.visibleProperty().bind(isAiTurnedOff.not().and(isNoFiles.not())); + + loadingIndicator.visibleProperty().bind(isWaiting); + transparentPane.visibleProperty().bind(isWaiting); + userMessageTextArea.visibleProperty().bind(isIdle); + sendButton.visibleProperty().bind(isIdle); + retryButton.visibleProperty().bind(isError); + cancelButton.visibleProperty().bind(isWaiting.or(isError)); + noticeText.textProperty().bind(viewModel.chatModelProperty().map(AiChatView::formatNoticeText)); + } + + private static String formatNoticeText(ChatModel model) { + String modelName = model.getAiProvider().getDisplayName() + " " + model.getName(); + return Localization.lang("Current AI model: %0. The AI may generate inaccurate or inappropriate responses. Please verify any information provided", modelName); + } + + private void setupFollowUpQuestions() { + followUpQuestionsArea.visibleProperty().bind( + viewModel.followUpQuestionsProperty().emptyProperty().not() + .and(preferences.getAiPreferences().generateFollowUpQuestionsProperty()) + .and(viewModel.stateProperty().isEqualTo(AiChatViewModel.State.IDLE)) + ); + + followUpQuestionsSimpleListView.itemsProperty().bind(viewModel.followUpQuestionsProperty()); + followUpQuestionsSimpleListView.setRenderer(question -> { + Button button = new Button(question); + button.getStyleClass().add("exampleQuestionStyle"); + button.setOnAction(_ -> viewModel.sendFollowUpMessage(question)); + return button; + }); + } + + private void setupValues() { + userMessageTextArea.getHistory().addAll( + viewModel + .chatHistoryProperty() + .stream() + .map(ChatMessage::content) + .filter(StringUtil::isNotBlank) + .toList() + ); + } + + private Node renderChatMessage(ChatMessage chatMessage) { + AiChatMessageView aiChatMessageView = new AiChatMessageView(); + + aiChatMessageView.setChatMessage(chatMessage); + aiChatMessageView.setOnDelete(_ -> viewModel.delete(chatMessage.id())); + aiChatMessageView.setOnRegenerate(_ -> viewModel.regenerate(chatMessage.id())); + + return aiChatMessageView; + } + + @FXML + private void showInfo() { + viewModel.showInfo(); + } + + @FXML + private void send() { + viewModel.sendMessage(userMessageTextArea.getText()); + userMessageTextArea.clear(); + } + + // [impl->req~ai.chat.retry-error~1] + @FXML + private void retry() { + viewModel.regenerate(); + } + + // [impl->req~ai.chat.cancel-generation~1] + // [impl->req~ai.chat.cancel-error-state~1] + @FXML + private void cancel() { + viewModel.cancel(); + } + + public ListProperty chatHistoryProperty() { + return viewModel.chatHistoryProperty(); + } + + public ListProperty entriesProperty() { + return viewModel.entriesProperty(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatViewModel.java b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatViewModel.java new file mode 100644 index 000000000000..9464ad33b95e --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiChatViewModel.java @@ -0,0 +1,419 @@ +package org.jabref.gui.ai.chat; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.TreeMap; + +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; +import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.gui.DialogService; +import org.jabref.gui.util.BindingsHelper; +import org.jabref.logic.FilePreferences; +import org.jabref.logic.ai.chatting.ChatModel; +import org.jabref.logic.ai.chatting.tasks.GenerateRagResponseTask; +import org.jabref.logic.ai.chatting.util.ChatHistoryUtils; +import org.jabref.logic.ai.chatting.util.ChatModelFactory; +import org.jabref.logic.ai.embedding.AsyncEmbeddingModel; +import org.jabref.logic.ai.embedding.EmbeddingModelCache; +import org.jabref.logic.ai.embedding.EmbeddingModelFactory; +import org.jabref.logic.ai.followup.tasks.GenerateFollowUpQuestions; +import org.jabref.logic.ai.ingestion.IngestionTaskAggregator; +import org.jabref.logic.ai.ingestion.logic.documentsplitting.DocumentSplitter; +import org.jabref.logic.ai.ingestion.repositories.IngestedDocumentsRepository; +import org.jabref.logic.ai.ingestion.tasks.generateembeddings.GenerateEmbeddingsTask; +import org.jabref.logic.ai.ingestion.tasks.generateembeddings.GenerateEmbeddingsTaskRequest; +import org.jabref.logic.ai.ingestion.util.DocumentSplitterFactory; +import org.jabref.logic.ai.preferences.AiPreferences; +import org.jabref.logic.ai.rag.logic.AnswerEngine; +import org.jabref.logic.ai.rag.util.AnswerEngineFactory; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.BackgroundTask; +import org.jabref.logic.util.ObservablesHelper; +import org.jabref.logic.util.TaskExecutor; +import org.jabref.logic.util.strings.StringUtil; +import org.jabref.model.ai.chatting.ChatMessage; +import org.jabref.model.ai.identifiers.FullBibEntry; + +import com.google.common.collect.Comparators; +import com.tobiasdiez.easybind.EasyBind; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.store.embedding.EmbeddingStore; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AiChatViewModel extends AbstractViewModel { + public enum State { + AI_TURNED_OFF, + NO_FILES, + IDLE, + WAITING_FOR_MESSAGE, + ERROR + } + + private static final Logger LOGGER = LoggerFactory.getLogger(AiChatViewModel.class); + + private static final String EXAMPLE_QUESTION_1 = Localization.lang("What is the goal of the paper?"); + private static final String EXAMPLE_QUESTION_2 = Localization.lang("Which methods were used in the research?"); + private static final String EXAMPLE_QUESTION_3 = Localization.lang("What are the key findings?"); + + private final ObjectProperty state = new SimpleObjectProperty<>(State.IDLE); + private final ObjectProperty answerEngine = new SimpleObjectProperty<>(); + private final ListProperty entries = new SimpleListProperty<>(FXCollections.observableArrayList()); + + private final ListProperty generateEmbeddingsTasks = new SimpleListProperty<>(FXCollections.observableArrayList()); + private final ObjectProperty generateRagResponseTask = new SimpleObjectProperty<>(); + private BackgroundTask> generateFollowUpQuestionsTask; + + private final ListProperty followUpQuestions = new SimpleListProperty<>(FXCollections.observableArrayList( + EXAMPLE_QUESTION_1, + EXAMPLE_QUESTION_2, + EXAMPLE_QUESTION_3 + )); + + private final ObjectProperty chatModel = new SimpleObjectProperty<>(); + private final ObjectProperty embeddingModel = new SimpleObjectProperty<>(); + private final ObjectProperty documentSplitter = new SimpleObjectProperty<>(); + + private final TreeMap, GenerateRagResponseTask> tasksMap = + new TreeMap<>(Comparators.lexicographical(Comparator.comparing(id -> id.entry().getId()))); + + private final TreeMap, List> followUpQuestionsCache = + new TreeMap<>(Comparators.lexicographical(Comparator.comparing(id -> id.entry().getId()))); + + private final AiPreferences aiPreferences; + private final FilePreferences filePreferences; + private final DialogService dialogService; + private final IngestionTaskAggregator ingestionTaskAggregator; + private final IngestedDocumentsRepository ingestedDocumentsRepository; + private final EmbeddingStore embeddingStore; + private final EmbeddingModelCache embeddingModelCache; + private final TaskExecutor taskExecutor; + + private final ListProperty chatHistory = new SimpleListProperty<>(FXCollections.observableArrayList()); + private final StringProperty systemMessageTemplate = new SimpleStringProperty(); + private final StringProperty userMessageTemplate = new SimpleStringProperty(); + + private List currentEntriesSnapshot = new ArrayList<>(); + + public AiChatViewModel( + AiPreferences aiPreferences, + FilePreferences filePreferences, + DialogService dialogService, + IngestionTaskAggregator ingestionTaskAggregator, + IngestedDocumentsRepository ingestedDocumentsRepository, + EmbeddingStore embeddingStore, + EmbeddingModelCache embeddingModelCache, + TaskExecutor taskExecutor + ) { + this.aiPreferences = aiPreferences; + this.filePreferences = filePreferences; + this.dialogService = dialogService; + this.ingestionTaskAggregator = ingestionTaskAggregator; + this.ingestedDocumentsRepository = ingestedDocumentsRepository; + this.embeddingStore = embeddingStore; + this.embeddingModelCache = embeddingModelCache; + this.taskExecutor = taskExecutor; + + setupBindings(); + setupValues(); + setupListeners(); + } + + private void setupBindings() { + systemMessageTemplate.bind(aiPreferences.chattingSystemMessageTemplateProperty()); + userMessageTemplate.bind(aiPreferences.chattingUserMessageTemplateProperty()); + + this.embeddingModel.bind(ObservablesHelper.createClosableObjectBinding( + () -> EmbeddingModelFactory.create(aiPreferences, embeddingModelCache), + aiPreferences.getEmbeddingsProperties() + )); + + this.documentSplitter.bind(ObservablesHelper.createObjectBinding( + () -> DocumentSplitterFactory.create(aiPreferences), + aiPreferences.getDocumentSplitterProperties() + )); + + BooleanBinding isAiTurnedOff = aiPreferences.enableAiProperty().not(); + BooleanBinding isWaiting = generateRagResponseTask.isNotNull(); + BooleanBinding hasNoFiles = Bindings.createBooleanBinding(() -> + entries.get() == null || + entries.isEmpty() || + entries.stream().flatMap(identifier -> identifier.entry().getFiles().stream()).findAny().isEmpty(), + entries, aiPreferences.enableAiProperty() + ); + + BooleanBinding isError = Bindings.createBooleanBinding(() -> { + if (chatHistory.isEmpty()) { + return false; + } + return chatHistory.getLast().role() == ChatMessage.Role.ERROR; + }, chatHistory); + + BindingsHelper.bindEnum( + state, + State.IDLE, + + Map.entry(State.AI_TURNED_OFF, isAiTurnedOff), + Map.entry(State.WAITING_FOR_MESSAGE, isWaiting), + Map.entry(State.NO_FILES, hasNoFiles), + Map.entry(State.ERROR, isError) + ); + } + + private void setupValues() { + answerEngine.setValue(AnswerEngineFactory.create( + aiPreferences.getAnswerEngineKind(), + filePreferences, + embeddingModel.get(), + embeddingStore, + aiPreferences.getRagMinScore(), + aiPreferences.getRagMaxResultsCount() + )); + + chatModel.setValue(ChatModelFactory.create(aiPreferences)); + } + + private void setupListeners() { + BindingsHelper.listenToListChange( + entries, + this::changeEmbeddingTasks + ); + + EasyBind.subscribe( + systemMessageTemplate, + _ -> ChatHistoryUtils.updateSystemMessage(chatHistory, systemMessageTemplate.get()) + ); + } + + private void changeEmbeddingTasks() { + if (!aiPreferences.getEnableAi() || entries.isEmpty()) { + return; + } + + if (!currentEntriesSnapshot.isEmpty()) { + followUpQuestionsCache.put(new ArrayList<>(currentEntriesSnapshot), new ArrayList<>(followUpQuestions)); + } + + if (generateFollowUpQuestionsTask != null && !generateFollowUpQuestionsTask.isCancelled()) { + generateFollowUpQuestionsTask.cancel(); + generateFollowUpQuestionsTask = null; + } + + currentEntriesSnapshot = new ArrayList<>(entries); + List cached = followUpQuestionsCache.get(currentEntriesSnapshot); + if (cached != null) { + followUpQuestions.setAll(cached); + } else { + followUpQuestions.clear(); + followUpQuestions.addAll( + EXAMPLE_QUESTION_1, + EXAMPLE_QUESTION_2, + EXAMPLE_QUESTION_3 + ); + } + + generateEmbeddingsTasks.clear(); + generateRagResponseTask.set(tasksMap.get(entries)); + + entries.forEach(identifier -> + identifier.entry().getFiles().forEach(file -> { + // [impl->req~ai.ingestion.trigger-on-demand~1] + GenerateEmbeddingsTask task = ingestionTaskAggregator.start( + new GenerateEmbeddingsTaskRequest( + filePreferences, + ingestedDocumentsRepository, + embeddingStore, + embeddingModel.get(), + documentSplitter.get(), + identifier.databaseContext(), + file + ) + ); + + generateEmbeddingsTasks.add(task); + } + ) + ); + } + + public void showInfo() { + AiChatStatusWindow window = new AiChatStatusWindow(); + + window.chatModelProperty().bind(chatModel); + window.entriesProperty().bind(entries); + window.generateEmbeddingsTasksProperty().bind(generateEmbeddingsTasks); + + window.setAnswerEngine(answerEngine.get()); + + dialogService.showCustomDialogAndWait(window); + + answerEngine.set(window.answerEngineProperty().get()); + } + + public void sendMessage(String userMessage) { + assert state.get() == State.IDLE; + + if (StringUtil.isBlank(userMessage)) { + return; + } + + followUpQuestions.clear(); + clearGenerateRagResponseTask(); + + ChatMessage userChatMessage = ChatMessage.userMessage(userMessage); + chatHistory.add(userChatMessage); + + GenerateRagResponseTask task = new GenerateRagResponseTask( + chatModel.get(), + answerEngine.get(), + chatHistory, + entries.get(), + systemMessageTemplate.get(), + userMessageTemplate.get() + ); + + List taskEntries = entries.get(); + + final ObservableList originalChatHistory = chatHistory.get(); + + task.onSuccess(aiMessage -> { + originalChatHistory.add(aiMessage); + + if (aiPreferences.getGenerateFollowUpQuestions() && chatModel.get() != null) { + scheduleFollowUpQuestionsGeneration(userMessage, aiMessage.content()); + } + }); + + task.onFailure(ex -> + // [impl->req~ai.chat.show-errors~1] + originalChatHistory.add(ChatMessage.errorMessage(ex))); + + task.onFinished(() -> { + tasksMap.remove(taskEntries); + if (generateRagResponseTask.get() == task) { + generateRagResponseTask.set(null); + } + }); + + task.executeWith(taskExecutor); + generateRagResponseTask.set(task); + tasksMap.put(taskEntries, task); + } + + private void scheduleFollowUpQuestionsGeneration(String userMessage, String aiResponse) { + ChatModel currentChatModel = chatModel.get(); + if (currentChatModel == null) { + return; + } + + if (generateFollowUpQuestionsTask != null && !generateFollowUpQuestionsTask.isCancelled()) { + generateFollowUpQuestionsTask.cancel(); + } + + List entriesSnapshot = new ArrayList<>(entries); + + final ObservableList originalFollowUpQuestions = followUpQuestions.get(); + + generateFollowUpQuestionsTask = new GenerateFollowUpQuestions( + currentChatModel, + aiPreferences, + userMessage, + aiResponse + ); + + generateFollowUpQuestionsTask + .onSuccess(questions -> { + originalFollowUpQuestions.setAll(questions); + followUpQuestionsCache.put(entriesSnapshot, new ArrayList<>(questions)); + }) + .onFailure(ex -> LOGGER.error("Failed to generate follow-up questions", ex)) + .executeWith(taskExecutor); + } + + public void sendFollowUpMessage(String question) { + followUpQuestions.clear(); + sendMessage(question); + } + + private void clearGenerateRagResponseTask() { + if (generateRagResponseTask.get() != null) { + if (!generateRagResponseTask.get().isCancelled()) { + generateRagResponseTask.get().cancel(); + } + generateRagResponseTask.set(null); + } + } + + public void cancel() { + assert state.get() == State.WAITING_FOR_MESSAGE || state.get() == State.ERROR; + + if (state.get() == State.WAITING_FOR_MESSAGE) { + clearGenerateRagResponseTask(); + } else if (state.get() == State.ERROR) { + if (!chatHistory.isEmpty()) { + chatHistory.removeLast(); + } + } + followUpQuestions.clear(); + } + + public void delete(String id) { + assert state.get() == State.IDLE; + ChatHistoryUtils.delete(chatHistory, id); + } + + public void regenerate(String id) { + assert state.get() == State.ERROR || state.get() == State.IDLE; + + Optional contentToRegenerate = ChatHistoryUtils.regenerate(chatHistory, id); + + contentToRegenerate.ifPresent(this::sendMessage); + } + + public void regenerate() { + if (!chatHistory.isEmpty()) { + regenerate(chatHistory.getLast().id()); + } + } + + public ListProperty entriesProperty() { + return entries; + } + + public ListProperty chatHistoryProperty() { + return chatHistory; + } + + public ObjectProperty stateProperty() { + return state; + } + + public ObjectProperty chatModelProperty() { + return chatModel; + } + + public ObjectProperty answerEngineProperty() { + return answerEngine; + } + + public ListProperty generateEmbeddingsTasksProperty() { + return generateEmbeddingsTasks; + } + + public ListProperty followUpQuestionsProperty() { + return followUpQuestions; + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/chat/AiEntryChatView.java b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiEntryChatView.java new file mode 100644 index 000000000000..82e49812aec7 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiEntryChatView.java @@ -0,0 +1,56 @@ +package org.jabref.gui.ai.chat; + +import javafx.beans.property.ObjectProperty; +import javafx.fxml.FXML; +import javafx.scene.layout.StackPane; + +import org.jabref.gui.ai.AiPrivacyNoticeView; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.logic.ai.AiService; +import org.jabref.model.ai.identifiers.FullBibEntry; + +import com.airhacks.afterburner.views.ViewLoader; +import jakarta.inject.Inject; + +// [impl->feat~ai.chatting.entries~1] +public class AiEntryChatView extends StackPane { + @FXML private AiPrivacyNoticeView privacyNotice; + @FXML private AiChatView aiChatView; + + @Inject private GuiPreferences preferences; + @Inject private AiService aiService; + + private AiEntryChatViewModel viewModel; + + public AiEntryChatView() { + ViewLoader.view(this) + .root(this) + .load(); + } + + @FXML + private void initialize() { + viewModel = new AiEntryChatViewModel( + preferences.getAiPreferences(), + aiService.getChatHistoryCache() + ); + + setupBindings(); + } + + private void setupBindings() { + // [pp->feat~ai.chatting.entries~1] + privacyNotice.managedProperty().bind(privacyNotice.visibleProperty()); + aiChatView.managedProperty().bind(aiChatView.visibleProperty()); + + privacyNotice.visibleProperty().bind(viewModel.stateProperty().isEqualTo(AiEntryChatViewModel.State.AI_TURNED_OFF)); + aiChatView.visibleProperty().bind(viewModel.stateProperty().isEqualTo(AiEntryChatViewModel.State.CHATTING)); + + aiChatView.chatHistoryProperty().bind(viewModel.chatHistoryProperty()); + aiChatView.entriesProperty().bind(viewModel.entriesProperty()); + } + + public ObjectProperty selectedEntryProperty() { + return viewModel.selectedEntryProperty(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/chat/AiEntryChatViewModel.java b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiEntryChatViewModel.java new file mode 100644 index 000000000000..eab243af8e5a --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiEntryChatViewModel.java @@ -0,0 +1,91 @@ +package org.jabref.gui.ai.chat; + +import java.util.Map; + +import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.value.ObservableValue; +import javafx.collections.FXCollections; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.gui.util.BindingsHelper; +import org.jabref.logic.ai.chatting.InMemoryChatHistoryCache; +import org.jabref.logic.ai.preferences.AiPreferences; +import org.jabref.model.ai.chatting.ChatMessage; +import org.jabref.model.ai.identifiers.FullBibEntry; + +import com.tobiasdiez.easybind.EasyBind; + +public class AiEntryChatViewModel extends AbstractViewModel { + public enum State { + AI_TURNED_OFF, + CHATTING + } + + private final ObjectProperty state = new SimpleObjectProperty<>(State.AI_TURNED_OFF); + private final ObjectProperty selectedEntry = new SimpleObjectProperty<>(); + private final ListProperty entries = new SimpleListProperty<>(FXCollections.observableArrayList()); + private final ListProperty chatHistory = new SimpleListProperty<>(FXCollections.observableArrayList()); + + private final AiPreferences aiPreferences; + private final InMemoryChatHistoryCache chatHistoryCache; + + public AiEntryChatViewModel( + AiPreferences aiPreferences, + InMemoryChatHistoryCache chatHistoryCache + ) { + this.aiPreferences = aiPreferences; + this.chatHistoryCache = chatHistoryCache; + + setupBindings(); + setupListeners(); + } + + private void setupBindings() { + ObservableValue isAiTurnedOff = aiPreferences.enableAiProperty().not(); + + BindingsHelper.bindEnum( + state, + State.CHATTING, + + Map.entry(State.AI_TURNED_OFF, + isAiTurnedOff.orElse(true) + ) + ); + } + + private void setupListeners() { + EasyBind.subscribe(selectedEntry, this::load); + } + + private void load(FullBibEntry identifier) { + if (selectedEntry.get() == null || state.get() != State.CHATTING) { + return; + } + + entries.set(FXCollections.observableArrayList(identifier)); + + chatHistory.set(chatHistoryCache.getForEntry( + identifier.databaseContext(), + identifier.entry() + )); + } + + public ObjectProperty selectedEntryProperty() { + return selectedEntry; + } + + public ObjectProperty stateProperty() { + return state; + } + + public ListProperty entriesProperty() { + return entries; + } + + public ListProperty chatHistoryProperty() { + return chatHistory; + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/chat/AiGroupChatView.java b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiGroupChatView.java new file mode 100644 index 000000000000..fa635eefa467 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiGroupChatView.java @@ -0,0 +1,58 @@ +package org.jabref.gui.ai.chat; + +import javafx.beans.property.ObjectProperty; +import javafx.fxml.FXML; +import javafx.scene.layout.StackPane; + +import org.jabref.gui.ai.AiPrivacyNoticeView; +import org.jabref.gui.groups.GroupNodeViewModel; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.logic.ai.AiService; +import org.jabref.model.database.BibDatabaseContext; + +import com.airhacks.afterburner.views.ViewLoader; +import jakarta.inject.Inject; + +// [impl->feat~ai.chatting.groups~1] +public class AiGroupChatView extends StackPane { + @FXML private AiPrivacyNoticeView privacyNotice; + @FXML private AiChatView aiChatView; + + @Inject private GuiPreferences preferences; + @Inject private AiService aiService; + + private AiGroupChatViewModel viewModel; + + public AiGroupChatView() { + ViewLoader.view(this) + .root(this) + .load(); + } + + @FXML + private void initialize() { + viewModel = new AiGroupChatViewModel(preferences.getAiPreferences(), aiService); + + setupBindings(); + } + + private void setupBindings() { + // [pp->feat~ai.chatting.groups~1] + privacyNotice.managedProperty().bind(privacyNotice.visibleProperty()); + aiChatView.managedProperty().bind(aiChatView.visibleProperty()); + + privacyNotice.visibleProperty().bind(viewModel.stateProperty().isEqualTo(AiGroupChatViewModel.State.AI_TURNED_OFF)); + aiChatView.visibleProperty().bind(viewModel.stateProperty().isEqualTo(AiGroupChatViewModel.State.CHATTING)); + + aiChatView.chatHistoryProperty().bind(viewModel.chatHistoryProperty()); + aiChatView.entriesProperty().bind(viewModel.entriesProperty()); + } + + public ObjectProperty groupNodeProperty() { + return viewModel.groupNodeProperty(); + } + + public ObjectProperty databaseContextProperty() { + return viewModel.databaseContextProperty(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/chat/AiGroupChatViewModel.java b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiGroupChatViewModel.java new file mode 100644 index 000000000000..4ddcb3a23649 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiGroupChatViewModel.java @@ -0,0 +1,95 @@ +package org.jabref.gui.ai.chat; + +import java.util.List; +import java.util.Map; + +import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.gui.groups.GroupNodeViewModel; +import org.jabref.gui.util.BindingsHelper; +import org.jabref.logic.ai.AiService; +import org.jabref.logic.ai.chatting.InMemoryChatHistoryCache; +import org.jabref.logic.ai.preferences.AiPreferences; +import org.jabref.model.ai.chatting.ChatMessage; +import org.jabref.model.ai.identifiers.FullBibEntry; +import org.jabref.model.database.BibDatabaseContext; +import org.jabref.model.entry.BibEntry; + +public class AiGroupChatViewModel extends AbstractViewModel { + public enum State { + AI_TURNED_OFF, + CHATTING + } + + private final ObjectProperty state = new SimpleObjectProperty<>(State.AI_TURNED_OFF); + + private final ObjectProperty groupNode = new SimpleObjectProperty<>(); + private final ObjectProperty databaseContext = new SimpleObjectProperty<>(); + + private final ListProperty entries = new SimpleListProperty<>(FXCollections.observableArrayList()); + private final ListProperty chatHistory = new SimpleListProperty<>(FXCollections.observableArrayList()); + + private final AiPreferences aiPreferences; + private final InMemoryChatHistoryCache chatHistoryCache; + + public AiGroupChatViewModel(AiPreferences aiPreferences, AiService aiService) { + this.aiPreferences = aiPreferences; + this.chatHistoryCache = aiService.getChatHistoryCache(); + + BindingsHelper.bindEnum( + state, + State.CHATTING, + + Map.entry(State.AI_TURNED_OFF, + aiPreferences.enableAiProperty().not()) + ); + + BindingsHelper.listen(this::loadGroupChat, groupNode, databaseContext); + } + + private void loadGroupChat() { + if (groupNode.get() == null || databaseContext.get() == null || !aiPreferences.getEnableAi()) { + return; + } + + BibDatabaseContext context = databaseContext.get(); + GroupNodeViewModel group = groupNode.get(); + + assert context.getMetaData().getAiLibraryId().isPresent(); + + List matchedEntries = group.getGroupNode().findMatches(context.getDatabase()); + List matchedEntryIdentifiers = FullBibEntry.fromSeveral(context, matchedEntries); + + entries.set(FXCollections.observableArrayList(matchedEntryIdentifiers)); + + chatHistory.set(chatHistoryCache.getForGroup( + context, + group.getGroupNode() + )); + } + + public ObjectProperty groupNodeProperty() { + return groupNode; + } + + public ObjectProperty databaseContextProperty() { + return databaseContext; + } + + public ObjectProperty stateProperty() { + return state; + } + + public ListProperty entriesProperty() { + return entries; + } + + public ListProperty chatHistoryProperty() { + return chatHistory; + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/chat/AiGroupChatWindow.java b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiGroupChatWindow.java new file mode 100644 index 000000000000..65c0a53187c2 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/chat/AiGroupChatWindow.java @@ -0,0 +1,58 @@ +package org.jabref.gui.ai.chat; + +import java.nio.file.Path; + +import javafx.beans.binding.Bindings; +import javafx.beans.property.ObjectProperty; +import javafx.fxml.FXML; + +import org.jabref.gui.groups.GroupNodeViewModel; +import org.jabref.gui.util.BaseDialog; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.database.BibDatabaseContext; + +import com.airhacks.afterburner.views.ViewLoader; + +public class AiGroupChatWindow extends BaseDialog { + @FXML private AiGroupChatView chatView; + + public AiGroupChatWindow() { + super(); + + ViewLoader.view(this) + .load() + .setAsDialogPane(this); + + this.titleProperty().bind(Bindings.createObjectBinding( + this::makeWindowTitle, + chatView.databaseContextProperty(), + chatView.groupNodeProperty() + )); + } + + private String makeWindowTitle() { + BibDatabaseContext context = chatView.databaseContextProperty().get(); + GroupNodeViewModel group = chatView.groupNodeProperty().get(); + + if (context == null || group == null) { + return ""; + } + + String groupName = group.getGroupNode().getGroup().getName(); + String libraryName = context.getDatabasePath() + .map(Path::getFileName) + .map(Path::toString) + .orElse(Localization.lang("Untitled")); + + // [impl->req~ai.chat.groups.display-names~1] + return Localization.lang("%0 — %1", groupName, libraryName); + } + + public ObjectProperty groupNodeProperty() { + return chatView.groupNodeProperty(); + } + + public ObjectProperty databaseContextProperty() { + return chatView.databaseContextProperty(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/AiChatComponent.java b/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/AiChatComponent.java deleted file mode 100644 index 04b82b63d68c..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/AiChatComponent.java +++ /dev/null @@ -1,482 +0,0 @@ -package org.jabref.gui.ai.components.aichat; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.stream.Stream; - -import javafx.beans.Observable; -import javafx.beans.binding.Bindings; -import javafx.beans.property.StringProperty; -import javafx.collections.FXCollections; -import javafx.collections.ObservableList; -import javafx.fxml.FXML; -import javafx.scene.control.Button; -import javafx.scene.control.Hyperlink; -import javafx.scene.control.Label; -import javafx.scene.control.MenuButton; -import javafx.scene.control.Tooltip; -import javafx.scene.layout.HBox; -import javafx.scene.layout.VBox; -import javafx.scene.paint.Color; - -import org.jabref.gui.DialogService; -import org.jabref.gui.ai.components.aichat.chathistory.ChatHistoryComponent; -import org.jabref.gui.ai.components.aichat.chatprompt.ChatPromptComponent; -import org.jabref.gui.ai.components.util.Loadable; -import org.jabref.gui.ai.components.util.notifications.Notification; -import org.jabref.gui.ai.components.util.notifications.NotificationsComponent; -import org.jabref.gui.icon.IconTheme; -import org.jabref.gui.util.FileDialogConfiguration; -import org.jabref.gui.util.UiTaskExecutor; -import org.jabref.logic.ai.AiExporter; -import org.jabref.logic.ai.AiPreferences; -import org.jabref.logic.ai.AiService; -import org.jabref.logic.ai.chatting.AiChatLogic; -import org.jabref.logic.ai.util.CitationKeyCheck; -import org.jabref.logic.ai.util.ErrorMessage; -import org.jabref.logic.bibtex.FieldPreferences; -import org.jabref.logic.l10n.Localization; -import org.jabref.logic.util.BackgroundTask; -import org.jabref.logic.util.StandardFileType; -import org.jabref.logic.util.TaskExecutor; -import org.jabref.logic.util.io.FileUtil; -import org.jabref.model.database.BibDatabaseContext; -import org.jabref.model.entry.BibEntry; -import org.jabref.model.entry.BibEntryTypesManager; -import org.jabref.model.util.ListUtil; - -import com.airhacks.afterburner.views.ViewLoader; -import com.google.common.annotations.VisibleForTesting; -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 org.controlsfx.control.PopOver; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class AiChatComponent extends VBox { - private static final Logger LOGGER = LoggerFactory.getLogger(AiChatComponent.class); - - // Example Questions - private static final String EXAMPLE_QUESTION_1 = Localization.lang("What is the goal of the paper?"); - private static final String EXAMPLE_QUESTION_2 = Localization.lang("Which methods were used in the research?"); - private static final String EXAMPLE_QUESTION_3 = Localization.lang("What are the key findings?"); - - private final AiService aiService; - private final ObservableList entries; - private final BibDatabaseContext bibDatabaseContext; - private final AiPreferences aiPreferences; - private final DialogService dialogService; - private final TaskExecutor taskExecutor; - private final BibEntryTypesManager entryTypesManager; - private final FieldPreferences fieldPreferences; - - private final AiChatLogic aiChatLogic; - - private final ObservableList notifications = FXCollections.observableArrayList(); - - @FXML private Loadable uiLoadableChatHistory; - @FXML private ChatHistoryComponent uiChatHistory; - @FXML private Button notificationsButton; - @FXML private ChatPromptComponent chatPrompt; - @FXML private Label noticeText; - @FXML private MenuButton exportButton; - @FXML private Hyperlink exQuestion1; - @FXML private Hyperlink exQuestion2; - @FXML private Hyperlink exQuestion3; - @FXML private HBox exQuestionBox; - @FXML private HBox followUpQuestionsBox; - - private String noticeTemplate; - - public AiChatComponent(AiService aiService, - StringProperty name, - ObservableList chatHistory, - ObservableList entries, - BibDatabaseContext bibDatabaseContext, - BibEntryTypesManager entryTypesManager, - AiPreferences aiPreferences, - FieldPreferences fieldPreferences, - DialogService dialogService, - TaskExecutor taskExecutor - ) { - this.aiService = aiService; - this.entries = entries; - this.bibDatabaseContext = bibDatabaseContext; - this.aiPreferences = aiPreferences; - this.entryTypesManager = entryTypesManager; - this.fieldPreferences = fieldPreferences; - this.dialogService = dialogService; - this.taskExecutor = taskExecutor; - - this.aiChatLogic = aiService.getAiChatService().makeChat(name, chatHistory, entries, bibDatabaseContext); - - aiService.getIngestionService().ingest(name, ListUtil.getLinkedFiles(entries).toList(), bibDatabaseContext); - - ViewLoader.view(this) - .root(this) - .load(); - } - - @FXML - public void initialize() { - uiChatHistory.setItems(aiChatLogic.getChatHistory()); - exportButton.disableProperty().bind(Bindings.isEmpty(aiChatLogic.getChatHistory())); - initializeChatPrompt(); - initializeNotice(); - initializeNotifications(); - sendExampleQuestions(); - initializeExampleQuestions(); - initializeFollowUpQuestions(); - } - - private void initializeNotifications() { - ListUtil.getLinkedFiles(entries).forEach(file -> - aiService.getIngestionService().ingest(file, bibDatabaseContext).stateProperty().addListener(obs -> updateNotifications())); - - updateNotifications(); - } - - private void initializeNotice() { - this.noticeTemplate = noticeText.getText(); - - noticeText.textProperty().bind(Bindings.createStringBinding(this::computeNoticeText, noticeDependencies())); - } - - @VisibleForTesting - String computeNoticeText() { - String provider = aiPreferences.getAiProvider().getLabel(); - String model = aiPreferences.getSelectedChatModel(); - return noticeTemplate.replace("%0", provider + " " + model); - } - - private Observable[] noticeDependencies() { - return new Observable[] { - aiPreferences.aiProviderProperty(), - aiPreferences.openAiChatModelProperty(), - aiPreferences.mistralAiChatModelProperty(), - aiPreferences.geminiChatModelProperty(), - aiPreferences.huggingFaceChatModelProperty() - }; - } - - private void initializeExampleQuestions() { - exQuestion1.setText(EXAMPLE_QUESTION_1); - exQuestion2.setText(EXAMPLE_QUESTION_2); - exQuestion3.setText(EXAMPLE_QUESTION_3); - } - - private void sendExampleQuestions() { - addExampleQuestionAction(exQuestion1); - addExampleQuestionAction(exQuestion2); - addExampleQuestionAction(exQuestion3); - } - - private void addExampleQuestionAction(Hyperlink hyperlink) { - if (chatPrompt.getHistory().contains(hyperlink.getText())) { - exQuestionBox.getChildren().remove(hyperlink); - if (exQuestionBox.getChildren().size() == 1) { - this.getChildren().remove(exQuestionBox); - } - return; - } - hyperlink.setOnAction(event -> { - onSendMessage(hyperlink.getText()); - exQuestionBox.getChildren().remove(hyperlink); - if (exQuestionBox.getChildren().size() == 1) { - this.getChildren().remove(exQuestionBox); - } - }); - } - - private void initializeChatPrompt() { - notificationsButton.setOnAction(event -> - new PopOver(new NotificationsComponent(notifications)) - .show(notificationsButton) - ); - - chatPrompt.setSendCallback(this::onSendMessage); - - chatPrompt.setCancelCallback(() -> chatPrompt.switchToNormalState()); - - chatPrompt.setRetryCallback(userMessage -> { - deleteLastMessage(); - deleteLastMessage(); - chatPrompt.switchToNormalState(); - onSendMessage(userMessage); - }); - - chatPrompt.setRegenerateCallback(() -> { - setLoading(true); - Optional lastUserPrompt = Optional.empty(); - if (!aiChatLogic.getChatHistory().isEmpty()) { - lastUserPrompt = getLastUserMessage(); - } - if (lastUserPrompt.isPresent()) { - while (aiChatLogic.getChatHistory().getLast().type() != ChatMessageType.USER) { - deleteLastMessage(); - } - deleteLastMessage(); - chatPrompt.switchToNormalState(); - onSendMessage(lastUserPrompt.get().singleText()); - } - }); - - chatPrompt.requestPromptFocus(); - - updatePromptHistory(); - } - - private void initializeFollowUpQuestions() { - aiChatLogic.getFollowUpQuestions().addListener((javafx.collections.ListChangeListener) change -> { - updateFollowUpQuestions(); - }); - } - - private void updateFollowUpQuestions() { - List questions = new ArrayList<>(aiChatLogic.getFollowUpQuestions()); - - UiTaskExecutor.runInJavaFXThread(() -> { - followUpQuestionsBox.getChildren().removeIf(node -> node instanceof Hyperlink); - - if (questions.isEmpty()) { - followUpQuestionsBox.setVisible(false); - followUpQuestionsBox.setManaged(false); - exQuestionBox.setVisible(true); - exQuestionBox.setManaged(true); - } else { - followUpQuestionsBox.setVisible(true); - followUpQuestionsBox.setManaged(true); - exQuestionBox.setVisible(false); - exQuestionBox.setManaged(false); - - for (String question : questions) { - Hyperlink link = new Hyperlink(question); - link.getStyleClass().add("exampleQuestionStyle"); - link.setTooltip(new Tooltip(question)); - link.setOnAction(event -> { - onSendMessage(question); - }); - followUpQuestionsBox.getChildren().add(link); - } - } - }); - } - - private void updateNotifications() { - notifications.clear(); - notifications.addAll(entries.stream().map(this::updateNotificationsForEntry).flatMap(List::stream).toList()); - - notificationsButton.setVisible(!notifications.isEmpty()); - notificationsButton.setManaged(!notifications.isEmpty()); - - if (!notifications.isEmpty()) { - UiTaskExecutor.runInJavaFXThread(() -> notificationsButton.setGraphic(IconTheme.JabRefIcons.WARNING.withColor(Color.YELLOW).getGraphicNode())); - } - } - - private List updateNotificationsForEntry(BibEntry entry) { - List notifications = new ArrayList<>(); - - if (entries.size() == 1) { - if (entry.getCitationKey().isEmpty()) { - notifications.add(new Notification( - Localization.lang("No citation key for %0", entry.getAuthorTitleYear()), - Localization.lang("The chat history will not be stored in next sessions") - )); - } else if (!CitationKeyCheck.citationKeyIsPresentAndUnique(bibDatabaseContext, entry)) { - notifications.add(new Notification( - Localization.lang("Invalid citation key for %0 (%1)", entry.getCitationKey().get(), entry.getAuthorTitleYear()), - Localization.lang("The chat history will not be stored in next sessions") - )); - } - } - - entry.getFiles().forEach(file -> { - if (!FileUtil.isPDFFile(Path.of(file.getLink()))) { - notifications.add(new Notification( - Localization.lang("File %0 is not a PDF file", file.getLink()), - Localization.lang("Only PDF files can be used for chatting") - )); - } - }); - - entry.getFiles().stream().map(file -> aiService.getIngestionService().ingest(file, bibDatabaseContext)).forEach(ingestionStatus -> { - switch (ingestionStatus.getState()) { - case PROCESSING -> - notifications.add(new Notification( - Localization.lang("File %0 is currently being processed", ingestionStatus.getObject().getLink()), - Localization.lang("After the file is ingested, you will be able to chat with it.") - )); - - case ERROR -> { - assert ingestionStatus.getException().isPresent(); // When the state is ERROR, the exception must be present. - - notifications.add(new Notification( - Localization.lang("File %0 could not be ingested", ingestionStatus.getObject().getLink()), - ingestionStatus.getException().get().getLocalizedMessage() - )); - } - - case SUCCESS -> { - } - } - }); - - return notifications; - } - - private void onSendMessage(String userPrompt) { - aiChatLogic.getFollowUpQuestions().clear(); - - UiTaskExecutor.runInJavaFXThread(() -> { - exQuestionBox.setVisible(false); - exQuestionBox.setManaged(false); - }); - - UserMessage userMessage = new UserMessage(userPrompt); - updatePromptHistory(); - setLoading(true); - - BackgroundTask task = - BackgroundTask - .wrap(() -> aiChatLogic.execute(userMessage)) - .showToUser(true) - .onSuccess(aiMessage -> { - setLoading(false); - chatPrompt.requestPromptFocus(); - }) - .onFailure(e -> { - LOGGER.error("Got an error while sending a message to AI", e); - setLoading(false); - - // Typically, if user has entered an invalid API base URL, we get either "401 - null" or "404 - null" strings. - // Since there might be other strings returned from other API endpoints, we use startsWith() here. - if (e.getMessage().startsWith("404") || e.getMessage().startsWith("401")) { - addError(Localization.lang("API base URL setting appears to be incorrect. Please check it in AI expert settings.")); - } else { - addError(e.getMessage()); - } - - chatPrompt.switchToErrorState(userPrompt); - }); - - task.titleProperty().set(Localization.lang("Waiting for AI reply...")); - - task.executeWith(taskExecutor); - } - - private void addError(String error) { - ErrorMessage chatMessage = new ErrorMessage(error); - aiChatLogic.getChatHistory().add(chatMessage); - } - - private void updatePromptHistory() { - chatPrompt.getHistory().clear(); - chatPrompt.getHistory().addAll(getReversedUserMessagesStream().map(UserMessage::singleText).toList()); - } - - private Stream getReversedUserMessagesStream() { - return aiChatLogic - .getChatHistory() - .reversed() - .stream() - .filter(message -> message instanceof UserMessage) - .map(UserMessage.class::cast); - } - - private void setLoading(boolean loading) { - uiLoadableChatHistory.setLoading(loading); - chatPrompt.setDisableToButtons(loading); - } - - @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) { - aiChatLogic.getChatHistory().clear(); - } - } - - private void deleteLastMessage() { - if (!aiChatLogic.getChatHistory().isEmpty()) { - int index = aiChatLogic.getChatHistory().size() - 1; - aiChatLogic.getChatHistory().remove(index); - } - } - - private Optional getLastUserMessage() { - int messageIndex = aiChatLogic.getChatHistory().size() - 1; - while (messageIndex >= 0) { - ChatMessage chat = aiChatLogic.getChatHistory().get(messageIndex); - if (chat.type() == ChatMessageType.USER) { - return Optional.of((UserMessage) chat); - } - messageIndex--; - } - return Optional.empty(); - } - - @FXML - private void exportMarkdown() { - assert !entries.isEmpty(); - - FileDialogConfiguration fileDialogConfiguration = new FileDialogConfiguration.Builder() - .addExtensionFilter(StandardFileType.MARKDOWN) - .withDefaultExtension(StandardFileType.MARKDOWN) - .withInitialDirectory(Path.of(System.getProperty("user.home"))) - .build(); - - dialogService.showFileSaveDialog(fileDialogConfiguration) - .ifPresent(path -> { - try { - AiExporter exporter = new AiExporter(entries, entryTypesManager, fieldPreferences); - String content = exporter.buildMarkdownForChat(aiChatLogic.getChatHistory()); - Files.writeString(path, content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - dialogService.notify(Localization.lang("Export operation finished successfully.")); - } catch (IOException e) { - LOGGER.error("Problem occurred while writing the export file", e); - dialogService.showErrorDialogAndWait(Localization.lang("Problem occurred while writing the export file"), e); - } - }); - } - - @FXML - private void exportJson() { - assert !entries.isEmpty(); - - FileDialogConfiguration fileDialogConfiguration = new FileDialogConfiguration.Builder() - .addExtensionFilter(StandardFileType.JSON) - .withDefaultExtension(StandardFileType.JSON) - .withInitialDirectory(Path.of(System.getProperty("user.home"))) - .build(); - - dialogService.showFileSaveDialog(fileDialogConfiguration) - .ifPresent(path -> { - try { - AiExporter exporter = new AiExporter(entries, entryTypesManager, fieldPreferences); - String jsonString = exporter.buildJsonExport( - aiPreferences.getAiProvider().getLabel(), - aiPreferences.getSelectedChatModel(), - java.time.LocalDateTime.now().toString(), - aiChatLogic.getChatHistory() - ); - Files.writeString(path, jsonString, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - dialogService.notify(Localization.lang("Export operation finished successfully.")); - } catch (IOException e) { - LOGGER.error("Problem occurred while writing the export file", e); - dialogService.showErrorDialogAndWait(Localization.lang("Problem occurred while writing the export file"), e); - } - }); - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/AiChatGuardedComponent.java b/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/AiChatGuardedComponent.java deleted file mode 100644 index 91c2030837b2..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/AiChatGuardedComponent.java +++ /dev/null @@ -1,84 +0,0 @@ -package org.jabref.gui.ai.components.aichat; - -import javafx.beans.property.StringProperty; -import javafx.collections.ObservableList; -import javafx.scene.Node; - -import org.jabref.gui.DialogService; -import org.jabref.gui.ai.components.util.EmbeddingModelGuardedComponent; -import org.jabref.gui.entryeditor.AdaptVisibleTabs; -import org.jabref.gui.frame.ExternalApplicationsPreferences; -import org.jabref.logic.ai.AiPreferences; -import org.jabref.logic.ai.AiService; -import org.jabref.logic.bibtex.FieldPreferences; -import org.jabref.logic.util.TaskExecutor; -import org.jabref.model.database.BibDatabaseContext; -import org.jabref.model.entry.BibEntry; -import org.jabref.model.entry.BibEntryTypesManager; - -import dev.langchain4j.data.message.ChatMessage; - -/// Main class for AI chatting. It checks if the AI features are enabled and if the embedding model is properly set up. -public class AiChatGuardedComponent extends EmbeddingModelGuardedComponent { - /// This field is used for two purposes: - /// 1. Logging - /// 2. Title of group chat window - /// Thus, if you use {@link AiChatGuardedComponent} for one entry in {@link org.jabref.gui.entryeditor.EntryEditor}, then you may not localize - /// this parameter. However, for group chat window, you should. - private final StringProperty name; - - private final ObservableList chatHistory; - private final BibDatabaseContext bibDatabaseContext; - private final ObservableList entries; - private final AiService aiService; - private final DialogService dialogService; - private final AiPreferences aiPreferences; - private final TaskExecutor taskExecutor; - private final BibEntryTypesManager entryTypesManager; - private final FieldPreferences fieldPreferences; - - public AiChatGuardedComponent(AiService aiService, - StringProperty name, - ObservableList chatHistory, - BibDatabaseContext bibDatabaseContext, - ObservableList entries, - BibEntryTypesManager entryTypesManager, - AiPreferences aiPreferences, - FieldPreferences fieldPreferences, - ExternalApplicationsPreferences externalApplicationsPreferences, - DialogService dialogService, - AdaptVisibleTabs adaptVisibleTabs, - TaskExecutor taskExecutor - ) { - super(aiService, aiPreferences, externalApplicationsPreferences, dialogService, adaptVisibleTabs); - - this.aiService = aiService; - this.name = name; - this.chatHistory = chatHistory; - this.bibDatabaseContext = bibDatabaseContext; - this.entries = entries; - this.entryTypesManager = entryTypesManager; - this.aiPreferences = aiPreferences; - this.fieldPreferences = fieldPreferences; - this.dialogService = dialogService; - this.taskExecutor = taskExecutor; - - rebuildUi(); - } - - @Override - protected Node showEmbeddingModelGuardedContent() { - return new AiChatComponent( - aiService, - name, - chatHistory, - entries, - bibDatabaseContext, - entryTypesManager, - aiPreferences, - fieldPreferences, - dialogService, - taskExecutor - ); - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/AiChatWindow.java b/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/AiChatWindow.java deleted file mode 100644 index b6ac9c8f7054..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/AiChatWindow.java +++ /dev/null @@ -1,81 +0,0 @@ -package org.jabref.gui.ai.components.aichat; - -import javafx.beans.property.StringProperty; -import javafx.collections.ObservableList; -import javafx.scene.Scene; - -import org.jabref.gui.DialogService; -import org.jabref.gui.entryeditor.AdaptVisibleTabs; -import org.jabref.gui.frame.ExternalApplicationsPreferences; -import org.jabref.gui.util.BaseWindow; -import org.jabref.logic.ai.AiPreferences; -import org.jabref.logic.ai.AiService; -import org.jabref.logic.bibtex.FieldPreferences; -import org.jabref.logic.l10n.Localization; -import org.jabref.logic.util.TaskExecutor; -import org.jabref.model.database.BibDatabaseContext; -import org.jabref.model.entry.BibEntry; -import org.jabref.model.entry.BibEntryTypesManager; - -import dev.langchain4j.data.message.ChatMessage; - -public class AiChatWindow extends BaseWindow { - private final BibEntryTypesManager entryTypesManager; - private final AiPreferences aiPreferences; - private final FieldPreferences fieldPreferences; - private final ExternalApplicationsPreferences externalApplicationsPreferences; - private final AiService aiService; - private final DialogService dialogService; - private final AdaptVisibleTabs adaptVisibleTabs; - private final TaskExecutor taskExecutor; - // This field is used for finding an existing AI chat window when user wants to chat with the same group again. - private String chatName; - - public AiChatWindow(BibEntryTypesManager entryTypesManager, - AiPreferences aiPreferences, - FieldPreferences fieldPreferences, - ExternalApplicationsPreferences externalApplicationsPreferences, - AiService aiService, - DialogService dialogService, - AdaptVisibleTabs adaptVisibleTabs, - TaskExecutor taskExecutor - ) { - this.entryTypesManager = entryTypesManager; - this.aiPreferences = aiPreferences; - this.fieldPreferences = fieldPreferences; - this.externalApplicationsPreferences = externalApplicationsPreferences; - this.aiService = aiService; - this.dialogService = dialogService; - this.adaptVisibleTabs = adaptVisibleTabs; - this.taskExecutor = taskExecutor; - } - - public void setChat(StringProperty name, ObservableList chatHistory, BibDatabaseContext bibDatabaseContext, ObservableList entries) { - setTitle(Localization.lang("AI chat with %0", name.getValue())); - chatName = name.getValue(); - setScene( - new Scene( - new AiChatGuardedComponent( - aiService, - name, - chatHistory, - bibDatabaseContext, - entries, - entryTypesManager, - aiPreferences, - fieldPreferences, - externalApplicationsPreferences, - dialogService, - adaptVisibleTabs, - taskExecutor - ), - 800, - 600 - ) - ); - } - - public String getChatName() { - return chatName; - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/chathistory/ChatHistoryComponent.java b/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/chathistory/ChatHistoryComponent.java deleted file mode 100644 index 198bdd553033..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/chathistory/ChatHistoryComponent.java +++ /dev/null @@ -1,51 +0,0 @@ -package org.jabref.gui.ai.components.aichat.chathistory; - -import javafx.collections.ListChangeListener; -import javafx.collections.ObservableList; -import javafx.fxml.FXML; -import javafx.scene.control.ScrollPane; -import javafx.scene.layout.VBox; - -import org.jabref.gui.ai.components.aichat.chatmessage.ChatMessageComponent; -import org.jabref.gui.util.UiTaskExecutor; - -import com.airhacks.afterburner.views.ViewLoader; -import dev.langchain4j.data.message.ChatMessage; - -public class ChatHistoryComponent extends ScrollPane { - @FXML private VBox vBox; - - public ChatHistoryComponent() { - ViewLoader.view(this) - .root(this) - .load(); - - this.needsLayoutProperty().addListener((obs, oldValue, newValue) -> { - if (newValue) { - scrollDown(); - } - }); - } - - /// @implNote You must call this method only once. - public void setItems(ObservableList items) { - fill(items); - items.addListener((ListChangeListener) obs -> fill(items)); - } - - private void fill(ObservableList items) { - UiTaskExecutor.runInJavaFXThread(() -> { - vBox.getChildren().clear(); - items.forEach(chatMessage -> - vBox.getChildren().add(new ChatMessageComponent(chatMessage, chatMessageComponent -> { - int index = vBox.getChildren().indexOf(chatMessageComponent); - items.remove(index); - }))); - }); - } - - public void scrollDown() { - this.layout(); - this.setVvalue(this.getVmax()); - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/chatmessage/ChatMessageComponent.java b/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/chatmessage/ChatMessageComponent.java deleted file mode 100644 index bd90c30dbdbf..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/chatmessage/ChatMessageComponent.java +++ /dev/null @@ -1,168 +0,0 @@ -package org.jabref.gui.ai.components.aichat.chatmessage; - -import java.util.function.Consumer; - -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleObjectProperty; -import javafx.fxml.FXML; -import javafx.geometry.NodeOrientation; -import javafx.geometry.Pos; -import javafx.scene.control.ContextMenu; -import javafx.scene.control.Label; -import javafx.scene.control.MenuItem; -import javafx.scene.input.MouseEvent; -import javafx.scene.layout.HBox; -import javafx.scene.layout.Pane; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; - -import org.jabref.gui.clipboard.ClipBoardManager; -import org.jabref.gui.util.MarkdownTextFlow; -import org.jabref.logic.ai.util.ChatMessageUtils; -import org.jabref.logic.ai.util.ErrorMessage; -import org.jabref.logic.l10n.Localization; - -import com.airhacks.afterburner.views.ViewLoader; -import dev.langchain4j.data.message.AiMessage; -import dev.langchain4j.data.message.ChatMessage; -import dev.langchain4j.data.message.UserMessage; -import jakarta.inject.Inject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class ChatMessageComponent extends HBox { - private static final Logger LOGGER = LoggerFactory.getLogger(ChatMessageComponent.class); - - private final ObjectProperty chatMessage = new SimpleObjectProperty<>(); - private final ObjectProperty> onDelete = new SimpleObjectProperty<>(); - - @FXML private HBox wrapperHBox; - @FXML private VBox vBox; - @FXML private Label sourceLabel; - @FXML private Pane markdownContentPane; - @FXML private VBox buttonsVBox; - - private final MarkdownTextFlow markdownTextFlow; - @Inject private ClipBoardManager clipBoardManager; - - public ChatMessageComponent() { - ViewLoader.view(this) - .root(this) - .load(); - - chatMessage.addListener((_, _, newValue) -> { - if (newValue != null) { - loadChatMessage(); - } - }); - - markdownTextFlow = new MarkdownTextFlow(markdownContentPane); - markdownContentPane.getChildren().add(markdownTextFlow); - markdownContentPane.minHeightProperty().bind(markdownTextFlow.heightProperty()); - markdownContentPane.prefHeightProperty().bind(markdownTextFlow.heightProperty()); - setupContextMenu(); - } - - public ChatMessageComponent(ChatMessage chatMessage, Consumer onDeleteCallback) { - this(); - setChatMessage(chatMessage); - setOnDelete(onDeleteCallback); - } - - private void setupContextMenu() { - ContextMenu contextMenu = new ContextMenu(); - MenuItem copyItem = new MenuItem(Localization.lang("Copy")); - contextMenu.getItems().add(copyItem); - - // 1. Capture and LOCK the selection state - markdownContentPane.addEventFilter(MouseEvent.MOUSE_PRESSED, event -> { - if (event.isSecondaryButtonDown() && markdownTextFlow.isSelectionActive()) { - // Consume the event to prevent JavaFX from clearing the selection highlight - event.consume(); - // Manually trigger the context menu since we consumed the event that usually triggers it - contextMenu.show(markdownContentPane, event.getScreenX(), event.getScreenY()); - } - }); - - copyItem.setOnAction(_ -> { - if (markdownTextFlow.isSelectionActive()) { - markdownTextFlow.copySelectedText(); - } else { - copyFullMessage(); - } - }); - - markdownContentPane.setOnContextMenuRequested(event -> { - if (!markdownTextFlow.isSelectionActive()) { - contextMenu.show(markdownContentPane, event.getScreenX(), event.getScreenY()); - } - }); - } - - public void setChatMessage(ChatMessage chatMessage) { - this.chatMessage.set(chatMessage); - } - - public ChatMessage getChatMessage() { - return chatMessage.get(); - } - - public void setOnDelete(Consumer onDeleteCallback) { - this.onDelete.set(onDeleteCallback); - } - - private void loadChatMessage() { - switch (chatMessage.get()) { - case UserMessage userMessage -> { - setColor("-jr-ai-message-user", "-jr-ai-message-user-border"); - setNodeOrientation(NodeOrientation.RIGHT_TO_LEFT); - wrapperHBox.setAlignment(Pos.TOP_RIGHT); - sourceLabel.setText(Localization.lang("User")); - markdownTextFlow.setMarkdown(userMessage.singleText()); - } - - case AiMessage aiMessage -> { - setColor("-jr-ai-message-ai", "-jr-ai-message-ai-border"); - setNodeOrientation(NodeOrientation.LEFT_TO_RIGHT); - wrapperHBox.setAlignment(Pos.TOP_LEFT); - sourceLabel.setText(Localization.lang("AI")); - markdownTextFlow.setMarkdown(aiMessage.text()); - } - - case ErrorMessage errorMessage -> { - setColor("-jr-ai-message-error", "-jr-ai-message-error-border"); - setNodeOrientation(NodeOrientation.LEFT_TO_RIGHT); - sourceLabel.setText(Localization.lang("Error")); - markdownTextFlow.setMarkdown(errorMessage.getText()); - } - - default -> - LOGGER.error("ChatMessageComponent supports only user, AI, or error messages, but other type was passed: {}", chatMessage.get().type().name()); - } - } - - @FXML - private void initialize() { - buttonsVBox.visibleProperty().bind(wrapperHBox.hoverProperty()); - HBox.setHgrow(this, Priority.ALWAYS); - } - - @FXML - private void onDeleteClick() { - if (onDelete.get() != null) { - onDelete.get().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;"); - } - - private void copyFullMessage() { - ChatMessageUtils.getContent(chatMessage.get()).ifPresent(content -> { - if (!content.isEmpty()) { - clipBoardManager.setContent(content); - } - }); - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/chatprompt/ChatPromptComponent.java b/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/chatprompt/ChatPromptComponent.java deleted file mode 100644 index 30bcd03a1fc8..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/aichat/chatprompt/ChatPromptComponent.java +++ /dev/null @@ -1,208 +0,0 @@ -package org.jabref.gui.ai.components.aichat.chatprompt; - -import java.util.function.Consumer; - -import javafx.application.Platform; -import javafx.beans.property.BooleanProperty; -import javafx.beans.property.IntegerProperty; -import javafx.beans.property.ListProperty; -import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleBooleanProperty; -import javafx.beans.property.SimpleIntegerProperty; -import javafx.beans.property.SimpleListProperty; -import javafx.beans.property.SimpleObjectProperty; -import javafx.collections.FXCollections; -import javafx.fxml.FXML; -import javafx.scene.control.Button; -import javafx.scene.input.KeyCode; -import javafx.scene.layout.HBox; - -import org.jabref.logic.l10n.Localization; - -import com.airhacks.afterburner.views.ViewLoader; -import com.dlsc.gemsfx.ExpandingTextArea; - -public class ChatPromptComponent extends HBox { - // If current message that user is typing in prompt is non-existent, new, or empty, then we use - // this value in currentUserMessageScroll. - private static final int NEW_NON_EXISTENT_MESSAGE = -1; - - private final ObjectProperty> sendCallback = new SimpleObjectProperty<>(); - private final ObjectProperty> retryCallback = new SimpleObjectProperty<>(); - private final ObjectProperty cancelCallback = new SimpleObjectProperty<>(); - private final ObjectProperty regenerateCallback = new SimpleObjectProperty<>(); - - private final ListProperty history = new SimpleListProperty<>(FXCollections.observableArrayList()); - - // This property stores index of a user history message. - // When user scrolls history in the prompt, this value is updated. - // Whenever user edits the prompt, this value is reset to NEW_NON_EXISTENT_MESSAGE. - private final IntegerProperty currentUserMessageScroll = new SimpleIntegerProperty(NEW_NON_EXISTENT_MESSAGE); - - // If the current content of the prompt is a history message, then this property is true. - // If user begins to edit or type a new text, then this property is false. - private final BooleanProperty showingHistoryMessage = new SimpleBooleanProperty(false); - - @FXML private ExpandingTextArea userPromptTextArea; - @FXML private Button submitButton; - @FXML private Button regenerateButton; - - public ChatPromptComponent() { - ViewLoader.view(this) - .root(this) - .load(); - - history.addListener((observable, oldValue, newValue) -> { - currentUserMessageScroll.set(NEW_NON_EXISTENT_MESSAGE); - showingHistoryMessage.set(false); - }); - } - - public void setSendCallback(Consumer sendCallback) { - this.sendCallback.set(sendCallback); - } - - public void setRetryCallback(Consumer retryCallback) { - this.retryCallback.set(retryCallback); - } - - public void setCancelCallback(Runnable cancelCallback) { - this.cancelCallback.set(cancelCallback); - } - - public void setRegenerateCallback(Runnable regenerateCallback) { - this.regenerateCallback.set(regenerateCallback); - } - - public ListProperty getHistory() { - return history; - } - - @FXML - private void initialize() { - userPromptTextArea.setOnKeyPressed(keyEvent -> { - if (keyEvent.getCode() == KeyCode.DOWN) { - // Do not go down in the history. - if (currentUserMessageScroll.get() != NEW_NON_EXISTENT_MESSAGE) { - showingHistoryMessage.set(true); - currentUserMessageScroll.set(currentUserMessageScroll.get() - 1); - - // There could be two effects after setting the properties: - // 1) User scrolls to a recent message, then we should properly update the prompt text. - // 2) Scroll is set to -1 (which is NEW_NON_EXISTENT_MESSAGE) and we should clear the prompt text. - // On the second event currentUserMessageScroll will be set to -1 and showingHistoryMessage - // will be true (this is important). - } - } else if (keyEvent.getCode() == KeyCode.UP) { - // [impl->req~ai.chat.new-message-based-on-previous~1] - if ((currentUserMessageScroll.get() < history.get().size() - 1) && (userPromptTextArea.getText().isEmpty() || showingHistoryMessage.get())) { - // 1. We should not go up the maximum number of user messages. - // 2. We can scroll history only on two conditions: - // 1) The prompt is empty. - // 2) User has already been scrolling the history. - showingHistoryMessage.set(true); - currentUserMessageScroll.set(currentUserMessageScroll.get() + 1); - } - } else { - // Cursor left/right should not stop history scrolling - if (keyEvent.getCode() != KeyCode.RIGHT && keyEvent.getCode() != KeyCode.LEFT) { - // It is okay to go back and forth in the prompt while showing a history message. - // But if user begins doing something else, we should not track the history and reset - // all the properties. - showingHistoryMessage.set(false); - currentUserMessageScroll.set(NEW_NON_EXISTENT_MESSAGE); - } - - if (keyEvent.getCode() == KeyCode.ENTER) { - if (keyEvent.isControlDown()) { - userPromptTextArea.appendText("\n"); - } else { - onSendMessage(); - } - } - } - }); - - currentUserMessageScroll.addListener((observable, oldValue, newValue) -> { - // When currentUserMessageScroll is reset, then its value is - // 1) either to NEW_NON_EXISTENT_MESSAGE, - // 2) or to a new history entry. - if (newValue.intValue() != NEW_NON_EXISTENT_MESSAGE && showingHistoryMessage.get()) { - if (userPromptTextArea.getCaretPosition() == 0 || !userPromptTextArea.getText().contains("\n")) { - // If there are new lines in the prompt, then it is ambiguous whether the user tries to scroll up or down in history or editing lines in the current prompt. - // The easy way to get rid of this ambiguity is to disallow scrolling when there are new lines in the prompt. - // But the exception to this situation is when the caret position is at the beginning of the prompt. - history.get().stream() - .skip(newValue.intValue()) - .findFirst() - .ifPresent(message -> userPromptTextArea.setText(message)); - } - } else { - // When currentUserMessageScroll is set to NEW_NON_EXISTENT_MESSAGE, then we should: - // 1) either clear the prompt, if user scrolls down the most recent history entry. - // 2) do nothing, if user starts to edit the history entry. - // We distinguish these two cases by checking showingHistoryMessage, which is true for -1 message, and false for others. - if (showingHistoryMessage.get()) { - userPromptTextArea.setText(""); - } - } - }); - } - - public void setDisableToButtons(boolean disable) { - this.getChildren().forEach(node -> node.setDisable(disable)); - } - - public void switchToErrorState(String userMessage) { - this.getChildren().clear(); - - Button retryButton = new Button(Localization.lang("Retry")); - - retryButton.setOnAction(event -> { - if (retryCallback.get() != null) { - retryCallback.get().accept(userMessage); - } - }); - - Button cancelButton = new Button(Localization.lang("Cancel")); - - cancelButton.setOnAction(event -> { - if (cancelCallback.get() != null) { - cancelCallback.get().run(); - } - }); - - this.getChildren().add(retryButton); - this.getChildren().add(cancelButton); - } - - public void switchToNormalState() { - this.getChildren().clear(); - this.getChildren().add(userPromptTextArea); - this.getChildren().add(submitButton); - this.getChildren().add(regenerateButton); - requestPromptFocus(); - } - - public void requestPromptFocus() { - // TODO: Check what would happen when programmer calls requestPromptFocus() while the component is in error state. - Platform.runLater(() -> userPromptTextArea.requestFocus()); - } - - @FXML - private void onSendMessage() { - String userPrompt = userPromptTextArea.getText().trim(); - userPromptTextArea.clear(); - - if (!userPrompt.isEmpty() && sendCallback.get() != null) { - sendCallback.get().accept(userPrompt); - } - } - - @FXML - private void onRegenerateMessage() { - if (regenerateCallback.get() != null) { - regenerateCallback.get().run(); - } - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/privacynotice/AiPrivacyNoticeGuardedComponent.java b/jabgui/src/main/java/org/jabref/gui/ai/components/privacynotice/AiPrivacyNoticeGuardedComponent.java deleted file mode 100644 index 2b803b918b4c..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/privacynotice/AiPrivacyNoticeGuardedComponent.java +++ /dev/null @@ -1,45 +0,0 @@ -package org.jabref.gui.ai.components.privacynotice; - -import javafx.scene.Node; - -import org.jabref.gui.DialogService; -import org.jabref.gui.entryeditor.AdaptVisibleTabs; -import org.jabref.gui.frame.ExternalApplicationsPreferences; -import org.jabref.gui.util.DynamicallyChangeableNode; -import org.jabref.logic.ai.AiPreferences; - -/// A class that guards a component, before AI privacy policy is accepted. -/// Remember to call rebuildUi() method after initializing the guarded component. See {@link org.jabref.gui.ai.components.aichat.AiChatGuardedComponent} to look how it works. -public abstract class AiPrivacyNoticeGuardedComponent extends DynamicallyChangeableNode { - private final AiPreferences aiPreferences; - private final ExternalApplicationsPreferences externalApplicationsPreferences; - private final DialogService dialogService; - private final AdaptVisibleTabs adaptVisibleTabs; - - public AiPrivacyNoticeGuardedComponent(AiPreferences aiPreferences, ExternalApplicationsPreferences externalApplicationsPreferences, DialogService dialogService, AdaptVisibleTabs adaptVisibleTabs) { - this.aiPreferences = aiPreferences; - this.externalApplicationsPreferences = externalApplicationsPreferences; - this.dialogService = dialogService; - this.adaptVisibleTabs = adaptVisibleTabs; - - aiPreferences.enableAiProperty().addListener(observable -> rebuildUi()); - } - - public final void rebuildUi() { - if (aiPreferences.getEnableAi()) { - setContent(showPrivacyPolicyGuardedContent()); - } else { - setContent( - new PrivacyNoticeComponent( - aiPreferences, - this::rebuildUi, - externalApplicationsPreferences, - dialogService, - adaptVisibleTabs - ) - ); - } - } - - protected abstract Node showPrivacyPolicyGuardedContent(); -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/privacynotice/PrivacyNoticeComponent.java b/jabgui/src/main/java/org/jabref/gui/ai/components/privacynotice/PrivacyNoticeComponent.java deleted file mode 100644 index e753a28bd641..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/privacynotice/PrivacyNoticeComponent.java +++ /dev/null @@ -1,118 +0,0 @@ -package org.jabref.gui.ai.components.privacynotice; - -import java.io.IOException; - -import javafx.beans.binding.Bindings; -import javafx.beans.binding.DoubleBinding; -import javafx.fxml.FXML; -import javafx.scene.control.Hyperlink; -import javafx.scene.control.Label; -import javafx.scene.control.ScrollPane; -import javafx.scene.layout.GridPane; -import javafx.scene.layout.VBox; -import javafx.scene.text.Text; - -import org.jabref.gui.DialogService; -import org.jabref.gui.desktop.os.NativeDesktop; -import org.jabref.gui.entryeditor.AdaptVisibleTabs; -import org.jabref.gui.entryeditor.EntryEditorPreferences; -import org.jabref.gui.frame.ExternalApplicationsPreferences; -import org.jabref.gui.preferences.GuiPreferences; -import org.jabref.logic.ai.AiPreferences; -import org.jabref.model.ai.AiProvider; - -import com.airhacks.afterburner.views.ViewLoader; -import jakarta.inject.Inject; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class PrivacyNoticeComponent extends ScrollPane { - private final Logger LOGGER = LoggerFactory.getLogger(PrivacyNoticeComponent.class); - - @FXML private VBox text; - @FXML private GridPane aiPolicies; - @FXML private Text embeddingModelText; - - private final AiPreferences aiPreferences; - private final Runnable onIAgreeButtonClickCallback; - private final DialogService dialogService; - private final AdaptVisibleTabs adaptVisibleTabs; - private final ExternalApplicationsPreferences externalApplicationsPreferences; - - @Inject private GuiPreferences preferences; - - public PrivacyNoticeComponent(AiPreferences aiPreferences, Runnable onIAgreeButtonClickCallback, ExternalApplicationsPreferences externalApplicationsPreferences, DialogService dialogService, AdaptVisibleTabs adaptVisibleTabs) { - this.aiPreferences = aiPreferences; - this.onIAgreeButtonClickCallback = onIAgreeButtonClickCallback; - this.externalApplicationsPreferences = externalApplicationsPreferences; - this.dialogService = dialogService; - this.adaptVisibleTabs = adaptVisibleTabs; - - ViewLoader.view(this) - .root(this) - .load(); - } - - @FXML - private void initialize() { - addPrivacyHyperlink(aiPolicies, AiProvider.OPEN_AI); - addPrivacyHyperlink(aiPolicies, AiProvider.MISTRAL_AI); - addPrivacyHyperlink(aiPolicies, AiProvider.GEMINI); - addPrivacyHyperlink(aiPolicies, AiProvider.HUGGING_FACE); - - 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. - - DoubleBinding textWidth = Bindings.subtract(this.widthProperty(), 88d); - text.getChildren().forEach(child -> { - if (child instanceof Text line) { - line.wrappingWidthProperty().bind(textWidth); - } - }); - aiPolicies.prefWidthProperty().bind(textWidth); - embeddingModelText.wrappingWidthProperty().bind(textWidth); - } - - private void addPrivacyHyperlink(GridPane gridPane, AiProvider aiProvider) { - int row = gridPane.getRowCount(); - Label aiName = new Label(aiProvider.getLabel()); - gridPane.add(aiName, 0, row); - - Hyperlink hyperlink = new Hyperlink(aiProvider.getPrivacyPolicyUrl()); - hyperlink.setWrapText(true); - // hyperlink.setFont(aiName.getFont()); - hyperlink.setOnAction(event -> openBrowser(aiProvider.getPrivacyPolicyUrl())); - gridPane.add(hyperlink, 1, row); - } - - @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 { - NativeDesktop.openBrowser(link, externalApplicationsPreferences); - } catch (IOException e) { - LOGGER.error("Error opening the browser to the Privacy Policy page of the AI provider.", e); - dialogService.showErrorDialogAndWait(e); - } - } - - @FXML - private void hideAITabs() { - EntryEditorPreferences entryEditorPreferences = preferences.getEntryEditorPreferences(); - entryEditorPreferences.setShouldShowAiSummaryTab(false); - entryEditorPreferences.setShouldShowAiChatTab(false); - adaptVisibleTabs.adaptVisibleTabs(); - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/summary/SummaryComponent.java b/jabgui/src/main/java/org/jabref/gui/ai/components/summary/SummaryComponent.java deleted file mode 100644 index e7bbabb8489b..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/summary/SummaryComponent.java +++ /dev/null @@ -1,240 +0,0 @@ -package org.jabref.gui.ai.components.summary; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardOpenOption; -import java.util.List; - -import javafx.scene.Node; - -import org.jabref.gui.DialogService; -import org.jabref.gui.ai.components.privacynotice.AiPrivacyNoticeGuardedComponent; -import org.jabref.gui.ai.components.util.errorstate.ErrorStateComponent; -import org.jabref.gui.entryeditor.AdaptVisibleTabs; -import org.jabref.gui.frame.ExternalApplicationsPreferences; -import org.jabref.gui.util.FileDialogConfiguration; -import org.jabref.logic.ai.AiExporter; -import org.jabref.logic.ai.AiPreferences; -import org.jabref.logic.ai.AiService; -import org.jabref.logic.ai.processingstatus.ProcessingInfo; -import org.jabref.logic.ai.summarization.Summary; -import org.jabref.logic.ai.util.CitationKeyCheck; -import org.jabref.logic.bibtex.FieldPreferences; -import org.jabref.logic.citationkeypattern.CitationKeyGenerator; -import org.jabref.logic.citationkeypattern.CitationKeyPatternPreferences; -import org.jabref.logic.l10n.Localization; -import org.jabref.logic.util.StandardFileType; -import org.jabref.logic.util.io.FileUtil; -import org.jabref.model.database.BibDatabaseContext; -import org.jabref.model.entry.BibEntry; -import org.jabref.model.entry.BibEntryTypesManager; -import org.jabref.model.entry.LinkedFile; - -import dev.langchain4j.data.message.AiMessage; -import dev.langchain4j.data.message.ChatMessage; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class SummaryComponent extends AiPrivacyNoticeGuardedComponent { - private static final Logger LOGGER = LoggerFactory.getLogger(SummaryComponent.class); - - private final BibDatabaseContext bibDatabaseContext; - private final BibEntry entry; - private final CitationKeyGenerator citationKeyGenerator; - private final AiService aiService; - private final AiPreferences aiPreferences; - private final DialogService dialogService; - private final BibEntryTypesManager entryTypesManager; - private final FieldPreferences fieldPreferences; - - public SummaryComponent(BibDatabaseContext bibDatabaseContext, - BibEntry entry, - BibEntryTypesManager entryTypesManager, - AiPreferences aiPreferences, - FieldPreferences fieldPreferences, - ExternalApplicationsPreferences externalApplicationsPreferences, - CitationKeyPatternPreferences citationKeyPatternPreferences, - AiService aiService, - DialogService dialogService, - AdaptVisibleTabs adaptVisibleTabs - - ) { - super(aiPreferences, externalApplicationsPreferences, dialogService, adaptVisibleTabs); - - this.bibDatabaseContext = bibDatabaseContext; - this.entry = entry; - this.entryTypesManager = entryTypesManager; - this.aiPreferences = aiPreferences; - this.citationKeyGenerator = new CitationKeyGenerator(bibDatabaseContext, citationKeyPatternPreferences); - this.aiService = aiService; - this.dialogService = dialogService; - this.fieldPreferences = fieldPreferences; - - aiService.getSummariesService().summarize(entry, bibDatabaseContext).stateProperty().addListener(o -> rebuildUi()); - - rebuildUi(); - } - - @Override - protected Node showPrivacyPolicyGuardedContent() { - if (bibDatabaseContext.getDatabasePath().isEmpty()) { - return showErrorNoDatabasePath(); - } else if (entry.getFiles().isEmpty()) { - return showErrorNoFiles(); - } else if (entry.getFiles().stream().map(LinkedFile::getLink).map(Path::of).noneMatch(FileUtil::isPDFFile)) { - return showErrorNotPdfs(); - } else if (!CitationKeyCheck.citationKeyIsPresentAndUnique(bibDatabaseContext, entry)) { - return tryToGenerateCitationKeyThenBind(entry); - } else { - return tryToShowSummary(); - } - } - - private Node showErrorNoDatabasePath() { - return new ErrorStateComponent( - Localization.lang("Unable to generate summary"), - Localization.lang("The path of the current library is not set, but it is required for summarization") - ); - } - - private Node showErrorNotPdfs() { - return new ErrorStateComponent( - Localization.lang("Unable to generate summary"), - Localization.lang("Only PDF files are supported.") - ); - } - - private Node showErrorNoFiles() { - return new ErrorStateComponent( - Localization.lang("Unable to generate summary"), - Localization.lang("Please attach at least one PDF file to enable summarization of PDF file(s).") - ); - } - - private Node tryToGenerateCitationKeyThenBind(BibEntry entry) { - if (citationKeyGenerator.generateAndSetKey(entry).isEmpty()) { - return new ErrorStateComponent( - Localization.lang("Unable to generate summary"), - Localization.lang("Please provide a non-empty and unique citation key for this entry.") - ); - } else { - return showPrivacyPolicyGuardedContent(); - } - } - - private Node tryToShowSummary() { - ProcessingInfo processingInfo = aiService.getSummariesService().summarize(entry, bibDatabaseContext); - - return switch (processingInfo.getState()) { - case SUCCESS -> { - assert processingInfo.getData().isPresent(); // When the state is SUCCESS, the data must be present. - yield showSummary(processingInfo.getData().get()); - } - case ERROR -> - showErrorWhileSummarizing(processingInfo); - case PROCESSING, - STOPPED -> - showErrorNotSummarized(); - }; - } - - private Node showErrorWhileSummarizing(ProcessingInfo processingInfo) { - assert processingInfo.getException().isPresent(); // When the state is ERROR, the exception must be present. - - LOGGER.error("Got an error while generating a summary for entry {}", entry.getCitationKey().orElse(""), processingInfo.getException().get()); - - return ErrorStateComponent.withTextAreaAndButton( - Localization.lang("Unable to chat"), - Localization.lang("Got error while processing the file:"), - processingInfo.getException().get().getLocalizedMessage(), - Localization.lang("Regenerate"), - () -> aiService.getSummariesService().regenerateSummary(entry, bibDatabaseContext) - ); - } - - private Node showErrorNotSummarized() { - return ErrorStateComponent.withSpinner( - Localization.lang("Processing..."), - Localization.lang("The attached file(s) are currently being processed by %0. Once completed, you will be able to see the summary.", aiPreferences.getSelectedChatModel()) - ); - } - - private Node showSummary(Summary summary) { - return new SummaryShowingComponent(summary, () -> { - if (bibDatabaseContext.getDatabasePath().isEmpty()) { - LOGGER.error("Bib database path is not set, but it was expected to be present. Unable to regenerate summary"); - return; - } - - if (entry.getCitationKey().isEmpty()) { - LOGGER.error("Citation key is not set, but it was expected to be present. Unable to regenerate summary"); - return; - } - - aiService.getSummariesService().regenerateSummary(entry, bibDatabaseContext); - // No need to rebuildUi(), because this class listens to the state of ProcessingInfo of the summary. - }, - () -> exportMarkdown(summary), - () -> exportJson(summary)); - } - - private void exportJson(Summary summary) { - if (summary == null) { - dialogService.notify(Localization.lang("No summary available to export")); - return; - } - - FileDialogConfiguration fileDialogConfiguration = new FileDialogConfiguration.Builder() - .addExtensionFilter(StandardFileType.JSON) - .withDefaultExtension(StandardFileType.JSON) - .withInitialDirectory(Path.of(System.getProperty("user.home"))) - .build(); - - dialogService.showFileSaveDialog(fileDialogConfiguration) - .ifPresent(path -> { - try { - List dummyChat = List.of(new AiMessage(summary.content())); - AiExporter exporter = new AiExporter(entry, entryTypesManager, fieldPreferences); - String jsonString = exporter.buildJsonExport( - summary.aiProvider().getLabel(), - summary.model(), - summary.timestamp().toString(), - dummyChat - ); - - Files.writeString(path, jsonString, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - dialogService.notify(Localization.lang("Export operation finished successfully.")); - } catch (IOException e) { - LOGGER.error("Problem occurred while writing the export file", e); - dialogService.showErrorDialogAndWait(Localization.lang("Problem occurred while writing the export file"), e); - } - }); - } - - private void exportMarkdown(Summary summary) { - if (summary == null) { - dialogService.notify(Localization.lang("No summary available to export")); - return; - } - - FileDialogConfiguration fileDialogConfiguration = new FileDialogConfiguration.Builder() - .addExtensionFilter(StandardFileType.MARKDOWN) - .withDefaultExtension(StandardFileType.MARKDOWN) - .withInitialDirectory(Path.of(System.getProperty("user.home"))) - .build(); - - dialogService.showFileSaveDialog(fileDialogConfiguration) - .ifPresent(path -> { - try { - AiExporter exporter = new AiExporter(entry, entryTypesManager, fieldPreferences); - String content = exporter.buildMarkdownExport("AI summary", "Summary", summary.content()); - Files.writeString(path, content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); - dialogService.notify(Localization.lang("Export operation finished successfully.")); - } catch (IOException e) { - LOGGER.error("Problem occurred while writing the export file", e); - dialogService.showErrorDialogAndWait(Localization.lang("Problem occurred while writing the export file"), e); - } - }); - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/summary/SummaryShowingComponent.java b/jabgui/src/main/java/org/jabref/gui/ai/components/summary/SummaryShowingComponent.java deleted file mode 100644 index 909b5350a78d..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/summary/SummaryShowingComponent.java +++ /dev/null @@ -1,103 +0,0 @@ -package org.jabref.gui.ai.components.summary; - -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import java.time.format.FormatStyle; -import java.util.Locale; - -import javafx.fxml.FXML; -import javafx.scene.control.CheckBox; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; -import javafx.scene.text.Text; -import javafx.scene.web.WebView; - -import org.jabref.gui.util.WebViewStore; -import org.jabref.logic.ai.summarization.Summary; -import org.jabref.logic.layout.format.MarkdownFormatter; - -import com.airhacks.afterburner.views.ViewLoader; -import org.jspecify.annotations.NonNull; - -public class SummaryShowingComponent extends VBox { - private static final MarkdownFormatter MARKDOWN_FORMATTER = new MarkdownFormatter(); - @FXML private Text summaryInfoText; - @FXML private CheckBox markdownCheckbox; - - private WebView contentWebView; - private final Summary summary; - private final Runnable regenerateCallback; - private final Runnable exportMarkdownCallback; - private final Runnable exportJsonCallback; - - public SummaryShowingComponent(@NonNull Summary summary, @NonNull Runnable regenerateCallback, @NonNull Runnable exportMarkdownCallback, @NonNull Runnable exportJsonCallback) { - this.summary = summary; - this.regenerateCallback = regenerateCallback; - this.exportMarkdownCallback = exportMarkdownCallback; - this.exportJsonCallback = exportJsonCallback; - - ViewLoader.view(this) - .root(this) - .load(); - } - - @FXML - private void initialize() { - initializeWebView(); - updateContent(true); // Start in Markdown mode - updateInfoText(); - } - - private void initializeWebView() { - contentWebView = WebViewStore.get(); - VBox.setVgrow(contentWebView, Priority.ALWAYS); - - getChildren().addFirst(contentWebView); - } - - private void updateContent(boolean isMarkdown) { - String content = summary.content(); - if (isMarkdown) { - contentWebView.getEngine().loadContent(MARKDOWN_FORMATTER.format(content)); - } else { - contentWebView.getEngine().loadContent( - "" + - "
" + - content + - "
" - ); - } - } - - private void updateInfoText() { - String newInfo = summaryInfoText - .getText() - .replaceAll("%0", formatTimestamp(summary.timestamp())) - .replaceAll("%1", summary.aiProvider().getLabel() + " " + summary.model()); - summaryInfoText.setText(newInfo); - } - - private static String formatTimestamp(LocalDateTime timestamp) { - return timestamp.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM).withLocale(Locale.getDefault())); - } - - @FXML - private void onMarkdownToggle() { - updateContent(markdownCheckbox.isSelected()); - } - - @FXML - private void onRegenerateButtonClick() { - regenerateCallback.run(); - } - - @FXML - private void exportMarkdown() { - exportMarkdownCallback.run(); - } - - @FXML - private void exportJson() { - exportJsonCallback.run(); - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/util/EmbeddingModelGuardedComponent.java b/jabgui/src/main/java/org/jabref/gui/ai/components/util/EmbeddingModelGuardedComponent.java deleted file mode 100644 index 34316fddffa9..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/util/EmbeddingModelGuardedComponent.java +++ /dev/null @@ -1,77 +0,0 @@ -package org.jabref.gui.ai.components.util; - -import javafx.scene.Node; - -import org.jabref.gui.DialogService; -import org.jabref.gui.ai.components.privacynotice.AiPrivacyNoticeGuardedComponent; -import org.jabref.gui.ai.components.util.errorstate.ErrorStateComponent; -import org.jabref.gui.entryeditor.AdaptVisibleTabs; -import org.jabref.gui.frame.ExternalApplicationsPreferences; -import org.jabref.gui.util.UiTaskExecutor; -import org.jabref.logic.ai.AiPreferences; -import org.jabref.logic.ai.AiService; -import org.jabref.logic.ai.ingestion.model.JabRefEmbeddingModel; -import org.jabref.logic.l10n.Localization; - -import com.google.common.eventbus.Subscribe; - -/// Class that has similar logic to {@link AiPrivacyNoticeGuardedComponent}. It extends from it, so that means, -/// if a component needs embedding model, then it should also be guarded with accepting AI privacy policy. -public abstract class EmbeddingModelGuardedComponent extends AiPrivacyNoticeGuardedComponent { - private final AiService aiService; - - public EmbeddingModelGuardedComponent(AiService aiService, - AiPreferences aiPreferences, - ExternalApplicationsPreferences externalApplicationsPreferences, - DialogService dialogService, - AdaptVisibleTabs adaptVisibleTabs - ) { - super(aiPreferences, externalApplicationsPreferences, dialogService, adaptVisibleTabs); - - this.aiService = aiService; - - aiService.getEmbeddingModel().registerListener(this); - } - - protected abstract Node showEmbeddingModelGuardedContent(); - - @Override - protected final Node showPrivacyPolicyGuardedContent() { - if (!aiService.getEmbeddingModel().isPresent()) { - if (aiService.getEmbeddingModel().hadErrorWhileBuildingModel()) { - return showErrorWhileBuildingEmbeddingModel(); - } else { - return showBuildingEmbeddingModel(); - } - } else { - return showEmbeddingModelGuardedContent(); - } - } - - private Node showErrorWhileBuildingEmbeddingModel() { - return ErrorStateComponent.withTextAreaAndButton( - Localization.lang("Unable to chat"), - Localization.lang("An error occurred while building the embedding model"), - aiService.getEmbeddingModel().getErrorWhileBuildingModel(), - Localization.lang("Rebuild"), - () -> aiService.getEmbeddingModel().startRebuildingTask() - ); - } - - public Node showBuildingEmbeddingModel() { - return ErrorStateComponent.withSpinner( - Localization.lang("Downloading..."), - Localization.lang("Downloading embedding model... Afterward, you will be able to chat with your files.") - ); - } - - @Subscribe - public void listen(JabRefEmbeddingModel.EmbeddingModelBuiltEvent event) { - UiTaskExecutor.runInJavaFXThread(EmbeddingModelGuardedComponent.this::rebuildUi); - } - - @Subscribe - public void listen(JabRefEmbeddingModel.EmbeddingModelBuildingErrorEvent event) { - UiTaskExecutor.runInJavaFXThread(EmbeddingModelGuardedComponent.this::rebuildUi); - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/util/Loadable.java b/jabgui/src/main/java/org/jabref/gui/ai/components/util/Loadable.java deleted file mode 100644 index e92b67ad2407..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/util/Loadable.java +++ /dev/null @@ -1,23 +0,0 @@ -package org.jabref.gui.ai.components.util; - -import javafx.scene.control.ProgressIndicator; -import javafx.scene.layout.BorderPane; -import javafx.scene.layout.StackPane; - -public class Loadable extends StackPane { - private boolean isInLoadingState = false; - - public void setLoading(boolean loading) { - if (loading == isInLoadingState) { - return; - } - - if (loading) { - getChildren().add(new BorderPane(new ProgressIndicator())); - } else { - getChildren().removeLast(); - } - - isInLoadingState = loading; - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/util/errorstate/ErrorStateComponent.java b/jabgui/src/main/java/org/jabref/gui/ai/components/util/errorstate/ErrorStateComponent.java deleted file mode 100644 index da640261ef72..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/util/errorstate/ErrorStateComponent.java +++ /dev/null @@ -1,73 +0,0 @@ -package org.jabref.gui.ai.components.util.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/jabgui/src/main/java/org/jabref/gui/ai/components/util/notifications/Notification.java b/jabgui/src/main/java/org/jabref/gui/ai/components/util/notifications/Notification.java deleted file mode 100644 index f37c7d53c6e2..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/util/notifications/Notification.java +++ /dev/null @@ -1,11 +0,0 @@ -package org.jabref.gui.ai.components.util.notifications; - -/// Record that is used to display errors and warnings in the AI chat. If you need global notifications, -/// see {@link org.jabref.gui.DialogService#notify(String)}. -/// -/// This type is used to represent errors for: no files in {@link org.jabref.model.entry.BibEntry}, files are processing, -/// etc. This is made via notifications to support chat with groups: on one hand we need to be able to notify users -/// about possible problems with entries (because that will affect LLM output), but on the other hand the user would -/// like to chat with all available entries in the group, even if some of them are not valid. -public record Notification(String title, String message) { -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/util/notifications/NotificationComponent.java b/jabgui/src/main/java/org/jabref/gui/ai/components/util/notifications/NotificationComponent.java deleted file mode 100644 index 77e0e97617d6..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/util/notifications/NotificationComponent.java +++ /dev/null @@ -1,31 +0,0 @@ -package org.jabref.gui.ai.components.util.notifications; - -import javafx.geometry.Insets; -import javafx.scene.control.Label; -import javafx.scene.layout.VBox; -import javafx.scene.text.Font; - -/// Component used to display {@link Notification} in AI chat. See the documentation of {@link Notification} for more -/// details. -public class NotificationComponent extends VBox { - private final Label title = new Label("Title"); - private final Label message = new Label("Message"); - - public NotificationComponent() { - setSpacing(10); - setPadding(new Insets(10)); - - title.setFont(new Font("System Bold", title.getFont().getSize())); - this.getChildren().addAll(title, message); - } - - public NotificationComponent(Notification notification) { - this(); - setNotification(notification); - } - - public void setNotification(Notification notification) { - title.setText(notification.title()); - message.setText(notification.message()); - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/components/util/notifications/NotificationsComponent.java b/jabgui/src/main/java/org/jabref/gui/ai/components/util/notifications/NotificationsComponent.java deleted file mode 100644 index bae1f9131336..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/ai/components/util/notifications/NotificationsComponent.java +++ /dev/null @@ -1,29 +0,0 @@ -package org.jabref.gui.ai.components.util.notifications; - -import java.util.List; - -import javafx.collections.ListChangeListener; -import javafx.collections.ObservableList; -import javafx.scene.control.ScrollPane; -import javafx.scene.layout.VBox; - -/// A {@link ScrollPane} for displaying AI chat {@link Notification}s. See the documentation of {@link Notification} for -/// more details. -public class NotificationsComponent extends ScrollPane { - private static final double SCROLL_PANE_MAX_HEIGHT = 300; - - private final VBox vBox = new VBox(10); - - public NotificationsComponent(ObservableList notifications) { - setContent(vBox); - setMaxHeight(SCROLL_PANE_MAX_HEIGHT); - - fill(notifications); - notifications.addListener((ListChangeListener) change -> fill(notifications)); - } - - private void fill(List notifications) { - vBox.getChildren().clear(); - notifications.stream().map(NotificationComponent::new).forEach(vBox.getChildren()::add); - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/statuspane/UniversalStatusPaneView.java b/jabgui/src/main/java/org/jabref/gui/ai/statuspane/UniversalStatusPaneView.java new file mode 100644 index 000000000000..10742133116a --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/statuspane/UniversalStatusPaneView.java @@ -0,0 +1,236 @@ +package org.jabref.gui.ai.statuspane; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.StringProperty; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +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.HBox; + +import com.airhacks.afterburner.views.ViewLoader; + +/// A handy component to display a state of a system: whether there is some important information, error, or action to take. +/// +/// Look at the [org.jabref.gui.ai.summary.AiSummaryView] for an example of usage. +public class UniversalStatusPaneView extends BorderPane { + @FXML private Label titleLabel; + @FXML private Label descriptionLabel; + @FXML private TextArea textArea; + @FXML private ProgressIndicator spinner; + @FXML private HBox buttonsBox; + @FXML private Button button1; + @FXML private Button button2; + + // NOTE: Needed to construct the view model in a field in order for localization tests to work. + private final UniversalStatusPaneViewModel viewModel = new UniversalStatusPaneViewModel(); + + public UniversalStatusPaneView() { + ViewLoader.view(this) + .root(this) + .load(); + } + + @FXML + private void initialize() { + setupBindings(); + } + + private void setupBindings() { + titleLabel.managedProperty().bind(titleLabel.visibleProperty()); + descriptionLabel.managedProperty().bind(descriptionLabel.visibleProperty()); + textArea.managedProperty().bind(textArea.visibleProperty()); + spinner.managedProperty().bind(spinner.visibleProperty()); + buttonsBox.managedProperty().bind(buttonsBox.visibleProperty()); + button1.managedProperty().bind(button1.visibleProperty()); + button2.managedProperty().bind(button2.visibleProperty()); + + titleLabel.visibleProperty().bind(viewModel.titleProperty().isNotEmpty()); + descriptionLabel.visibleProperty().bind(viewModel.descriptionProperty().isNotEmpty()); + + textArea.visibleProperty().bind(viewModel.showTextAreaProperty()); + spinner.visibleProperty().bind(viewModel.showSpinnerProperty()); + button1.visibleProperty().bind(viewModel.showButton1Property()); + button2.visibleProperty().bind(viewModel.showButton2Property()); + + buttonsBox.visibleProperty().bind(viewModel.showButton1Property().or(viewModel.showButton2Property())); + + titleLabel.textProperty().bind(viewModel.titleProperty()); + descriptionLabel.textProperty().bind(viewModel.descriptionProperty()); + textArea.textProperty().bind(viewModel.textAreaContentProperty()); + button1.textProperty().bind(viewModel.button1TextProperty()); + button2.textProperty().bind(viewModel.button2TextProperty()); + } + + @FXML + private void onButton1Click() { + viewModel.executeButton1Action(); + } + + @FXML + private void onButton2Click() { + viewModel.executeButton2Action(); + } + + public StringProperty titleProperty() { + return viewModel.titleProperty(); + } + + public String getTitle() { + return viewModel.titleProperty().get(); + } + + public void setTitle(String title) { + viewModel.titleProperty().set(title); + } + + public StringProperty descriptionProperty() { + return viewModel.descriptionProperty(); + } + + public String getDescription() { + return viewModel.descriptionProperty().get(); + } + + public void setDescription(String description) { + viewModel.descriptionProperty().set(description); + } + + public BooleanProperty showTextAreaProperty() { + return viewModel.showTextAreaProperty(); + } + + public boolean isShowTextArea() { + return viewModel.showTextAreaProperty().get(); + } + + public void setShowTextArea(boolean showTextArea) { + viewModel.showTextAreaProperty().set(showTextArea); + } + + public StringProperty textAreaContentProperty() { + return viewModel.textAreaContentProperty(); + } + + public String getTextAreaContent() { + return viewModel.textAreaContentProperty().get(); + } + + public void setTextAreaContent(String textAreaContent) { + viewModel.textAreaContentProperty().set(textAreaContent); + } + + public BooleanProperty showSpinnerProperty() { + return viewModel.showSpinnerProperty(); + } + + public boolean isShowSpinner() { + return viewModel.showSpinnerProperty().get(); + } + + public void setShowSpinner(boolean showSpinner) { + viewModel.showSpinnerProperty().set(showSpinner); + } + + public BooleanProperty showButton1Property() { + return viewModel.showButton1Property(); + } + + public boolean isShowButton1() { + return viewModel.showButton1Property().get(); + } + + public void setShowButton1(boolean showButton1) { + viewModel.showButton1Property().set(showButton1); + } + + public StringProperty button1TextProperty() { + return viewModel.button1TextProperty(); + } + + public String getButton1Text() { + return viewModel.button1TextProperty().get(); + } + + public void setButton1Text(String button1Text) { + viewModel.button1TextProperty().set(button1Text); + } + + public ObjectProperty> button1ActionProperty() { + return viewModel.button1ActionProperty(); + } + + public EventHandler getButton1Action() { + return viewModel.button1ActionProperty().get(); + } + + public void setButton1Action(EventHandler button1Action) { + viewModel.button1ActionProperty().set(button1Action); + } + + public BooleanProperty showButton2Property() { + return viewModel.showButton2Property(); + } + + public boolean isShowButton2() { + return viewModel.showButton2Property().get(); + } + + public void setShowButton2(boolean showButton2) { + viewModel.showButton2Property().set(showButton2); + } + + public StringProperty button2TextProperty() { + return viewModel.button2TextProperty(); + } + + public String getButton2Text() { + return viewModel.button2TextProperty().get(); + } + + public void setButton2Text(String button2Text) { + viewModel.button2TextProperty().set(button2Text); + } + + public ObjectProperty> button2ActionProperty() { + return viewModel.button2ActionProperty(); + } + + public EventHandler getButton2Action() { + return viewModel.button2ActionProperty().get(); + } + + public void setButton2Action(EventHandler button2Action) { + viewModel.button2ActionProperty().set(button2Action); + } + + public ObjectProperty> onButton1ClickProperty() { + return viewModel.button1ActionProperty(); + } + + public EventHandler getOnButton1Click() { + return viewModel.button1ActionProperty().get(); + } + + public void setOnButton1Click(EventHandler onButton1Click) { + viewModel.button1ActionProperty().set(onButton1Click); + } + + public ObjectProperty> onButton2ClickProperty() { + return viewModel.button2ActionProperty(); + } + + public EventHandler getOnButton2Click() { + return viewModel.button2ActionProperty().get(); + } + + public void setOnButton2Click(EventHandler onButton2Click) { + viewModel.button2ActionProperty().set(onButton2Click); + } +} + diff --git a/jabgui/src/main/java/org/jabref/gui/ai/statuspane/UniversalStatusPaneViewModel.java b/jabgui/src/main/java/org/jabref/gui/ai/statuspane/UniversalStatusPaneViewModel.java new file mode 100644 index 000000000000..4814907dfdd7 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/statuspane/UniversalStatusPaneViewModel.java @@ -0,0 +1,84 @@ +package org.jabref.gui.ai.statuspane; + +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.gui.util.BindingsHelper; + +public class UniversalStatusPaneViewModel extends AbstractViewModel { + private final StringProperty title = new SimpleStringProperty(""); + private final StringProperty description = new SimpleStringProperty(""); + + private final BooleanProperty showTextArea = new SimpleBooleanProperty(false); + private final StringProperty textAreaContent = new SimpleStringProperty(""); + + private final BooleanProperty showSpinner = new SimpleBooleanProperty(false); + + private final BooleanProperty showButton1 = new SimpleBooleanProperty(false); + private final StringProperty button1Text = new SimpleStringProperty(""); + private final ObjectProperty> button1Action = new SimpleObjectProperty<>(); + + private final BooleanProperty showButton2 = new SimpleBooleanProperty(false); + private final StringProperty button2Text = new SimpleStringProperty(""); + private final ObjectProperty> button2Action = new SimpleObjectProperty<>(); + + public void executeButton1Action() { + BindingsHelper.handle(button1Action); + } + + public void executeButton2Action() { + BindingsHelper.handle(button2Action); + } + + public StringProperty titleProperty() { + return title; + } + + public StringProperty descriptionProperty() { + return description; + } + + public BooleanProperty showTextAreaProperty() { + return showTextArea; + } + + public StringProperty textAreaContentProperty() { + return textAreaContent; + } + + public BooleanProperty showSpinnerProperty() { + return showSpinner; + } + + public BooleanProperty showButton1Property() { + return showButton1; + } + + public StringProperty button1TextProperty() { + return button1Text; + } + + public ObjectProperty> button1ActionProperty() { + return button1Action; + } + + public BooleanProperty showButton2Property() { + return showButton2; + } + + public StringProperty button2TextProperty() { + return button2Text; + } + + public ObjectProperty> button2ActionProperty() { + return button2Action; + } +} + diff --git a/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryParametersDialog.java b/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryParametersDialog.java new file mode 100644 index 000000000000..74b9036ac31b --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryParametersDialog.java @@ -0,0 +1,32 @@ +package org.jabref.gui.ai.summary; + +import javafx.beans.property.ObjectProperty; +import javafx.fxml.FXML; +import javafx.scene.control.ButtonType; + +import org.jabref.gui.util.BaseDialog; +import org.jabref.logic.ai.summarization.logic.summarizationalgorithms.Summarizator; +import org.jabref.logic.l10n.Localization; + +import com.airhacks.afterburner.views.ViewLoader; + +// [impl->req~ai.expert-settings.summarization-local~1] +public class AiSummaryParametersDialog extends BaseDialog { + @FXML private AiSummaryParametersView aiSummaryParametersView; + + public AiSummaryParametersDialog() { + super(); + + this.setTitle(Localization.lang("Summarization parameters")); + + this.setResultConverter(button -> button == ButtonType.OK); + + ViewLoader.view(this) + .load() + .setAsDialogPane(this); + } + + public ObjectProperty summarizatorProperty() { + return aiSummaryParametersView.summarizatorProperty(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryParametersView.java b/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryParametersView.java new file mode 100644 index 000000000000..17fb7ca416b5 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryParametersView.java @@ -0,0 +1,51 @@ +package org.jabref.gui.ai.summary; + +import javafx.beans.property.ObjectProperty; +import javafx.fxml.FXML; +import javafx.scene.control.ComboBox; +import javafx.scene.layout.VBox; + +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.gui.util.ViewModelListCellFactory; +import org.jabref.logic.ai.summarization.logic.summarizationalgorithms.Summarizator; +import org.jabref.model.ai.summarization.SummarizatorKind; + +import com.airhacks.afterburner.views.ViewLoader; +import jakarta.inject.Inject; + +/// A quick view (that is used as a dialog in [AiSummaryParametersDialog]) for modifying the parameters of the summarization process. +public class AiSummaryParametersView extends VBox { + @FXML private ComboBox summarizatorCombo; + + @Inject private GuiPreferences preferences; + + private AiSummaryParametersViewModel viewModel; + + public AiSummaryParametersView() { + ViewLoader.view(this) + .root(this) + .load(); + } + + @FXML + private void initialize() { + this.viewModel = new AiSummaryParametersViewModel( + preferences.getAiPreferences() + ); + + setupBindings(); + } + + private void setupBindings() { + new ViewModelListCellFactory() + .withText(SummarizatorKind::getDisplayName) + .install(summarizatorCombo); + + summarizatorCombo.itemsProperty().bind(viewModel.summarizatorKindsProperty()); + summarizatorCombo.valueProperty().bindBidirectional(viewModel.summarizatorKindProperty()); + } + + public ObjectProperty summarizatorProperty() { + return viewModel.summarizatorProperty(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryParametersViewModel.java b/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryParametersViewModel.java new file mode 100644 index 000000000000..b280cac3e359 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryParametersViewModel.java @@ -0,0 +1,49 @@ +package org.jabref.gui.ai.summary; + +import javafx.beans.binding.Bindings; +import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.logic.ai.preferences.AiPreferences; +import org.jabref.logic.ai.summarization.logic.summarizationalgorithms.Summarizator; +import org.jabref.logic.ai.summarization.util.SummarizatorFactory; +import org.jabref.model.ai.summarization.SummarizatorKind; + +public class AiSummaryParametersViewModel extends AbstractViewModel { + private final ListProperty summarizatorKinds = new SimpleListProperty<>( + FXCollections.observableArrayList(SummarizatorKind.values()) + ); + private final ObjectProperty summarizatorKind = new SimpleObjectProperty<>(); + + private final ObjectProperty summarizator = new SimpleObjectProperty<>(); + + public AiSummaryParametersViewModel(AiPreferences aiPreferences) { + this.summarizatorKind.set(aiPreferences.getSummarizatorKind()); + + this.summarizator.bind(Bindings.createObjectBinding( + () -> SummarizatorFactory.create( + summarizatorKind.get(), + aiPreferences.getSummarizationChunkSystemMessageTemplate(), + aiPreferences.getSummarizationCombineSystemMessageTemplate(), + aiPreferences.getSummarizationFullDocumentSystemMessageTemplate() + ), + summarizatorKind + )); + } + + public ListProperty summarizatorKindsProperty() { + return summarizatorKinds; + } + + public ObjectProperty summarizatorKindProperty() { + return summarizatorKind; + } + + public ObjectProperty summarizatorProperty() { + return summarizator; + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryShowingView.java b/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryShowingView.java new file mode 100644 index 000000000000..85afaf75bec4 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryShowingView.java @@ -0,0 +1,157 @@ +package org.jabref.gui.ai.summary; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.Locale; + +import javafx.beans.property.ObjectProperty; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.fxml.FXML; +import javafx.scene.control.CheckBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.scene.text.Text; +import javafx.scene.web.WebView; + +import org.jabref.gui.DialogService; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.gui.util.BindingsHelper; +import org.jabref.gui.util.UiTaskExecutor; +import org.jabref.gui.util.WebViewStore; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.util.strings.StringUtil; +import org.jabref.model.ai.identifiers.FullBibEntry; +import org.jabref.model.ai.summarization.AiSummary; +import org.jabref.model.entry.BibEntryTypesManager; + +import com.airhacks.afterburner.views.ViewLoader; +import jakarta.inject.Inject; + +public class AiSummaryShowingView extends VBox { + @FXML private CheckBox markdownCheckbox; + @FXML private Text summaryInfoText; + + private WebView webView; + + private AiSummaryShowingViewModel viewModel; + + @Inject private GuiPreferences preferences; + @Inject private DialogService dialogService; + @Inject private BibEntryTypesManager entryTypesManager; + + public AiSummaryShowingView() { + ViewLoader.view(this) + .root(this) + .load(); + } + + private static String formatSummaryInfo(AiSummary summary) { + if (summary == null) { + return ""; + } + + return Localization.lang("Generated at %0 by %1 (algorithm %2)") + .replaceAll("%0", formatTimestamp(summary.metadata().timestamp())) + .replaceAll("%1", summary.metadata().aiProvider().getDisplayName() + " " + summary.metadata().model()) + .replaceAll("%2", summary.summarizationAlgorithm().getDisplayName()); + } + + private static String formatTimestamp(Instant timestamp) { + return timestamp + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + .withLocale(Locale.getDefault())); + } + + public ObjectProperty summaryProperty() { + return viewModel.summaryProperty(); + } + + public ObjectProperty entryProperty() { + return viewModel.entryProperty(); + } + + @FXML + private void initialize() { + viewModel = new AiSummaryShowingViewModel( + preferences.getAiPreferences(), + preferences.getFieldPreferences(), + entryTypesManager, + dialogService + ); + initializeWebView(); + + setupBindings(); + setupListeners(); + } + + private void initializeWebView() { + webView = WebViewStore.get(); + VBox.setVgrow(webView, Priority.ALWAYS); + + getChildren().addFirst(webView); + } + + private void setupBindings() { + viewModel.isMarkdownProperty().bindBidirectional(markdownCheckbox.selectedProperty()); + + summaryInfoText.textProperty().bind(viewModel.summaryProperty().map(AiSummaryShowingView::formatSummaryInfo)); + } + + private void setupListeners() { + BindingsHelper.listen( + viewModel.webViewSourceProperty(), + value -> UiTaskExecutor.runInJavaFXThread(() -> + webView.getEngine().loadContent(StringUtil.makeSafe(value))) + ); + } + + public ObjectProperty> onRegenerateProperty() { + return viewModel.onRegenerateProperty(); + } + + public EventHandler getOnRegenerate() { + return viewModel.onRegenerateProperty().get(); + } + + public void setOnRegenerate(EventHandler onRegenerate) { + viewModel.onRegenerateProperty().set(onRegenerate); + } + + public ObjectProperty> onRegenerateCustomProperty() { + return viewModel.onRegenerateCustomProperty(); + } + + public EventHandler getOnRegenerateCustom() { + return viewModel.onRegenerateCustomProperty().get(); + } + + public void setOnRegenerateCustom(EventHandler onRegenerateCustom) { + viewModel.onRegenerateCustomProperty().set(onRegenerateCustom); + } + + @FXML + private void regenerate() { + viewModel.regenerate(); + } + + @FXML + private void regenerateCustom() { + viewModel.regenerateCustom(); + } + + // [impl->req~ai.summarization.general.export~1] + @FXML + private void exportMarkdown() { + viewModel.exportMarkdown(); + } + + // [impl->req~ai.summarization.general.export~1] + @FXML + private void exportJson() { + viewModel.exportJson(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryShowingViewModel.java b/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryShowingViewModel.java new file mode 100644 index 000000000000..3cc95f356649 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryShowingViewModel.java @@ -0,0 +1,186 @@ +package org.jabref.gui.ai.summary; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.property.SimpleStringProperty; +import javafx.beans.property.StringProperty; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.gui.DialogService; +import org.jabref.gui.util.BindingsHelper; +import org.jabref.gui.util.FileDialogConfiguration; +import org.jabref.logic.ai.preferences.AiPreferences; +import org.jabref.logic.ai.summarization.exporters.AiSummaryJsonExporter; +import org.jabref.logic.ai.summarization.exporters.AiSummaryMarkdownExporter; +import org.jabref.logic.bibtex.FieldPreferences; +import org.jabref.logic.l10n.Localization; +import org.jabref.logic.layout.format.MarkdownFormatter; +import org.jabref.logic.util.StandardFileType; +import org.jabref.model.ai.AiMetadata; +import org.jabref.model.ai.identifiers.FullBibEntry; +import org.jabref.model.ai.summarization.AiSummary; +import org.jabref.model.entry.BibEntryTypesManager; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AiSummaryShowingViewModel extends AbstractViewModel { + private static final Logger LOGGER = LoggerFactory.getLogger(AiSummaryShowingViewModel.class); + + private static final String HTML_TEMPLATE = + "" + + "
%s
"; + + private static final MarkdownFormatter MARKDOWN_FORMATTER = new MarkdownFormatter(); + + private final ObjectProperty summary = new SimpleObjectProperty<>(); + private final ObjectProperty entry = new SimpleObjectProperty<>(); + private final BooleanProperty isMarkdown = new SimpleBooleanProperty(true); + + private final StringProperty webViewSource = new SimpleStringProperty(""); + + private final ObjectProperty> onRegenerate = new SimpleObjectProperty<>(); + private final ObjectProperty> onRegenerateCustom = new SimpleObjectProperty<>(); + + private final AiPreferences aiPreferences; + private final FieldPreferences fieldPreferences; + private final BibEntryTypesManager entryTypesManager; + private final DialogService dialogService; + + public AiSummaryShowingViewModel( + AiPreferences aiPreferences, + FieldPreferences fieldPreferences, + BibEntryTypesManager entryTypesManager, + DialogService dialogService + ) { + this.aiPreferences = aiPreferences; + this.fieldPreferences = fieldPreferences; + this.entryTypesManager = entryTypesManager; + this.dialogService = dialogService; + + setupBindings(); + } + + private void setupBindings() { + webViewSource.bind(Bindings.createObjectBinding( + this::generateWebSource, + summary, isMarkdown + )); + } + + private String generateWebSource() { + if (summary.get() == null) { + return ""; + } + + String content = summary.get().content(); + + if (isMarkdown.get()) { + return MARKDOWN_FORMATTER.format(content); + } else { + return String.format(HTML_TEMPLATE, content); + } + } + + public void regenerate() { + BindingsHelper.handle(onRegenerate); + } + + public void regenerateCustom() { + BindingsHelper.handle(onRegenerateCustom); + } + + public void exportMarkdown() { + AiSummary currentSummary = summary.get(); + FullBibEntry fullEntry = entry.get(); + + if (currentSummary == null || fullEntry == null) { + dialogService.notify(Localization.lang("No summary available to export")); + return; + } + + FileDialogConfiguration fileDialogConfiguration = new FileDialogConfiguration.Builder() + .addExtensionFilter(StandardFileType.MARKDOWN) + .withDefaultExtension(StandardFileType.MARKDOWN) + .withInitialDirectory(Path.of(System.getProperty("user.home"))) + .build(); + + dialogService.showFileSaveDialog(fileDialogConfiguration) + .ifPresent(path -> { + try { + AiSummaryMarkdownExporter exporter = new AiSummaryMarkdownExporter(entryTypesManager, fieldPreferences, aiPreferences.getMarkdownChatExportTemplate()); + AiMetadata metadata = currentSummary.metadata(); + String content = exporter.export(metadata, fullEntry.entry(), fullEntry.databaseContext().getMode(), currentSummary); + Files.writeString(path, content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + dialogService.notify(Localization.lang("Export operation finished successfully.")); + } catch (IOException e) { + LOGGER.error("Problem occurred while writing the export file", e); + dialogService.showErrorDialogAndWait(Localization.lang("Problem occurred while writing the export file"), e); + } + }); + } + + public void exportJson() { + AiSummary currentSummary = summary.get(); + FullBibEntry fullEntry = entry.get(); + + if (currentSummary == null || fullEntry == null) { + dialogService.notify(Localization.lang("No summary available to export")); + return; + } + + FileDialogConfiguration fileDialogConfiguration = new FileDialogConfiguration.Builder() + .addExtensionFilter(StandardFileType.JSON) + .withDefaultExtension(StandardFileType.JSON) + .withInitialDirectory(Path.of(System.getProperty("user.home"))) + .build(); + + dialogService.showFileSaveDialog(fileDialogConfiguration) + .ifPresent(path -> { + try { + AiSummaryJsonExporter exporter = new AiSummaryJsonExporter(entryTypesManager, fieldPreferences); + AiMetadata metadata = currentSummary.metadata(); + String content = exporter.export(metadata, fullEntry.entry(), fullEntry.databaseContext().getMode(), currentSummary); + Files.writeString(path, content, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING); + dialogService.notify(Localization.lang("Export operation finished successfully.")); + } catch (IOException e) { + LOGGER.error("Problem occurred while writing the export file", e); + dialogService.showErrorDialogAndWait(Localization.lang("Problem occurred while writing the export file"), e); + } + }); + } + + public ObjectProperty summaryProperty() { + return summary; + } + + public ObjectProperty entryProperty() { + return entry; + } + + public BooleanProperty isMarkdownProperty() { + return isMarkdown; + } + + public StringProperty webViewSourceProperty() { + return webViewSource; + } + + public ObjectProperty> onRegenerateProperty() { + return onRegenerate; + } + + public ObjectProperty> onRegenerateCustomProperty() { + return onRegenerateCustom; + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryView.java b/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryView.java new file mode 100644 index 000000000000..0168a8d07e58 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryView.java @@ -0,0 +1,125 @@ +package org.jabref.gui.ai.summary; + +import javafx.beans.binding.Bindings; +import javafx.beans.property.ObjectProperty; +import javafx.fxml.FXML; +import javafx.scene.layout.StackPane; + +import org.jabref.gui.DialogService; +import org.jabref.gui.ai.AiPrivacyNoticeView; +import org.jabref.gui.ai.statuspane.UniversalStatusPaneView; +import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.gui.util.ExceptionsUtil; +import org.jabref.logic.ai.AiService; +import org.jabref.logic.ai.chatting.ChatModel; +import org.jabref.logic.ai.summarization.logic.summarizationalgorithms.Summarizator; +import org.jabref.logic.l10n.Localization; +import org.jabref.model.ai.identifiers.FullBibEntry; +import org.jabref.model.entry.BibEntryTypesManager; + +import com.airhacks.afterburner.views.ViewLoader; +import jakarta.inject.Inject; + +public class AiSummaryView extends StackPane { + @FXML private AiPrivacyNoticeView privacyNotice; + + @FXML private UniversalStatusPaneView processingPane; + @FXML private UniversalStatusPaneView errorPane; + @FXML private UniversalStatusPaneView cancelledPane; + + @FXML private UniversalStatusPaneView noFilesPane; + @FXML private UniversalStatusPaneView noSupportedFileTypesPane; + + @FXML private AiSummaryShowingView summaryShowing; + + @Inject private GuiPreferences preferences; + @Inject private AiService aiService; + @Inject private DialogService dialogService; + @Inject private BibEntryTypesManager entryTypesManager; + + private AiSummaryViewModel viewModel; + + public AiSummaryView() { + ViewLoader.view(this) + .root(this) + .load(); + } + + @FXML + private void initialize() { + viewModel = new AiSummaryViewModel( + preferences.getAiPreferences(), + preferences.getFilePreferences(), + aiService.getSummariesRepository(), + aiService.getSummaryCache(), + aiService.getSummarizationTaskAggregator(), + dialogService + ); + + setupBindings(); + } + + private void setupBindings() { + errorPane.textAreaContentProperty().bind(viewModel.errorProperty().map(ExceptionsUtil::generateExceptionMessage)); + + summaryShowing.summaryProperty().bind(viewModel.summaryProperty()); + summaryShowing.entryProperty().bind(viewModel.entryProperty()); + + processingPane.descriptionProperty().bind(Bindings.createObjectBinding( + this::generateDescription, + viewModel.summarizatorProperty(), viewModel.chatModelProperty() + )); + + privacyNotice.managedProperty().bind(privacyNotice.visibleProperty()); + processingPane.managedProperty().bind(processingPane.visibleProperty()); + errorPane.managedProperty().bind(errorPane.visibleProperty()); + cancelledPane.managedProperty().bind(cancelledPane.visibleProperty()); + noFilesPane.managedProperty().bind(noFilesPane.visibleProperty()); + noSupportedFileTypesPane.managedProperty().bind(noSupportedFileTypesPane.visibleProperty()); + summaryShowing.managedProperty().bind(summaryShowing.visibleProperty()); + + // [pp->feat~ai.summarization.entries~1] + privacyNotice.visibleProperty().bind(viewModel.stateProperty().isEqualTo(AiSummaryViewModel.State.AI_TURNED_OFF)); + processingPane.visibleProperty().bind(viewModel.stateProperty().isEqualTo(AiSummaryViewModel.State.PROCESSING)); + errorPane.visibleProperty().bind(viewModel.stateProperty().isEqualTo(AiSummaryViewModel.State.ERROR_WHILE_GENERATING)); + cancelledPane.visibleProperty().bind(viewModel.stateProperty().isEqualTo(AiSummaryViewModel.State.CANCELLED)); + noFilesPane.visibleProperty().bind(viewModel.stateProperty().isEqualTo(AiSummaryViewModel.State.NO_FILES)); + noSupportedFileTypesPane.visibleProperty().bind(viewModel.stateProperty().isEqualTo(AiSummaryViewModel.State.NO_SUPPORTED_FILE_TYPES)); + summaryShowing.visibleProperty().bind(viewModel.stateProperty().isEqualTo(AiSummaryViewModel.State.DONE)); + } + + private String generateDescription() { + Summarizator summarizator = viewModel.summarizatorProperty().get(); + ChatModel chatModel = viewModel.chatModelProperty().get(); + + if (summarizator == null || chatModel == null) { + return ""; + } + + return Localization.lang( + "Your entry is being summarized by %0 %1 using algorithm %2", + chatModel.getAiProvider().getDisplayName(), + chatModel.getName(), + summarizator.getKind().getDisplayName() + ); + } + + public ObjectProperty entryProperty() { + return viewModel.entryProperty(); + } + + @FXML + private void regenerate() { + viewModel.regenerate(); + } + + @FXML + private void regenerateCustom() { + viewModel.regenerateCustom(); + } + + @FXML + private void cancel() { + viewModel.cancel(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryViewModel.java b/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryViewModel.java new file mode 100644 index 000000000000..34509674c8f3 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/ai/summary/AiSummaryViewModel.java @@ -0,0 +1,366 @@ +package org.jabref.gui.ai.summary; + +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.beans.value.ChangeListener; + +import org.jabref.gui.AbstractViewModel; +import org.jabref.gui.DialogService; +import org.jabref.gui.util.BindingsHelper; +import org.jabref.gui.util.UiTaskExecutor; +import org.jabref.logic.FilePreferences; +import org.jabref.logic.ai.chatting.ChatModel; +import org.jabref.logic.ai.chatting.util.ChatModelFactory; +import org.jabref.logic.ai.ingestion.logic.parsing.UniversalContentParser; +import org.jabref.logic.ai.preferences.AiPreferences; +import org.jabref.logic.ai.summarization.InMemorySummaryCache; +import org.jabref.logic.ai.summarization.SummarizationTaskAggregator; +import org.jabref.logic.ai.summarization.logic.summarizationalgorithms.Summarizator; +import org.jabref.logic.ai.summarization.repositories.SummariesRepository; +import org.jabref.logic.ai.summarization.tasks.GenerateSummaryTask; +import org.jabref.logic.ai.summarization.tasks.GenerateSummaryTaskRequest; +import org.jabref.logic.ai.summarization.util.SummarizatorFactory; +import org.jabref.logic.ai.util.TrackedBackgroundTask; +import org.jabref.logic.util.ObservablesHelper; +import org.jabref.model.ai.identifiers.FullBibEntry; +import org.jabref.model.ai.summarization.AiSummary; +import org.jabref.model.entry.BibEntry; + +import com.tobiasdiez.easybind.EasyBind; +import jakarta.annotation.Nullable; + +public class AiSummaryViewModel extends AbstractViewModel { + public enum State { + AI_TURNED_OFF, + NO_FILES, + NO_SUPPORTED_FILE_TYPES, + PROCESSING, + DONE, + ERROR_WHILE_GENERATING, + READY, + CANCELLED + } + + private final ObjectProperty state = new SimpleObjectProperty<>(State.AI_TURNED_OFF); + private final ObjectProperty error = new SimpleObjectProperty<>(null); + private final ObjectProperty summary = new SimpleObjectProperty<>(); + + private final ObjectProperty entry = new SimpleObjectProperty<>(); + private final ObjectProperty chatModel = new SimpleObjectProperty<>(); + private final ObjectProperty summarizator = new SimpleObjectProperty<>(); + + private final ObjectProperty currentTask = new SimpleObjectProperty<>(); + private final ChangeListener taskStateListener = (_, _, value) -> updateByTaskState(value); + + private final AiPreferences aiPreferences; + private final FilePreferences filePreferences; + private final SummariesRepository summariesRepository; + private final InMemorySummaryCache inMemoryCache; + private final SummarizationTaskAggregator summarizationTaskAggregator; + private final DialogService dialogService; + + public AiSummaryViewModel( + AiPreferences aiPreferences, + FilePreferences filePreferences, + SummariesRepository summariesRepository, + InMemorySummaryCache inMemoryCache, + SummarizationTaskAggregator summarizationTaskAggregator, + DialogService dialogService + ) { + this.aiPreferences = aiPreferences; + this.filePreferences = filePreferences; + this.summariesRepository = summariesRepository; + this.inMemoryCache = inMemoryCache; + this.summarizationTaskAggregator = summarizationTaskAggregator; + this.dialogService = dialogService; + + setupBindings(); + setupListeners(); + } + + private void setupBindings() { + BindingsHelper.bindEnum( + state, + State.READY, + + Map.entry(State.AI_TURNED_OFF, + aiPreferences.enableAiProperty().not() + ), + + Map.entry(State.NO_FILES, + entry.map(FullBibEntry::entry) + .map(BibEntry::getFiles) + .map(List::isEmpty) + ), + + Map.entry(State.NO_SUPPORTED_FILE_TYPES, + entry.map(FullBibEntry::entry) + .map(BibEntry::getFiles) + .map(l -> l.stream() + .map(f -> Path.of(f.getLink())) + .noneMatch(UniversalContentParser::isSupportedFileType)) + ), + + Map.entry(State.DONE, + summary.isNotNull() + ), + + Map.entry(State.CANCELLED, + currentTask.map(TrackedBackgroundTask::getStatus) + .map(s -> s == TrackedBackgroundTask.Status.CANCELLED) + .orElse(false) + ), + + Map.entry(State.ERROR_WHILE_GENERATING, + error.isNotNull() + ), + + Map.entry(State.PROCESSING, + currentTask.isNotNull() + ) + ); + + Function> propertyExtractor = GenerateSummaryTask::statusProperty; + + currentTask.addListener((_, oldVal, newVal) -> { + if (oldVal != null) { + propertyExtractor.apply(oldVal).removeListener(taskStateListener); + } + if (newVal != null) { + propertyExtractor.apply(newVal).addListener(taskStateListener); + } + }); + + this.chatModel.bind(ObservablesHelper.createClosableObjectBinding( + () -> ChatModelFactory.create(aiPreferences), + aiPreferences.getChatProperties() + )); + + setupSummarizatorBinding(); + } + + private void setupSummarizatorBinding() { + summarizator.bind(ObservablesHelper.createObjectBinding( + () -> SummarizatorFactory.create(aiPreferences), + aiPreferences.getSummarizatorProperties() + )); + } + + private void setupListeners() { + EasyBind.subscribe(entry, _ -> prepareForEntry()); + EasyBind.subscribe(entry, this::processEntry); + } + + /// Resets the chat model and summarizator to the default values from AI preferences. + /// Called before generating a summary to ensure default models are used + /// (as opposed to a custom summarizator set by {@link #regenerateCustom()}). + private void setDefaultModels() { + summarizator.unbind(); + setupSummarizatorBinding(); + } + + private void clearTask() { + if (currentTask.get() != null) { + currentTask.set(null); + } + } + + public void regenerate() { + regenerate(getEntry()); + } + + public void regenerateCustom() { + regenerateCustom(getEntry()); + } + + public void generate() { + generate(getEntry()); + } + + public void cancel() { + if (currentTask.get() != null) { + currentTask.get().cancel(); + } + } + + private void prepareForEntry() { + clearTask(); + summary.set(null); + error.set(null); + } + + private void processEntry(FullBibEntry fullEntry) { + if (fullEntry == null || state.get() != State.READY) { + return; + } + + // THe retrieval is done in 4 steps: + // 1. Check the repository if there is a generated entry. + // 2. Check the in-memory cache. + // 3. Check if there is a running task. + // 4. Otherwise, start a new task. + + Optional persistedSummary = fullEntry.toAiSummaryIdentifier() + .flatMap(summariesRepository::get); + if (persistedSummary.isPresent()) { + this.summary.set(persistedSummary.get()); + return; + } + + Optional cachedSummary = inMemoryCache.get(fullEntry.entry()); + if (cachedSummary.isPresent()) { + this.summary.set(cachedSummary.get()); + return; + } + + Optional runningTask = summarizationTaskAggregator.getTask(fullEntry.entry()); + if (runningTask.isPresent()) { + GenerateSummaryTask task = runningTask.get(); + currentTask.set(task); + summarizator.set(task.getRequest().summarizator()); + chatModel.set(task.getRequest().chatModel()); + + switch (task.getStatus()) { + case SUCCESS -> { + summary.set(task.getResult()); + clearTask(); + } + case ERROR -> { + error.set(task.getException()); + clearTask(); + } + default -> { + } + } + return; + } + + generate(); + } + + private void regenerate(FullBibEntry identifier) { + clearSummary(identifier); + generate(identifier); + } + + private void regenerateCustom(FullBibEntry identifier) { + if (identifier == null) { + return; + } + + AiSummaryParametersDialog parametersDialog = new AiSummaryParametersDialog(); + Optional result = dialogService.showCustomDialogAndWait(parametersDialog); + + if (result.isEmpty() || !result.get()) { + return; + } + + @Nullable Summarizator customSummarizator = parametersDialog.summarizatorProperty().get(); + + if (customSummarizator == null) { + return; + } + + summarizator.unbind(); + summarizator.set(customSummarizator); + + clearSummary(identifier); + startSummarization(identifier); + } + + private void generate(FullBibEntry identifier) { + setDefaultModels(); + clearSummary(identifier); + startSummarization(identifier); + } + + public void clearSummary(FullBibEntry fullEntry) { + if (fullEntry == null) { + return; + } + + inMemoryCache.remove(fullEntry.entry()); + + fullEntry.toAiSummaryIdentifier() + .ifPresent(summariesRepository::clear); + + summary.set(null); + } + + private void startSummarization(FullBibEntry fullEntry) { + if (fullEntry == null) { + return; + } + + GenerateSummaryTask task = summarizationTaskAggregator.start( + new GenerateSummaryTaskRequest( + filePreferences, + chatModel.get(), + summarizator.get(), + fullEntry, + true + ) + ); + + currentTask.set(task); + } + + private void updateByTaskState(TrackedBackgroundTask.Status value) { + GenerateSummaryTask task = currentTask.get(); + if (task == null) { + return; + } + + UiTaskExecutor.runInJavaFXThread(() -> { + switch (value) { + case TrackedBackgroundTask.Status.ERROR -> { + error.set(task.getException()); + clearTask(); + } + case TrackedBackgroundTask.Status.SUCCESS -> { + summary.set(task.getResult()); + clearTask(); + } + } + }); + } + + public ObjectProperty entryProperty() { + return entry; + } + + public FullBibEntry getEntry() { + return entry.get(); + } + + public void setEntry(FullBibEntry entry) { + this.entry.set(entry); + } + + public ObjectProperty stateProperty() { + return state; + } + + public ObjectProperty errorProperty() { + return error; + } + + public ObjectProperty summaryProperty() { + return summary; + } + + public ObjectProperty summarizatorProperty() { + return summarizator; + } + + public ObjectProperty chatModelProperty() { + return chatModel; + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java b/jabgui/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java index 8045eee36ac3..4502c70531b7 100644 --- a/jabgui/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java +++ b/jabgui/src/main/java/org/jabref/gui/entryeditor/AiChatTab.java @@ -1,73 +1,31 @@ package org.jabref.gui.entryeditor; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - -import javafx.beans.property.SimpleStringProperty; -import javafx.beans.property.StringProperty; -import javafx.collections.FXCollections; import javafx.scene.control.Tooltip; -import org.jabref.gui.DialogService; import org.jabref.gui.StateManager; -import org.jabref.gui.ai.components.aichat.AiChatGuardedComponent; -import org.jabref.gui.ai.components.privacynotice.PrivacyNoticeComponent; -import org.jabref.gui.ai.components.util.errorstate.ErrorStateComponent; -import org.jabref.gui.frame.ExternalApplicationsPreferences; +import org.jabref.gui.ai.chat.AiEntryChatView; import org.jabref.gui.preferences.GuiPreferences; -import org.jabref.logic.ai.AiPreferences; -import org.jabref.logic.ai.AiService; -import org.jabref.logic.ai.util.CitationKeyCheck; -import org.jabref.logic.bibtex.FieldPreferences; -import org.jabref.logic.citationkeypattern.CitationKeyGenerator; -import org.jabref.logic.citationkeypattern.CitationKeyPatternPreferences; import org.jabref.logic.l10n.Localization; -import org.jabref.logic.util.TaskExecutor; -import org.jabref.logic.util.io.FileUtil; +import org.jabref.model.ai.identifiers.FullBibEntry; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; -import org.jabref.model.entry.BibEntryTypesManager; -import org.jabref.model.entry.LinkedFile; public class AiChatTab extends EntryEditorTab { - private final AiService aiService; - private final DialogService dialogService; - private final AiPreferences aiPreferences; - private final ExternalApplicationsPreferences externalApplicationsPreferences; private final EntryEditorPreferences entryEditorPreferences; private final StateManager stateManager; - private final TaskExecutor taskExecutor; - private final AdaptVisibleTabs adaptVisibleTabs; - private final CitationKeyPatternPreferences citationKeyPatternPreferences; - private final BibEntryTypesManager entryTypesManager; - private final FieldPreferences fieldPreferences; - - private Optional previousBibEntry = Optional.empty(); - public AiChatTab(StateManager stateManager, - BibEntryTypesManager entryTypesManager, - GuiPreferences preferences, - AiService aiService, - DialogService dialogService, - AdaptVisibleTabs adaptVisibleTabs, - TaskExecutor taskExecutor) { + private final AiEntryChatView aiEntryChatView = new AiEntryChatView(); - this.stateManager = stateManager; - this.entryTypesManager = entryTypesManager; + public AiChatTab( + GuiPreferences preferences, + StateManager stateManager + ) { this.entryEditorPreferences = preferences.getEntryEditorPreferences(); - this.citationKeyPatternPreferences = preferences.getCitationKeyPatternPreferences(); - this.aiPreferences = preferences.getAiPreferences(); - this.fieldPreferences = preferences.getFieldPreferences(); - this.externalApplicationsPreferences = preferences.getExternalApplicationsPreferences(); - this.aiService = aiService; - this.dialogService = dialogService; - this.adaptVisibleTabs = adaptVisibleTabs; - this.taskExecutor = taskExecutor; + this.stateManager = stateManager; setText(Localization.lang("AI chat")); setTooltip(new Tooltip(Localization.lang("Chat with AI about content of attached file(s)"))); + setContent(aiEntryChatView); } @Override @@ -78,79 +36,7 @@ public boolean shouldShow(BibEntry entry) { /// @implNote Method similar to {@link AiSummaryTab#bindToEntry(BibEntry)} @Override protected void bindToEntry(BibEntry entry) { - previousBibEntry.ifPresent(previousBibEntry -> aiService.getChatHistoryService().closeChatHistoryForEntry(previousBibEntry)); - previousBibEntry = Optional.of(entry); BibDatabaseContext bibDatabaseContext = stateManager.getActiveDatabase().orElse(new BibDatabaseContext()); - - if (!aiPreferences.getEnableAi()) { - showPrivacyNotice(entry); - } else if (entry.getFiles().isEmpty()) { - showErrorNoFiles(); - } else if (entry.getFiles().stream().map(LinkedFile::getLink).map(Path::of).noneMatch(FileUtil::isPDFFile)) { - showErrorNotPdfs(); - } else if (!CitationKeyCheck.citationKeyIsPresentAndUnique(bibDatabaseContext, entry)) { - tryToGenerateCitationKeyThenBind(bibDatabaseContext, entry); - } else { - showChatPanel(bibDatabaseContext, entry); - } - } - - private void showPrivacyNotice(BibEntry entry) { - setContent(new PrivacyNoticeComponent(aiPreferences, () -> bindToEntry(entry), externalApplicationsPreferences, dialogService, adaptVisibleTabs)); - } - - private void showErrorNotPdfs() { - setContent( - new ErrorStateComponent( - Localization.lang("Unable to chat"), - Localization.lang("Only PDF files are supported.") - ) - ); - } - - private void showErrorNoFiles() { - setContent( - new ErrorStateComponent( - Localization.lang("Unable to chat"), - Localization.lang("Please attach at least one PDF file to enable chatting with PDF file(s).") - ) - ); - } - - private void tryToGenerateCitationKeyThenBind(BibDatabaseContext bibDatabaseContext, BibEntry entry) { - CitationKeyGenerator citationKeyGenerator = new CitationKeyGenerator(bibDatabaseContext, citationKeyPatternPreferences); - if (citationKeyGenerator.generateAndSetKey(entry).isEmpty()) { - setContent( - new ErrorStateComponent( - Localization.lang("Unable to chat"), - Localization.lang("Please provide a non-empty and unique citation key for this entry.") - ) - ); - } else { - bindToEntry(entry); - } - } - - private void showChatPanel(BibDatabaseContext bibDatabaseContext, BibEntry entry) { - // We omit the localization here, because it is only a chat with one entry in the {@link EntryEditor}. - // See documentation for {@link AiChatGuardedComponent#name}. - StringProperty chatName = new SimpleStringProperty("entry " + entry.getCitationKey().orElse("")); - entry.getCiteKeyBinding().addListener((observable, oldValue, newValue) -> chatName.setValue("entry " + newValue)); - - setContent(new AiChatGuardedComponent( - aiService, - chatName, - aiService.getChatHistoryService().getChatHistoryForEntry(bibDatabaseContext, entry), - bibDatabaseContext, - FXCollections.observableArrayList(new ArrayList<>(List.of(entry))), - entryTypesManager, - aiPreferences, - fieldPreferences, - externalApplicationsPreferences, - dialogService, - adaptVisibleTabs, - taskExecutor - - )); + aiEntryChatView.selectedEntryProperty().set(new FullBibEntry(bibDatabaseContext, entry)); } } diff --git a/jabgui/src/main/java/org/jabref/gui/entryeditor/AiSummaryTab.java b/jabgui/src/main/java/org/jabref/gui/entryeditor/AiSummaryTab.java index 58d21fa64515..4f6cb3c17688 100644 --- a/jabgui/src/main/java/org/jabref/gui/entryeditor/AiSummaryTab.java +++ b/jabgui/src/main/java/org/jabref/gui/entryeditor/AiSummaryTab.java @@ -2,73 +2,44 @@ import javafx.scene.control.Tooltip; -import org.jabref.gui.DialogService; import org.jabref.gui.StateManager; -import org.jabref.gui.ai.components.summary.SummaryComponent; -import org.jabref.gui.frame.ExternalApplicationsPreferences; +import org.jabref.gui.ai.summary.AiSummaryView; import org.jabref.gui.preferences.GuiPreferences; -import org.jabref.logic.ai.AiPreferences; -import org.jabref.logic.ai.AiService; -import org.jabref.logic.bibtex.FieldPreferences; -import org.jabref.logic.citationkeypattern.CitationKeyPatternPreferences; import org.jabref.logic.l10n.Localization; +import org.jabref.model.ai.identifiers.FullBibEntry; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; -import org.jabref.model.entry.BibEntryTypesManager; +// [impl->feat~ai.summarization.entries~1] public class AiSummaryTab extends EntryEditorTab { - private final AiService aiService; - private final DialogService dialogService; + private final GuiPreferences preferences; private final StateManager stateManager; - private final AdaptVisibleTabs adaptVisibleTabs; - private final AiPreferences aiPreferences; - private final ExternalApplicationsPreferences externalApplicationsPreferences; - private final CitationKeyPatternPreferences citationKeyPatternPreferences; - private final EntryEditorPreferences entryEditorPreferences; - private final BibEntryTypesManager entryTypesManager; - private final FieldPreferences fieldPreferences; - public AiSummaryTab(StateManager stateManager, - BibEntryTypesManager entryTypesManager, - GuiPreferences preferences, - AiService aiService, - DialogService dialogService, - AdaptVisibleTabs adaptVisibleTabs) { + private final AiSummaryView aiSummaryView; + + public AiSummaryTab( + GuiPreferences preferences, + StateManager stateManager + ) { + this.preferences = preferences; this.stateManager = stateManager; - this.entryTypesManager = entryTypesManager; - this.aiPreferences = preferences.getAiPreferences(); - this.aiService = aiService; - this.dialogService = dialogService; - this.adaptVisibleTabs = adaptVisibleTabs; - this.externalApplicationsPreferences = preferences.getExternalApplicationsPreferences(); - this.citationKeyPatternPreferences = preferences.getCitationKeyPatternPreferences(); - this.entryEditorPreferences = preferences.getEntryEditorPreferences(); - this.fieldPreferences = preferences.getFieldPreferences(); + + this.aiSummaryView = new AiSummaryView(); setText(Localization.lang("AI summary")); setTooltip(new Tooltip(Localization.lang("AI-generated summary of attached file(s)"))); + setContent(aiSummaryView); } @Override public boolean shouldShow(BibEntry entry) { - return entryEditorPreferences.shouldShowAiSummaryTab(); + return preferences.getEntryEditorPreferences().shouldShowAiSummaryTab(); } /// @implNote Method similar to {@link AiChatTab#bindToEntry(BibEntry)} @Override protected void bindToEntry(BibEntry entry) { BibDatabaseContext bibDatabaseContext = stateManager.getActiveDatabase().orElse(new BibDatabaseContext()); - setContent(new SummaryComponent( - bibDatabaseContext, - entry, - entryTypesManager, - aiPreferences, - fieldPreferences, - externalApplicationsPreferences, - citationKeyPatternPreferences, - aiService, - dialogService, - adaptVisibleTabs - )); + aiSummaryView.entryProperty().set(new FullBibEntry(bibDatabaseContext, entry)); } } diff --git a/jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java b/jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java index 65b9116ffdda..ee39ed8e1e7e 100644 --- a/jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java +++ b/jabgui/src/main/java/org/jabref/gui/entryeditor/EntryEditor.java @@ -403,8 +403,8 @@ private List createTabs() { tabs.add(sourceTab); tabs.add(new LatexCitationsTab(preferences, dialogService, stateManager, directoryMonitor)); tabs.add(new FulltextSearchResultsTab(stateManager, preferences, dialogService, taskExecutor, this)); - tabs.add(new AiSummaryTab(stateManager, bibEntryTypesManager, preferences, aiService, dialogService, this)); - tabs.add(new AiChatTab(stateManager, bibEntryTypesManager, preferences, aiService, dialogService, this, taskExecutor)); + tabs.add(new AiSummaryTab(preferences, stateManager)); + tabs.add(new AiChatTab(preferences, stateManager)); return tabs; } diff --git a/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java b/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java index 9a934f1f2e80..0f34b624267a 100644 --- a/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java +++ b/jabgui/src/main/java/org/jabref/gui/frame/MainMenu.java @@ -351,7 +351,7 @@ private void createMenu() { new SeparatorMenuItem(), factory.createMenuItem(StandardActions.REBUILD_FULLTEXT_SEARCH_INDEX, new RebuildFulltextSearchIndexAction(stateManager, frame::getCurrentLibraryTab, dialogService, preferences)), - factory.createMenuItem(StandardActions.CLEAR_EMBEDDINGS_CACHE, new ClearEmbeddingsAction(stateManager, dialogService, aiService, taskExecutor)), + factory.createMenuItem(StandardActions.CLEAR_EMBEDDINGS_CACHE, new ClearEmbeddingsAction(stateManager, dialogService, aiService, taskExecutor, preferences.getFilePreferences())), new SeparatorMenuItem(), diff --git a/jabgui/src/main/java/org/jabref/gui/groups/GroupTreeView.java b/jabgui/src/main/java/org/jabref/gui/groups/GroupTreeView.java index e94fc4becbb4..608405af10c0 100644 --- a/jabgui/src/main/java/org/jabref/gui/groups/GroupTreeView.java +++ b/jabgui/src/main/java/org/jabref/gui/groups/GroupTreeView.java @@ -662,7 +662,7 @@ private ContextMenu createContextMenuForGroup(GroupNodeViewModel group) { removeGroup = factory.createMenuItem(StandardActions.GROUP_REMOVE, new GroupTreeView.ContextAction(StandardActions.GROUP_REMOVE, group)); } - if (preferences.getAiPreferences().getEnableAi()) { + if (preferences.getAiPreferences().getEnableAi() && preferences.getGroupsPreferences().showAiChatButton()) { contextMenu.getItems().add(factory.createMenuItem(StandardActions.GROUP_CHAT, new ContextAction(StandardActions.GROUP_CHAT, group))); } diff --git a/jabgui/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java b/jabgui/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java index 4af23668cd8e..ada7f3b0c327 100644 --- a/jabgui/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/groups/GroupTreeViewModel.java @@ -19,20 +19,25 @@ import javafx.scene.control.Alert; import javafx.scene.control.ButtonBar; import javafx.scene.control.ButtonType; +import javafx.stage.WindowEvent; import org.jabref.gui.AbstractViewModel; import org.jabref.gui.DialogService; import org.jabref.gui.StateManager; -import org.jabref.gui.ai.components.aichat.AiChatWindow; +import org.jabref.gui.ai.chat.AiGroupChatWindow; import org.jabref.gui.entryeditor.AdaptVisibleTabs; import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.gui.util.BaseDialog; import org.jabref.gui.util.CustomLocalDragboard; import org.jabref.logic.ai.AiService; +import org.jabref.logic.ai.ingestion.tasks.generateembeddingsforseveral.GenerateEmbeddingsForSeveralTaskRequest; +import org.jabref.logic.ai.summarization.tasks.GenerateSummaryTaskRequest; import org.jabref.logic.bibtex.FieldPreferences; import org.jabref.logic.groups.GroupsFactory; import org.jabref.logic.l10n.Localization; import org.jabref.logic.search.query.GroupNameFilterVisitor; import org.jabref.logic.util.TaskExecutor; +import org.jabref.model.ai.identifiers.FullBibEntry; import org.jabref.model.database.BibDatabaseContext; import org.jabref.model.entry.BibEntry; import org.jabref.model.entry.BibEntryTypesManager; @@ -50,7 +55,6 @@ import org.jabref.model.metadata.MetaData; import com.tobiasdiez.easybind.EasyBind; -import dev.langchain4j.data.message.ChatMessage; import org.jspecify.annotations.NonNull; public class GroupTreeViewModel extends AbstractViewModel { @@ -456,48 +460,36 @@ public void editGroup(GroupNodeViewModel oldGroup) { public void chatWithGroup(GroupNodeViewModel group) { assert currentDatabase.isPresent(); - StringProperty groupNameProperty = group.getGroupNode().getGroup().nameProperty(); + BibDatabaseContext context = currentDatabase.get(); + String groupName = group.getGroupNode().getGroup().getName(); - // We localize the name here, because it is used as the title of the window. - // See documentation for {@link AiChatGuardedComponent#name}. - StringProperty nameProperty = new SimpleStringProperty(Localization.lang("Group %0", groupNameProperty.get())); - groupNameProperty.addListener((obs, oldValue, newValue) -> nameProperty.setValue(Localization.lang("Group %0", groupNameProperty.get()))); + Optional existingWindow = stateManager.getAiChatWindowForGroup(context, groupName); - ObservableList chatHistory = aiService.getChatHistoryService().getChatHistoryForGroup(currentDatabase.get(), group.getGroupNode()); - ObservableList bibEntries = FXCollections.observableArrayList(group.getGroupNode().findMatches(currentDatabase.get().getDatabase())); + if (existingWindow.isPresent()) { + BaseDialog.bringToFront(existingWindow.get()); + return; + } - openAiChat(nameProperty, chatHistory, currentDatabase.get(), bibEntries); - } + AiGroupChatWindow aiChatWindow = new AiGroupChatWindow(); + aiChatWindow.databaseContextProperty().set(context); + aiChatWindow.groupNodeProperty().set(group); - private void openAiChat(StringProperty name, ObservableList chatHistory, BibDatabaseContext bibDatabaseContext, ObservableList entries) { - Optional existingWindow = stateManager.getAiChatWindows().stream().filter(window -> window.getChatName().equals(name.get())).findFirst(); + aiChatWindow.getDialogPane().getScene().getWindow().addEventHandler( + WindowEvent.WINDOW_CLOSE_REQUEST, + _ -> stateManager.removeAiChatWindowForGroup(context, groupName) + ); - if (existingWindow.isPresent()) { - existingWindow.get().requestFocus(); - } else { - AiChatWindow aiChatWindow = new AiChatWindow( - entryTypesManager, - preferences.getAiPreferences(), - fieldPreferences, - preferences.getExternalApplicationsPreferences(), - aiService, - dialogService, - adaptVisibleTabs, - taskExecutor - ); - - aiChatWindow.setOnCloseRequest(event -> - stateManager.getAiChatWindows().remove(aiChatWindow) - ); - - stateManager.getAiChatWindows().add(aiChatWindow); - dialogService.showCustomWindow(aiChatWindow); - aiChatWindow.setChat(name, chatHistory, bibDatabaseContext, entries); - aiChatWindow.requestFocus(); - } + stateManager.setAiChatWindowForGroup(context, groupName, aiChatWindow); + + dialogService.showCustomDialogModal(aiChatWindow); + BaseDialog.bringToFront(aiChatWindow); } public void generateEmbeddings(GroupNodeViewModel groupNode) { + if (!preferences.getAiPreferences().getEnableAi() || !preferences.getAiPreferences().getAutoGenerateEmbeddings()) { + return; + } + assert currentDatabase.isPresent(); AbstractGroup group = groupNode.getGroupNode().getGroup(); @@ -511,16 +503,27 @@ public void generateEmbeddings(GroupNodeViewModel groupNode) { .flatMap(entry -> entry.getFiles().stream()) .toList(); - aiService.getIngestionService().ingest( - group.nameProperty(), - linkedFiles, - currentDatabase.get() - ); + aiService.getIngestionTaskAggregator() + .start(new GenerateEmbeddingsForSeveralTaskRequest( + preferences.getFilePreferences(), + aiService.getIngestedDocumentsRepository(), + aiService.getEmbeddingsStore(), + aiService.getCurrentEmbeddingModel(), + aiService.getCurrentDocumentSplitter(), + currentDatabase.get(), + group.nameProperty(), + linkedFiles, + taskExecutor + )); dialogService.notify(Localization.lang("Ingestion started for group \"%0\".", group.getName())); } public void generateSummaries(GroupNodeViewModel groupNode) { + if (!preferences.getAiPreferences().getEnableAi() || !preferences.getAiPreferences().getAutoGenerateSummaries()) { + return; + } + assert currentDatabase.isPresent(); AbstractGroup group = groupNode.getGroupNode().getGroup(); @@ -533,10 +536,16 @@ public void generateSummaries(GroupNodeViewModel groupNode) { .filter(group::isMatch) .toList(); - aiService.getSummariesService().summarize( - group.nameProperty(), - entries, - currentDatabase.get() + entries.forEach(entry -> + aiService.getSummarizationTaskAggregator().start( + new GenerateSummaryTaskRequest( + preferences.getFilePreferences(), + aiService.getCurrentChatModel(), + aiService.getCurrentSummarizator(), + new FullBibEntry(currentDatabase.get(), entry), + false + ) + ) ); dialogService.notify(Localization.lang("Summarization started for group \"%0\".", group.getName())); diff --git a/jabgui/src/main/java/org/jabref/gui/groups/GroupsPreferences.java b/jabgui/src/main/java/org/jabref/gui/groups/GroupsPreferences.java index 9f584ab5e78f..2e675044d789 100644 --- a/jabgui/src/main/java/org/jabref/gui/groups/GroupsPreferences.java +++ b/jabgui/src/main/java/org/jabref/gui/groups/GroupsPreferences.java @@ -20,18 +20,21 @@ public class GroupsPreferences { private final BooleanProperty shouldAutoAssignGroup; private final BooleanProperty shouldDisplayGroupCount; private final ObjectProperty defaultHierarchicalContext; + private final BooleanProperty showAiChatButton; public GroupsPreferences(boolean viewModeIntersection, boolean viewModeFilter, boolean viewModeInvert, boolean shouldAutoAssignGroup, boolean shouldDisplayGroupCount, - GroupHierarchyType defaultHierarchicalContext) { + GroupHierarchyType defaultHierarchicalContext, + boolean showAiChatButton) { this.groupViewMode = new SimpleSetProperty<>(FXCollections.observableSet()); this.shouldAutoAssignGroup = new SimpleBooleanProperty(shouldAutoAssignGroup); this.shouldDisplayGroupCount = new SimpleBooleanProperty(shouldDisplayGroupCount); this.defaultHierarchicalContext = new SimpleObjectProperty<>(defaultHierarchicalContext); + this.showAiChatButton = new SimpleBooleanProperty(showAiChatButton); if (viewModeIntersection) { this.groupViewMode.add(GroupViewMode.INTERSECTION); @@ -51,7 +54,8 @@ private GroupsPreferences() { false, // Default view mode invert true, // Default auto assign group true, // Default display group content - GroupHierarchyType.INDEPENDENT // Default hierarchical context + GroupHierarchyType.INDEPENDENT, // Default hierarchical context + true // Default view mode for the AI chat button ); } @@ -59,11 +63,13 @@ private GroupsPreferences() { public GroupsPreferences(EnumSet groupViewMode, boolean shouldAutoAssignGroup, boolean shouldDisplayGroupCount, - GroupHierarchyType defaultHierarchicalContext) { + GroupHierarchyType defaultHierarchicalContext, + boolean showAiChatButton) { this.groupViewMode = new SimpleSetProperty<>(FXCollections.observableSet(groupViewMode)); this.shouldAutoAssignGroup = new SimpleBooleanProperty(shouldAutoAssignGroup); this.shouldDisplayGroupCount = new SimpleBooleanProperty(shouldDisplayGroupCount); this.defaultHierarchicalContext = new SimpleObjectProperty<>(defaultHierarchicalContext); + this.showAiChatButton = new SimpleBooleanProperty(showAiChatButton); } public static GroupsPreferences getDefault() { @@ -131,4 +137,16 @@ public ObjectProperty defaultHierarchicalContextProperty() { public void setDefaultHierarchicalContext(GroupHierarchyType defaultHierarchicalContext) { this.defaultHierarchicalContext.set(defaultHierarchicalContext); } + + public boolean showAiChatButton() { + return showAiChatButton.getValue(); + } + + public BooleanProperty showAiChatButtonProperty() { + return showAiChatButton; + } + + public void setShowAiChatButton(boolean showAiChatButton) { + this.showAiChatButton.set(showAiChatButton); + } } diff --git a/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryView.java b/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryView.java index 525d5bcadeb7..8e9c157b1b90 100644 --- a/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryView.java +++ b/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryView.java @@ -91,8 +91,8 @@ public class NewEntryView extends BaseDialog { private final DialogService dialogService; @Inject private StateManager stateManager; @Inject private TaskExecutor taskExecutor; - @Inject private AiService aiService; @Inject private FileUpdateMonitor fileUpdateMonitor; + @Inject private AiService aiService; private final ControlsFxVisualizer visualizer; @@ -216,7 +216,7 @@ private void finalizeTabs() { @FXML public void initialize() { - viewModel = new NewEntryViewModel(preferences, libraryTab, dialogService, stateManager, (UiTaskExecutor) taskExecutor, aiService, fileUpdateMonitor); + viewModel = new NewEntryViewModel(preferences, libraryTab, dialogService, stateManager, (UiTaskExecutor) taskExecutor, fileUpdateMonitor, aiService); getDialogPane().disableProperty().bind(viewModel.executingProperty()); diff --git a/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryViewModel.java b/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryViewModel.java index 98f12b54c1bc..5c3865ceda72 100644 --- a/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/newentry/NewEntryViewModel.java @@ -66,8 +66,8 @@ public class NewEntryViewModel { private final DialogService dialogService; private final StateManager stateManager; private final UiTaskExecutor taskExecutor; - private final AiService aiService; private final FileUpdateMonitor fileUpdateMonitor; + private final AiService aiService; private final BookCoverFetcher bookCoverFetcher; @@ -99,15 +99,15 @@ public NewEntryViewModel(GuiPreferences preferences, DialogService dialogService, StateManager stateManager, UiTaskExecutor taskExecutor, - AiService aiService, - FileUpdateMonitor fileUpdateMonitor) { + FileUpdateMonitor fileUpdateMonitor, + AiService aiService) { this.preferences = preferences; this.libraryTab = libraryTab; this.dialogService = dialogService; this.stateManager = stateManager; this.taskExecutor = taskExecutor; - this.aiService = aiService; this.fileUpdateMonitor = fileUpdateMonitor; + this.aiService = aiService; this.bookCoverFetcher = new BookCoverFetcher(preferences.getExternalApplicationsPreferences()); @@ -374,7 +374,14 @@ protected Optional> call() throws FetcherException { return Optional.empty(); } - final PlainCitationParser parser = PlainCitationParserFactory.getPlainCitationParser(parserChoice, preferences.getCitationKeyPatternPreferences(), preferences.getGrobidPreferences(), preferences.getImportFormatPreferences(), aiService); + final PlainCitationParser parser = PlainCitationParserFactory.getPlainCitationParser( + parserChoice, + preferences.getCitationKeyPatternPreferences(), + preferences.getGrobidPreferences(), + preferences.getImportFormatPreferences(), + preferences.getAiPreferences(), + aiService.getCurrentChatModel() + ); final List entries = parser.parseMultiplePlainCitations(text); diff --git a/jabgui/src/main/java/org/jabref/gui/preferences/JabRefGuiPreferences.java b/jabgui/src/main/java/org/jabref/gui/preferences/JabRefGuiPreferences.java index 180be36d0096..ac5d23fec072 100644 --- a/jabgui/src/main/java/org/jabref/gui/preferences/JabRefGuiPreferences.java +++ b/jabgui/src/main/java/org/jabref/gui/preferences/JabRefGuiPreferences.java @@ -179,6 +179,7 @@ public class JabRefGuiPreferences extends JabRefCliPreferences implements GuiPre private static final String GROUP_VIEW_FILTER = "groupFilter"; private static final String GROUP_VIEW_INVERT = "groupInvert"; private static final String DEFAULT_HIERARCHICAL_CONTEXT = "defaultHierarchicalContext"; + private static final String GROUP_SHOW_AI_CHAT = "groupShowAiChat"; // endregion // region specialFieldsPreferences @@ -767,6 +768,7 @@ public GroupsPreferences getGroupsPreferences() { EasyBind.listen(groupsPreferences.autoAssignGroupProperty(), (_, _, newValue) -> putBoolean(AUTO_ASSIGN_GROUP, newValue)); EasyBind.listen(groupsPreferences.displayGroupCountProperty(), (_, _, newValue) -> putBoolean(DISPLAY_GROUP_COUNT, newValue)); EasyBind.listen(groupsPreferences.defaultHierarchicalContextProperty(), (_, _, newValue) -> put(DEFAULT_HIERARCHICAL_CONTEXT, newValue.name())); + EasyBind.listen(groupsPreferences.showAiChatButtonProperty(), (_, _, newValue) -> putBoolean(GROUP_SHOW_AI_CHAT, newValue)); return groupsPreferences; } @@ -780,7 +782,8 @@ private GroupsPreferences getGroupsPreferencesFromBackingStore(GroupsPreferences getBoolean(DISPLAY_GROUP_COUNT, defaults.shouldDisplayGroupCount()), GroupHierarchyType.valueOf( get(DEFAULT_HIERARCHICAL_CONTEXT, defaults.getDefaultHierarchicalContext().name()) - ) + ), + getBoolean(GROUP_SHOW_AI_CHAT, defaults.showAiChatButton()) ); } // endregion diff --git a/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTab.java b/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTab.java index 33f83808b8e3..0be93c924db7 100644 --- a/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTab.java +++ b/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTab.java @@ -1,14 +1,13 @@ package org.jabref.gui.preferences.ai; -import java.util.Optional; - import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.binding.BooleanBinding; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.fxml.FXML; import javafx.scene.control.Button; import javafx.scene.control.CheckBox; import javafx.scene.control.ComboBox; -import javafx.scene.control.Label; import javafx.scene.control.Spinner; import javafx.scene.control.Tab; import javafx.scene.control.TabPane; @@ -24,11 +23,13 @@ import org.jabref.gui.preferences.AbstractPreferenceTabView; import org.jabref.gui.preferences.PreferencesTab; import org.jabref.gui.util.ViewModelListCellFactory; -import org.jabref.logic.ai.templates.AiTemplate; import org.jabref.logic.help.HelpFile; import org.jabref.logic.l10n.Localization; -import org.jabref.model.ai.AiProvider; -import org.jabref.model.ai.EmbeddingModel; +import org.jabref.model.ai.embeddings.PredefinedEmbeddingModel; +import org.jabref.model.ai.llm.AiProvider; +import org.jabref.model.ai.pipeline.AnswerEngineKind; +import org.jabref.model.ai.summarization.SummarizatorKind; +import org.jabref.model.ai.tokenization.TokenEstimatorKind; import com.airhacks.afterburner.views.ViewLoader; import com.dlsc.gemsfx.EnhancedPasswordField; @@ -37,56 +38,67 @@ import org.controlsfx.control.SearchableComboBox; public class AiTab extends AbstractPreferenceTabView implements PreferencesTab { + private static final String HUGGING_FACE_CHAT_MODEL_PROMPT = "TinyLlama/TinyLlama_v1.1 (or any other model name)"; + + private final ControlsFxVisualizer visualizer = new ControlsFxVisualizer(); @FXML private CheckBox enableAi; + // [impl->req~ai.ingestion.automatic-trigger~1] @FXML private CheckBox autoGenerateEmbeddings; + // [impl->req~ai.summarization.entries.auto~1] @FXML private CheckBox autoGenerateSummaries; - @FXML private CheckBox generateFollowUpQuestions; - @FXML private Spinner followUpQuestionsCountSpinner; - @FXML private Tab followUpQuestionsTab; - @FXML private TextArea followUpQuestionsTextArea; - @FXML private Label followUpQuestionsCountLabel; - + // [impl->feat~ai.llms.providers~1] @FXML private ComboBox aiProviderComboBox; @FXML private ComboBox chatModelComboBox; @FXML private EnhancedPasswordField apiKeyTextField; - @FXML private CheckBox customizeExpertSettingsCheckbox; @FXML private VBox expertSettingsPane; - @FXML private TextField apiBaseUrlTextField; - @FXML private SearchableComboBox embeddingModelComboBox; + @FXML private SearchableComboBox embeddingModelComboBox; + @FXML private ComboBox answerEngineComboBox; + // [impl->req~ai.summarization.algorithm.default~1] + @FXML private ComboBox summarizationAlgorithmComboBox; + @FXML private ComboBox tokenEstimationAlgorithmComboBox; + // [impl->req~ai.expert-settings.chat-inference-global~1] @FXML private TextField temperatureTextField; @FXML private IntegerInputField contextWindowSizeTextField; @FXML private IntegerInputField documentSplitterChunkSizeTextField; @FXML private IntegerInputField documentSplitterOverlapSizeTextField; + // [impl->req~ai.expert-settings.rag-global~1] @FXML private IntegerInputField ragMaxResultsCountTextField; @FXML private TextField ragMinScoreTextField; - + // [impl->req~ai.expert-settings.templates~1] @FXML private TabPane templatesTabPane; @FXML private Tab systemMessageForChattingTab; @FXML private Tab userMessageForChattingTab; @FXML private Tab summarizationChunkSystemMessageTab; - @FXML private Tab summarizationChunkUserMessageTab; @FXML private Tab summarizationCombineSystemMessageTab; - @FXML private Tab summarizationCombineUserMessageTab; + @FXML private Tab summarizationFullDocumentSystemMessageTab; @FXML private Tab citationParsingSystemMessageTab; - @FXML private Tab citationParsingUserMessageTab; - + @FXML private Tab markdownChatExportTemplateTab; + @FXML private Tab followUpQuestionsTemplateTab; + // [impl->req~ai.chat.customize-system-prompt~1] @FXML private TextArea systemMessageTextArea; + // [impl->req~ai.answer-engines.embeddings-search.prompt~1] + // [impl->req~ai.answer-engines.full-document.prompt~1] @FXML private TextArea userMessageTextArea; + // [impl->req~ai.summarization.algorithms.chunked.system-prompt-chunk~1] @FXML private TextArea summarizationChunkSystemMessageTextArea; - @FXML private TextArea summarizationChunkUserMessageTextArea; + // [impl->req~ai.summarization.algorithms.chunked.system-prompt-combine~1] @FXML private TextArea summarizationCombineSystemMessageTextArea; - @FXML private TextArea summarizationCombineUserMessageTextArea; + // [impl->req~ai.summarization.algorithms.full.system-prompt~1] + @FXML private TextArea summarizationFullDocumentSystemMessageTextArea; + // [impl->req~ai.citation-parsing.system-prompt-config~1] @FXML private TextArea citationParsingSystemMessageTextArea; - @FXML private TextArea citationParsingUserMessageTextArea; - + @FXML private TextArea markdownChatExportTemplateTextArea; + @FXML private TextArea followUpQuestionsTemplateTextArea; @FXML private Button generalSettingsHelp; @FXML private Button expertSettingsHelp; @FXML private Button templatesHelp; - - private final ControlsFxVisualizer visualizer = new ControlsFxVisualizer(); + @FXML private Button resetCurrentTemplateButton; + @FXML private Button resetTemplatesButton; + @FXML private CheckBox generateFollowUpQuestions; + @FXML private Spinner followUpQuestionsCountSpinner; public AiTab() { ViewLoader.view(this) @@ -95,7 +107,7 @@ public AiTab() { } public void initialize() { - this.viewModel = new AiTabViewModel(preferences, taskExecutor); + this.viewModel = new AiTabViewModel(preferences); initializeEnableAi(); initializeAiProvider(); @@ -104,9 +116,19 @@ public void initialize() { initializeExpertSettings(); initializeValidations(); initializeTemplates(); + initializeFollowUpQuestions(); initializeHelp(); } + private void initializeFollowUpQuestions() { + generateFollowUpQuestions.selectedProperty().bindBidirectional(viewModel.generateFollowUpQuestionsProperty()); + generateFollowUpQuestions.disableProperty().bind(viewModel.disableBasicSettingsProperty()); + + followUpQuestionsCountSpinner.setValueFactory(AiTabViewModel.followUpQuestionsCountValueFactory); + followUpQuestionsCountSpinner.getValueFactory().valueProperty().bindBidirectional(viewModel.followUpQuestionsCountProperty().asObject()); + followUpQuestionsCountSpinner.disableProperty().bind(generateFollowUpQuestions.selectedProperty().not()); + } + private void initializeHelp() { ActionFactory actionFactory = new ActionFactory(); actionFactory.configureIconButton(StandardActions.HELP, new HelpAction(HelpFile.AI_GENERAL_SETTINGS, dialogService, preferences.getExternalApplicationsPreferences()), generalSettingsHelp); @@ -115,16 +137,28 @@ private void initializeHelp() { } private void initializeTemplates() { - systemMessageTextArea.textProperty().bindBidirectional(viewModel.getTemplateSources().get(AiTemplate.CHATTING_SYSTEM_MESSAGE)); - userMessageTextArea.textProperty().bindBidirectional(viewModel.getTemplateSources().get(AiTemplate.CHATTING_USER_MESSAGE)); - summarizationChunkSystemMessageTextArea.textProperty().bindBidirectional(viewModel.getTemplateSources().get(AiTemplate.SUMMARIZATION_CHUNK_SYSTEM_MESSAGE)); - summarizationChunkUserMessageTextArea.textProperty().bindBidirectional(viewModel.getTemplateSources().get(AiTemplate.SUMMARIZATION_CHUNK_USER_MESSAGE)); - summarizationCombineSystemMessageTextArea.textProperty().bindBidirectional(viewModel.getTemplateSources().get(AiTemplate.SUMMARIZATION_COMBINE_SYSTEM_MESSAGE)); - summarizationCombineUserMessageTextArea.textProperty().bindBidirectional(viewModel.getTemplateSources().get(AiTemplate.SUMMARIZATION_COMBINE_USER_MESSAGE)); - citationParsingSystemMessageTextArea.textProperty().bindBidirectional(viewModel.getTemplateSources().get(AiTemplate.CITATION_PARSING_SYSTEM_MESSAGE)); - citationParsingUserMessageTextArea.textProperty().bindBidirectional(viewModel.getTemplateSources().get(AiTemplate.CITATION_PARSING_USER_MESSAGE)); - followUpQuestionsTextArea.textProperty().bindBidirectional(viewModel.getTemplateSources().get(AiTemplate.FOLLOW_UP_QUESTIONS)); - templatesTabPane.getSelectionModel().selectedItemProperty().addListener(_ -> viewModel.selectedTemplateProperty().set(getAiTemplate())); + systemMessageTextArea.textProperty().bindBidirectional(viewModel.chattingSystemMessageTemplateProperty()); + userMessageTextArea.textProperty().bindBidirectional(viewModel.chattingUserMessageTemplateProperty()); + summarizationChunkSystemMessageTextArea.textProperty().bindBidirectional(viewModel.summarizationChunkSystemMessageTemplateProperty()); + summarizationCombineSystemMessageTextArea.textProperty().bindBidirectional(viewModel.summarizationCombineSystemMessageTemplateProperty()); + summarizationFullDocumentSystemMessageTextArea.textProperty().bindBidirectional(viewModel.summarizationFullDocumentSystemMessageTemplateProperty()); + citationParsingSystemMessageTextArea.textProperty().bindBidirectional(viewModel.citationParsingSystemMessageTemplateProperty()); + markdownChatExportTemplateTextArea.textProperty().bindBidirectional(viewModel.markdownChatExportTemplateProperty()); + followUpQuestionsTemplateTextArea.textProperty().bindBidirectional(viewModel.followUpQuestionsTemplateProperty()); + + BooleanBinding aiDisabled = enableAi.selectedProperty().not(); + + systemMessageTextArea.disableProperty().bind(aiDisabled); + userMessageTextArea.disableProperty().bind(aiDisabled); + summarizationChunkSystemMessageTextArea.disableProperty().bind(aiDisabled); + summarizationCombineSystemMessageTextArea.disableProperty().bind(aiDisabled); + summarizationFullDocumentSystemMessageTextArea.disableProperty().bind(aiDisabled); + citationParsingSystemMessageTextArea.disableProperty().bind(aiDisabled); + markdownChatExportTemplateTextArea.disableProperty().bind(aiDisabled); + followUpQuestionsTemplateTextArea.disableProperty().bind(aiDisabled); + + resetCurrentTemplateButton.disableProperty().bind(aiDisabled); + resetTemplatesButton.disableProperty().bind(aiDisabled); } private void initializeValidations() { @@ -151,8 +185,8 @@ private void initializeExpertSettings() { expertSettingsPane.visibleProperty().bind(customizeExpertSettingsCheckbox.selectedProperty()); expertSettingsPane.managedProperty().bind(customizeExpertSettingsCheckbox.selectedProperty()); - new ViewModelListCellFactory() - .withText(EmbeddingModel::fullInfo) + new ViewModelListCellFactory() + .withText(PredefinedEmbeddingModel::fullInfo) .install(embeddingModelComboBox); embeddingModelComboBox.setItems(viewModel.embeddingModelsProperty()); embeddingModelComboBox.valueProperty().bindBidirectional(viewModel.selectedEmbeddingModelProperty()); @@ -168,9 +202,6 @@ private void initializeExpertSettings() { apiBaseUrlTextField.setDisable(newValue || viewModel.disableExpertSettingsProperty().get()) ); - // bindBidirectional doesn't work well with number input fields ({@link IntegerInputField}, {@link DoubleInputField}), - // so they are expanded into `addListener` calls. - contextWindowSizeTextField.valueProperty().addListener((observable, oldValue, newValue) -> viewModel.contextWindowSizeProperty().set(newValue == null ? 0 : newValue)); @@ -179,6 +210,27 @@ private void initializeExpertSettings() { contextWindowSizeTextField.disableProperty().bind(viewModel.disableExpertSettingsProperty()); + new ViewModelListCellFactory() + .withText(AnswerEngineKind::getDisplayName) + .install(answerEngineComboBox); + answerEngineComboBox.setItems(viewModel.answerEngineKindsProperty()); + answerEngineComboBox.valueProperty().bindBidirectional(viewModel.answerEngineProperty()); + answerEngineComboBox.disableProperty().bind(viewModel.disableExpertSettingsProperty()); + + new ViewModelListCellFactory() + .withText(SummarizatorKind::getDisplayName) + .install(summarizationAlgorithmComboBox); + summarizationAlgorithmComboBox.setItems(viewModel.summarizationAlgorithmsProperty()); + summarizationAlgorithmComboBox.valueProperty().bindBidirectional(viewModel.summarizationAlgorithmProperty()); + summarizationAlgorithmComboBox.disableProperty().bind(viewModel.disableExpertSettingsProperty()); + + new ViewModelListCellFactory() + .withText(TokenEstimatorKind::getDisplayName) + .install(tokenEstimationAlgorithmComboBox); + tokenEstimationAlgorithmComboBox.setItems(viewModel.tokenEstimationAlgorithmsProperty()); + tokenEstimationAlgorithmComboBox.valueProperty().bindBidirectional(viewModel.tokenEstimationAlgorithmProperty()); + tokenEstimationAlgorithmComboBox.disableProperty().bind(viewModel.disableExpertSettingsProperty()); + temperatureTextField.textProperty().bindBidirectional(viewModel.temperatureProperty()); temperatureTextField.disableProperty().bind(viewModel.disableExpertSettingsProperty()); @@ -238,7 +290,7 @@ private void initializeChatModel() { this.aiProviderComboBox.valueProperty().addListener((observable, oldValue, newValue) -> { if (newValue == AiProvider.HUGGING_FACE) { - chatModelComboBox.setPromptText(Localization.lang("TinyLlama/TinyLlama_v1.1 (or any other model name)")); + chatModelComboBox.setPromptText(HUGGING_FACE_CHAT_MODEL_PROMPT); } }); } @@ -255,14 +307,19 @@ private void initializeAiProvider() { private void initializeEnableAi() { enableAi.selectedProperty().bindBidirectional(viewModel.enableAi()); autoGenerateSummaries.selectedProperty().bindBidirectional(viewModel.autoGenerateSummaries()); - autoGenerateSummaries.disableProperty().bind(viewModel.disableAutoGenerateSummaries()); + autoGenerateSummaries.disableProperty().bind( + Bindings.or( + enableAi.selectedProperty().not(), + viewModel.disableAutoGenerateSummaries() + ) + ); autoGenerateEmbeddings.selectedProperty().bindBidirectional(viewModel.autoGenerateEmbeddings()); - autoGenerateEmbeddings.disableProperty().bind(viewModel.disableAutoGenerateEmbeddings()); - generateFollowUpQuestions.selectedProperty().bindBidirectional(viewModel.generateFollowUpQuestions()); - followUpQuestionsCountSpinner.setValueFactory(AiTabViewModel.followUpQuestionsCountValueFactory); - followUpQuestionsCountSpinner.getValueFactory().valueProperty().bindBidirectional(viewModel.followUpQuestionsCountProperty().asObject()); - followUpQuestionsCountSpinner.disableProperty().bind(generateFollowUpQuestions.selectedProperty().not()); - followUpQuestionsCountLabel.disableProperty().bind(generateFollowUpQuestions.selectedProperty().not()); + autoGenerateEmbeddings.disableProperty().bind( + Bindings.or( + enableAi.selectedProperty().not(), + viewModel.disableAutoGenerateEmbeddings() + ) + ); } @Override @@ -282,35 +339,28 @@ private void onResetTemplatesButtonClick() { @FXML private void onResetCurrentTemplateButtonClick() { - viewModel.resetCurrentTemplate(); - } - - public ReadOnlyBooleanProperty aiEnabledProperty() { - return enableAi.selectedProperty(); - } - - public Optional getAiTemplate() { Tab selectedTab = templatesTabPane.getSelectionModel().getSelectedItem(); + if (selectedTab == systemMessageForChattingTab) { - return Optional.of(AiTemplate.CHATTING_SYSTEM_MESSAGE); + viewModel.resetChattingSystemMessageTemplate(); } else if (selectedTab == userMessageForChattingTab) { - return Optional.of(AiTemplate.CHATTING_USER_MESSAGE); + viewModel.resetChattingUserMessageTemplate(); } else if (selectedTab == summarizationChunkSystemMessageTab) { - return Optional.of(AiTemplate.SUMMARIZATION_CHUNK_SYSTEM_MESSAGE); - } else if (selectedTab == summarizationChunkUserMessageTab) { - return Optional.of(AiTemplate.SUMMARIZATION_CHUNK_USER_MESSAGE); + viewModel.resetSummarizationChunkSystemMessageTemplate(); } else if (selectedTab == summarizationCombineSystemMessageTab) { - return Optional.of(AiTemplate.SUMMARIZATION_COMBINE_SYSTEM_MESSAGE); - } else if (selectedTab == summarizationCombineUserMessageTab) { - return Optional.of(AiTemplate.SUMMARIZATION_COMBINE_USER_MESSAGE); + viewModel.resetSummarizationCombineSystemMessageTemplate(); + } else if (selectedTab == summarizationFullDocumentSystemMessageTab) { + viewModel.resetSummarizationFullDocumentSystemMessageTemplate(); } else if (selectedTab == citationParsingSystemMessageTab) { - return Optional.of(AiTemplate.CITATION_PARSING_SYSTEM_MESSAGE); - } else if (selectedTab == citationParsingUserMessageTab) { - return Optional.of(AiTemplate.CITATION_PARSING_USER_MESSAGE); - } else if (selectedTab == followUpQuestionsTab) { - return Optional.of(AiTemplate.FOLLOW_UP_QUESTIONS); + viewModel.resetCitationParsingSystemMessageTemplate(); + } else if (selectedTab == markdownChatExportTemplateTab) { + viewModel.resetMarkdownChatExportTemplate(); + } else if (selectedTab == followUpQuestionsTemplateTab) { + viewModel.resetFollowUpQuestionsTemplate(); } + } - return Optional.empty(); + public ReadOnlyBooleanProperty aiEnabledProperty() { + return enableAi.selectedProperty(); } } diff --git a/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java b/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java index 95462c52c16e..e59d868f1809 100644 --- a/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/preferences/ai/AiTabViewModel.java @@ -1,9 +1,7 @@ package org.jabref.gui.preferences.ai; -import java.util.Arrays; import java.util.List; import java.util.Locale; -import java.util.Map; import java.util.Objects; import javafx.beans.property.BooleanProperty; @@ -21,28 +19,25 @@ import javafx.scene.control.SpinnerValueFactory; import org.jabref.gui.preferences.PreferenceTabViewModel; -import org.jabref.logic.ai.AiDefaultPreferences; -import org.jabref.logic.ai.AiPreferences; -import org.jabref.logic.ai.models.AiModelService; -import org.jabref.logic.ai.models.FetchAiModelsBackgroundTask; -import org.jabref.logic.ai.templates.AiTemplate; +import org.jabref.logic.ai.preferences.AiDefaultExpertSettings; +import org.jabref.logic.ai.preferences.AiDefaultTemplates; +import org.jabref.logic.ai.preferences.AiPreferences; import org.jabref.logic.l10n.Localization; import org.jabref.logic.preferences.CliPreferences; -import org.jabref.logic.util.LocalizedNumbers; -import org.jabref.logic.util.OptionalObjectProperty; -import org.jabref.logic.util.TaskExecutor; +import org.jabref.logic.util.LocalizedNumbersUtils; import org.jabref.logic.util.strings.StringUtil; -import org.jabref.model.ai.AiProvider; -import org.jabref.model.ai.EmbeddingModel; +import org.jabref.model.ai.embeddings.PredefinedEmbeddingModel; +import org.jabref.model.ai.llm.AiProvider; +import org.jabref.model.ai.llm.PredefinedChatModel; +import org.jabref.model.ai.pipeline.AnswerEngineKind; +import org.jabref.model.ai.summarization.SummarizatorKind; +import org.jabref.model.ai.tokenization.TokenEstimatorKind; import de.saxsys.mvvmfx.utils.validation.FunctionBasedValidator; import de.saxsys.mvvmfx.utils.validation.ValidationMessage; import de.saxsys.mvvmfx.utils.validation.ValidationStatus; import de.saxsys.mvvmfx.utils.validation.Validator; -import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; -@NullMarked public class AiTabViewModel implements PreferenceTabViewModel { protected static SpinnerValueFactory followUpQuestionsCountValueFactory = new SpinnerValueFactory.IntegerSpinnerValueFactory(1, 5, 3); @@ -53,8 +48,6 @@ public class AiTabViewModel implements PreferenceTabViewModel { private final BooleanProperty disableAutoGenerateEmbeddings = new SimpleBooleanProperty(); private final BooleanProperty autoGenerateSummaries = new SimpleBooleanProperty(); private final BooleanProperty disableAutoGenerateSummaries = new SimpleBooleanProperty(); - private final BooleanProperty generateFollowUpQuestions = new SimpleBooleanProperty(); - private final IntegerProperty followUpQuestionsCount = new SimpleIntegerProperty(); private final ListProperty aiProvidersList = new SimpleListProperty<>(FXCollections.observableArrayList(AiProvider.values())); @@ -79,9 +72,9 @@ public class AiTabViewModel implements PreferenceTabViewModel { private final BooleanProperty customizeExpertSettings = new SimpleBooleanProperty(); - private final ListProperty embeddingModelsList = - new SimpleListProperty<>(FXCollections.observableArrayList(EmbeddingModel.values())); - private final ObjectProperty selectedEmbeddingModel = new SimpleObjectProperty<>(); + private final ListProperty embeddingModelsList = + new SimpleListProperty<>(FXCollections.observableArrayList(PredefinedEmbeddingModel.values())); + private final ObjectProperty selectedEmbeddingModel = new SimpleObjectProperty<>(); private final StringProperty currentApiBaseUrl = new SimpleStringProperty(); private final BooleanProperty disableApiBaseUrl = new SimpleBooleanProperty(true); // {@link HuggingFaceChatModel} and {@link GoogleAiGeminiChatModel} doesn't support setting API base URL @@ -91,19 +84,29 @@ public class AiTabViewModel implements PreferenceTabViewModel { private final StringProperty geminiApiBaseUrl = new SimpleStringProperty(); private final StringProperty huggingFaceApiBaseUrl = new SimpleStringProperty(); - private final Map templateSources = Map.of( - AiTemplate.CHATTING_SYSTEM_MESSAGE, new SimpleStringProperty(), - AiTemplate.CHATTING_USER_MESSAGE, new SimpleStringProperty(), - AiTemplate.SUMMARIZATION_CHUNK_SYSTEM_MESSAGE, new SimpleStringProperty(), - AiTemplate.SUMMARIZATION_CHUNK_USER_MESSAGE, new SimpleStringProperty(), - AiTemplate.SUMMARIZATION_COMBINE_SYSTEM_MESSAGE, new SimpleStringProperty(), - AiTemplate.SUMMARIZATION_COMBINE_USER_MESSAGE, new SimpleStringProperty(), - AiTemplate.CITATION_PARSING_SYSTEM_MESSAGE, new SimpleStringProperty(), - AiTemplate.CITATION_PARSING_USER_MESSAGE, new SimpleStringProperty(), - AiTemplate.FOLLOW_UP_QUESTIONS, new SimpleStringProperty() - ); + private final StringProperty chattingSystemMessageTemplate = new SimpleStringProperty(); + private final StringProperty chattingUserMessageTemplate = new SimpleStringProperty(); + private final StringProperty summarizationChunkSystemMessageTemplate = new SimpleStringProperty(); + private final StringProperty summarizationCombineSystemMessageTemplate = new SimpleStringProperty(); + private final StringProperty citationParsingSystemMessageTemplate = new SimpleStringProperty(); + private final StringProperty summarizationFullDocumentSystemMessageTemplate = new SimpleStringProperty(); + private final StringProperty markdownChatExportTemplate = new SimpleStringProperty(); + private final StringProperty followUpQuestionsTemplate = new SimpleStringProperty(); + + private final BooleanProperty generateFollowUpQuestions = new SimpleBooleanProperty(); + private final IntegerProperty followUpQuestionsCount = new SimpleIntegerProperty(); - private final OptionalObjectProperty selectedTemplate = OptionalObjectProperty.empty(); + private final ListProperty answerEnginesList = + new SimpleListProperty<>(FXCollections.observableArrayList(AnswerEngineKind.values())); + private final ObjectProperty answerEngineProperty = new SimpleObjectProperty<>(); + + private final ListProperty summarizationAlgorithmsList = + new SimpleListProperty<>(FXCollections.observableArrayList(SummarizatorKind.values())); + private final ObjectProperty summarizationAlgorithmProperty = new SimpleObjectProperty<>(); + + private final ObjectProperty tokenEstimationAlgorithmProperty = new SimpleObjectProperty<>(); + private final ListProperty tokenEstimationAlgorithmsProperty = + new SimpleListProperty<>(FXCollections.observableArrayList(TokenEstimatorKind.values())); private final StringProperty temperature = new SimpleStringProperty(); private final IntegerProperty contextWindowSize = new SimpleIntegerProperty(); @@ -116,8 +119,6 @@ AiTemplate.FOLLOW_UP_QUESTIONS, new SimpleStringProperty() private final BooleanProperty disableExpertSettings = new SimpleBooleanProperty(true); private final AiPreferences aiPreferences; - private final AiModelService aiModelService; - private final TaskExecutor taskExecutor; private final Validator apiKeyValidator; private final Validator chatModelValidator; @@ -132,12 +133,10 @@ AiTemplate.FOLLOW_UP_QUESTIONS, new SimpleStringProperty() private final Validator ragMinScoreTypeValidator; private final Validator ragMinScoreRangeValidator; - public AiTabViewModel(CliPreferences preferences, TaskExecutor taskExecutor) { + public AiTabViewModel(CliPreferences preferences) { this.oldLocale = Locale.getDefault(); this.aiPreferences = preferences.getAiPreferences(); - this.aiModelService = new AiModelService(); - this.taskExecutor = taskExecutor; this.enableAi.addListener((_, _, newValue) -> { disableBasicSettings.set(!newValue); @@ -149,7 +148,7 @@ public AiTabViewModel(CliPreferences preferences, TaskExecutor taskExecutor) { ); this.selectedAiProvider.addListener((_, oldValue, newValue) -> { - List models = AiDefaultPreferences.getAvailableModels(newValue); + List models = PredefinedChatModel.getAvailableModels(newValue); disableApiBaseUrl.set(newValue == AiProvider.HUGGING_FACE || newValue == AiProvider.GEMINI); @@ -224,7 +223,7 @@ public AiTabViewModel(CliPreferences preferences, TaskExecutor taskExecutor) { huggingFaceChatModel.set(newValue); } - contextWindowSize.set(AiDefaultPreferences.getContextWindowSize(selectedAiProvider.get(), newValue)); + contextWindowSize.set(PredefinedChatModel.getContextWindowSize(selectedAiProvider.get(), newValue)); }); this.currentApiKey.addListener((_, _, newValue) -> { @@ -275,13 +274,13 @@ public AiTabViewModel(CliPreferences preferences, TaskExecutor taskExecutor) { this.temperatureTypeValidator = new FunctionBasedValidator<>( temperature, - temp -> LocalizedNumbers.stringToDouble(temp).isPresent(), + temp -> LocalizedNumbersUtils.stringToDouble(temp).isPresent(), ValidationMessage.error(Localization.lang("Temperature must be a number"))); // Source: https://platform.openai.com/docs/api-reference/chat/create#chat-create-temperature this.temperatureRangeValidator = new FunctionBasedValidator<>( temperature, - temp -> LocalizedNumbers.stringToDouble(temp).map(t -> t >= 0 && t <= 2).orElse(false), + temp -> LocalizedNumbersUtils.stringToDouble(temp).map(t -> t >= 0 && t <= 2).orElse(false), ValidationMessage.error(Localization.lang("Temperature must be between 0 and 2"))); this.contextWindowSizeValidator = new FunctionBasedValidator<>( @@ -306,12 +305,12 @@ public AiTabViewModel(CliPreferences preferences, TaskExecutor taskExecutor) { this.ragMinScoreTypeValidator = new FunctionBasedValidator<>( ragMinScore, - minScore -> LocalizedNumbers.stringToDouble(minScore).isPresent(), + minScore -> LocalizedNumbersUtils.stringToDouble(minScore).isPresent(), ValidationMessage.error(Localization.lang("RAG minimum score must be a number"))); this.ragMinScoreRangeValidator = new FunctionBasedValidator<>( ragMinScore, - minScore -> LocalizedNumbers.stringToDouble(minScore).map(s -> s > 0 && s < 1).orElse(false), + minScore -> LocalizedNumbersUtils.stringToDouble(minScore).map(s -> s > 0 && s < 1).orElse(false), ValidationMessage.error(Localization.lang("RAG minimum score must be greater than 0 and less than 1"))); } @@ -335,8 +334,6 @@ public void setValues() { enableAi.setValue(aiPreferences.getEnableAi()); autoGenerateSummaries.setValue(aiPreferences.getAutoGenerateSummaries()); autoGenerateEmbeddings.setValue(aiPreferences.getAutoGenerateEmbeddings()); - generateFollowUpQuestions.setValue(aiPreferences.getGenerateFollowUpQuestions()); - followUpQuestionsCount.setValue(aiPreferences.getFollowUpQuestionsCount()); selectedAiProvider.setValue(aiPreferences.getAiProvider()); @@ -344,15 +341,28 @@ public void setValues() { selectedEmbeddingModel.setValue(aiPreferences.getEmbeddingModel()); - Arrays.stream(AiTemplate.values()).forEach(template -> - templateSources.get(template).set(aiPreferences.getTemplate(template))); + chattingSystemMessageTemplate.set(aiPreferences.getChattingSystemMessageTemplate()); + chattingUserMessageTemplate.set(aiPreferences.getChattingUserMessageTemplate()); + summarizationChunkSystemMessageTemplate.set(aiPreferences.getSummarizationChunkSystemMessageTemplate()); + summarizationCombineSystemMessageTemplate.set(aiPreferences.getSummarizationCombineSystemMessageTemplate()); + citationParsingSystemMessageTemplate.set(aiPreferences.getCitationParsingSystemMessageTemplate()); + summarizationFullDocumentSystemMessageTemplate.set(aiPreferences.getSummarizationFullDocumentSystemMessageTemplate()); + markdownChatExportTemplate.set(aiPreferences.getMarkdownChatExportTemplate()); + + generateFollowUpQuestions.set(aiPreferences.getGenerateFollowUpQuestions()); + followUpQuestionsCount.set(aiPreferences.getFollowUpQuestionsCount()); + followUpQuestionsTemplate.set(aiPreferences.getFollowUpQuestionsTemplate()); + + answerEngineProperty.set(aiPreferences.getAnswerEngineKind()); + summarizationAlgorithmProperty.setValue(aiPreferences.getSummarizatorKind()); + tokenEstimationAlgorithmProperty.setValue(aiPreferences.getTokenEstimatorKind()); - temperature.setValue(LocalizedNumbers.doubleToString(aiPreferences.getTemperature())); + temperature.setValue(LocalizedNumbersUtils.doubleToString(aiPreferences.getTemperature())); contextWindowSize.setValue(aiPreferences.getContextWindowSize()); documentSplitterChunkSize.setValue(aiPreferences.getDocumentSplitterChunkSize()); documentSplitterOverlapSize.setValue(aiPreferences.getDocumentSplitterOverlapSize()); ragMaxResultsCount.setValue(aiPreferences.getRagMaxResultsCount()); - ragMinScore.setValue(LocalizedNumbers.doubleToString(aiPreferences.getRagMinScore())); + ragMinScore.setValue(LocalizedNumbersUtils.doubleToString(aiPreferences.getRagMinScore())); } @Override @@ -360,8 +370,6 @@ public void storeSettings() { aiPreferences.setEnableAi(enableAi.get()); aiPreferences.setAutoGenerateEmbeddings(autoGenerateEmbeddings.get()); aiPreferences.setAutoGenerateSummaries(autoGenerateSummaries.get()); - aiPreferences.setGenerateFollowUpQuestions(generateFollowUpQuestions.get()); - aiPreferences.setFollowUpQuestionsCount(followUpQuestionsCount.get()); aiPreferences.setAiProvider(selectedAiProvider.get()); @@ -374,8 +382,6 @@ public void storeSettings() { aiPreferences.storeAiApiKeyInKeyring(AiProvider.MISTRAL_AI, mistralAiApiKey.get() == null ? "" : mistralAiApiKey.get()); aiPreferences.storeAiApiKeyInKeyring(AiProvider.GEMINI, geminiAiApiKey.get() == null ? "" : geminiAiApiKey.get()); aiPreferences.storeAiApiKeyInKeyring(AiProvider.HUGGING_FACE, huggingFaceApiKey.get() == null ? "" : huggingFaceApiKey.get()); - // We notify in all cases without a real check if something was changed - aiPreferences.apiKeyUpdated(); aiPreferences.setCustomizeExpertSettings(customizeExpertSettings.get()); @@ -386,90 +392,95 @@ public void storeSettings() { aiPreferences.setGeminiApiBaseUrl(geminiApiBaseUrl.get() == null ? "" : geminiApiBaseUrl.get()); aiPreferences.setHuggingFaceApiBaseUrl(huggingFaceApiBaseUrl.get() == null ? "" : huggingFaceApiBaseUrl.get()); - Arrays.stream(AiTemplate.values()).forEach(template -> - aiPreferences.setTemplate(template, templateSources.get(template).get())); + aiPreferences.setChattingSystemMessageTemplate(chattingSystemMessageTemplate.get()); + aiPreferences.setChattingUserMessageTemplate(chattingUserMessageTemplate.get()); + aiPreferences.setSummarizationChunkSystemMessageTemplate(summarizationChunkSystemMessageTemplate.get()); + aiPreferences.setSummarizationCombineSystemMessageTemplate(summarizationCombineSystemMessageTemplate.get()); + aiPreferences.setCitationParsingSystemMessageTemplate(citationParsingSystemMessageTemplate.get()); + aiPreferences.setSummarizationFullDocumentSystemMessageTemplate(summarizationFullDocumentSystemMessageTemplate.get()); + aiPreferences.setMarkdownChatExportTemplate(markdownChatExportTemplate.get()); + + aiPreferences.setGenerateFollowUpQuestions(generateFollowUpQuestions.get()); + aiPreferences.setFollowUpQuestionsCount(followUpQuestionsCount.get()); + aiPreferences.setFollowUpQuestionsTemplate(followUpQuestionsTemplate.get()); + + aiPreferences.setAnswerEngineKind(answerEngineProperty.get()); + aiPreferences.setSummarizatorKind(summarizationAlgorithmProperty.get()); + aiPreferences.setTokenEstimatorKind(tokenEstimationAlgorithmProperty.get()); // We already check the correctness of temperature and RAG minimum score in validators, so we don't need to check it here. - aiPreferences.setTemperature(LocalizedNumbers.stringToDouble(oldLocale, temperature.get()).get()); + aiPreferences.setTemperature(LocalizedNumbersUtils.stringToDouble(oldLocale, temperature.get()).get()); aiPreferences.setContextWindowSize(contextWindowSize.get()); aiPreferences.setDocumentSplitterChunkSize(documentSplitterChunkSize.get()); aiPreferences.setDocumentSplitterOverlapSize(documentSplitterOverlapSize.get()); aiPreferences.setRagMaxResultsCount(ragMaxResultsCount.get()); - aiPreferences.setRagMinScore(LocalizedNumbers.stringToDouble(oldLocale, ragMinScore.get()).get()); + aiPreferences.setRagMinScore(LocalizedNumbersUtils.stringToDouble(oldLocale, ragMinScore.get()).get()); } public void resetExpertSettings() { String resetApiBaseUrl = selectedAiProvider.get().getApiUrl(); currentApiBaseUrl.set(resetApiBaseUrl); - contextWindowSize.set(AiDefaultPreferences.getContextWindowSize(selectedAiProvider.get(), currentChatModel.get())); + contextWindowSize.set(PredefinedChatModel.getContextWindowSize(selectedAiProvider.get(), currentChatModel.get())); + + answerEngineProperty.set(AiDefaultExpertSettings.ANSWER_ENGINE_KIND); + summarizationAlgorithmProperty.set(AiDefaultExpertSettings.SUMMARIZATOR_KIND); + tokenEstimationAlgorithmProperty.set(AiDefaultExpertSettings.TOKEN_ESTIMATOR_KIND); - temperature.set(LocalizedNumbers.doubleToString(AiDefaultPreferences.TEMPERATURE)); - documentSplitterChunkSize.set(AiDefaultPreferences.DOCUMENT_SPLITTER_CHUNK_SIZE); - documentSplitterOverlapSize.set(AiDefaultPreferences.DOCUMENT_SPLITTER_OVERLAP); - ragMaxResultsCount.set(AiDefaultPreferences.RAG_MAX_RESULTS_COUNT); - ragMinScore.set(LocalizedNumbers.doubleToString(AiDefaultPreferences.RAG_MIN_SCORE)); - followUpQuestionsCount.set(AiDefaultPreferences.FOLLOW_UP_QUESTIONS_COUNT); + summarizationAlgorithmProperty.set(AiDefaultExpertSettings.SUMMARIZATOR_KIND); + tokenEstimationAlgorithmProperty.set(AiDefaultExpertSettings.TOKEN_ESTIMATOR_KIND); + temperature.set(LocalizedNumbersUtils.doubleToString(AiDefaultExpertSettings.TEMPERATURE)); + documentSplitterChunkSize.set(AiDefaultExpertSettings.DOCUMENT_SPLITTER_CHUNK_SIZE); + documentSplitterOverlapSize.set(AiDefaultExpertSettings.DOCUMENT_SPLITTER_OVERLAP_SIZE); + ragMaxResultsCount.set(AiDefaultExpertSettings.RAG_MAX_RESULTS_COUNT); + ragMinScore.set(LocalizedNumbersUtils.doubleToString(AiDefaultExpertSettings.RAG_MIN_SCORE)); } public void resetTemplates() { - Arrays.stream(AiTemplate.values()).forEach(template -> - templateSources.get(template).set(AiDefaultPreferences.TEMPLATES.get(template))); + resetChattingSystemMessageTemplate(); + resetChattingUserMessageTemplate(); + resetSummarizationChunkSystemMessageTemplate(); + resetSummarizationCombineSystemMessageTemplate(); + resetCitationParsingSystemMessageTemplate(); + resetSummarizationFullDocumentSystemMessageTemplate(); + resetMarkdownChatExportTemplate(); + resetFollowUpQuestionsTemplate(); } - public void resetCurrentTemplate() { - selectedTemplateProperty().get().ifPresent(template -> { - String defaultTemplate = AiDefaultPreferences.TEMPLATES.get(template); - templateSources.get(template).set(defaultTemplate); - }); + public void resetChattingSystemMessageTemplate() { + chattingSystemMessageTemplate.set(AiDefaultTemplates.CHATTING_SYSTEM_MESSAGE_TEMPLATE); } - /// Fetches available models for the currently selected AI provider. - /// Attempts to fetch models dynamically from the API, falling back to hardcoded models if fetch fails. - /// This method runs asynchronously using a BackgroundTask and updates the chatModelsList when complete. - public void refreshAvailableModels() { - AiProvider provider = selectedAiProvider.get(); - if (provider == null) { - return; - } + public void resetChattingUserMessageTemplate() { + chattingUserMessageTemplate.set(AiDefaultTemplates.CHATTING_USER_MESSAGE_TEMPLATE); + } - String apiKey = currentApiKey.get(); + public void resetSummarizationChunkSystemMessageTemplate() { + summarizationChunkSystemMessageTemplate.set(AiDefaultTemplates.SUMMARIZATION_CHUNK_SYSTEM_MESSAGE_TEMPLATE); + } - // Get API base URL, defaulting to provider's default URL if not customized - String apiBaseUrl; - if (customizeExpertSettings.get()) { - String customUrl = currentApiBaseUrl.get(); - apiBaseUrl = (customUrl != null && !customUrl.isBlank()) ? customUrl : provider.getApiUrl(); - } else { - apiBaseUrl = provider.getApiUrl(); - } + public void resetSummarizationCombineSystemMessageTemplate() { + summarizationCombineSystemMessageTemplate.set(AiDefaultTemplates.SUMMARIZATION_COMBINE_SYSTEM_MESSAGE_TEMPLATE); + } - List staticModels = aiModelService.getStaticModels(provider); - chatModelsList.setAll(staticModels); + public void resetSummarizationFullDocumentSystemMessageTemplate() { + summarizationFullDocumentSystemMessageTemplate.set(AiDefaultTemplates.SUMMARIZATION_FULL_DOCUMENT_SYSTEM_MESSAGE_TEMPLATE); + } - FetchAiModelsBackgroundTask fetchTask = getAiModelsBackgroundTask(provider, apiBaseUrl, apiKey); + public void resetCitationParsingSystemMessageTemplate() { + citationParsingSystemMessageTemplate.set(AiDefaultTemplates.CITATION_PARSING_SYSTEM_MESSAGE_TEMPLATE); + } - fetchTask.executeWith(taskExecutor); + public void resetMarkdownChatExportTemplate() { + markdownChatExportTemplate.set(AiDefaultTemplates.MARKDOWN_CHAT_EXPORT_TEMPLATE); } - private FetchAiModelsBackgroundTask getAiModelsBackgroundTask(AiProvider provider, String apiBaseUrl, @Nullable String apiKey) { - FetchAiModelsBackgroundTask fetchTask = new FetchAiModelsBackgroundTask( - aiModelService, - provider, - apiBaseUrl, - apiKey - ); + public StringProperty markdownChatExportTemplateProperty() { + return markdownChatExportTemplate; + } - fetchTask.onSuccess(dynamicModels -> { - if (!dynamicModels.isEmpty()) { - String currentModel = currentChatModel.get(); - chatModelsList.setAll(dynamicModels); - if (currentModel != null && !currentModel.isBlank()) { - currentChatModel.set(currentModel); - } - } - }); - return fetchTask; + public void resetFollowUpQuestionsTemplate() { + followUpQuestionsTemplate.set(AiDefaultTemplates.FOLLOW_UP_QUESTIONS_TEMPLATE); } @Override @@ -531,18 +542,6 @@ public BooleanProperty disableAutoGenerateSummaries() { return disableAutoGenerateSummaries; } - public BooleanProperty generateFollowUpQuestions() { - return generateFollowUpQuestions; - } - - public IntegerProperty followUpQuestionsCountProperty() { - return followUpQuestionsCount; - } - - public StringProperty followUpQuestionsTemplateProperty() { - return aiPreferences.templateProperty(AiTemplate.FOLLOW_UP_QUESTIONS); - } - public ReadOnlyListProperty aiProvidersProperty() { return aiProvidersList; } @@ -567,11 +566,11 @@ public BooleanProperty customizeExpertSettingsProperty() { return customizeExpertSettings; } - public ReadOnlyListProperty embeddingModelsProperty() { + public ReadOnlyListProperty embeddingModelsProperty() { return embeddingModelsList; } - public ObjectProperty selectedEmbeddingModelProperty() { + public ObjectProperty selectedEmbeddingModelProperty() { return selectedEmbeddingModel; } @@ -583,12 +582,52 @@ public BooleanProperty disableApiBaseUrlProperty() { return disableApiBaseUrl; } - public Map getTemplateSources() { - return templateSources; + public StringProperty chattingSystemMessageTemplateProperty() { + return chattingSystemMessageTemplate; + } + + public StringProperty chattingUserMessageTemplateProperty() { + return chattingUserMessageTemplate; + } + + public StringProperty summarizationChunkSystemMessageTemplateProperty() { + return summarizationChunkSystemMessageTemplate; + } + + public StringProperty summarizationCombineSystemMessageTemplateProperty() { + return summarizationCombineSystemMessageTemplate; + } + + public StringProperty summarizationFullDocumentSystemMessageTemplateProperty() { + return summarizationFullDocumentSystemMessageTemplate; + } + + public StringProperty citationParsingSystemMessageTemplateProperty() { + return citationParsingSystemMessageTemplate; + } + + public ListProperty answerEngineKindsProperty() { + return answerEnginesList; + } + + public ObjectProperty answerEngineProperty() { + return answerEngineProperty; + } + + public ListProperty summarizationAlgorithmsProperty() { + return summarizationAlgorithmsList; + } + + public ObjectProperty summarizationAlgorithmProperty() { + return summarizationAlgorithmProperty; } - public OptionalObjectProperty selectedTemplateProperty() { - return selectedTemplate; + public ListProperty tokenEstimationAlgorithmsProperty() { + return tokenEstimationAlgorithmsProperty; + } + + public ObjectProperty tokenEstimationAlgorithmProperty() { + return tokenEstimationAlgorithmProperty; } public StringProperty temperatureProperty() { @@ -670,4 +709,16 @@ public ValidationStatus getRagMinScoreTypeValidationStatus() { public ValidationStatus getRagMinScoreRangeValidationStatus() { return ragMinScoreRangeValidator.getValidationStatus(); } + + public BooleanProperty generateFollowUpQuestionsProperty() { + return generateFollowUpQuestions; + } + + public IntegerProperty followUpQuestionsCountProperty() { + return followUpQuestionsCount; + } + + public StringProperty followUpQuestionsTemplateProperty() { + return followUpQuestionsTemplate; + } } diff --git a/jabgui/src/main/java/org/jabref/gui/preferences/groups/GroupsTab.java b/jabgui/src/main/java/org/jabref/gui/preferences/groups/GroupsTab.java index cf6ada3c627e..6aa68b6de3ec 100644 --- a/jabgui/src/main/java/org/jabref/gui/preferences/groups/GroupsTab.java +++ b/jabgui/src/main/java/org/jabref/gui/preferences/groups/GroupsTab.java @@ -16,6 +16,7 @@ public class GroupsTab extends AbstractPreferenceTabView imp @FXML private RadioButton groupViewModeUnion; @FXML private CheckBox autoAssignGroup; @FXML private CheckBox displayGroupCount; + @FXML private CheckBox showAiChatButton; public GroupsTab() { ViewLoader.view(this) @@ -35,5 +36,6 @@ public void initialize() { groupViewModeUnion.selectedProperty().bindBidirectional(viewModel.groupViewModeUnionProperty()); autoAssignGroup.selectedProperty().bindBidirectional(viewModel.autoAssignGroupProperty()); displayGroupCount.selectedProperty().bindBidirectional(viewModel.displayGroupCount()); + showAiChatButton.selectedProperty().bindBidirectional(viewModel.showAiChatButtonProperty()); } } diff --git a/jabgui/src/main/java/org/jabref/gui/preferences/groups/GroupsTabViewModel.java b/jabgui/src/main/java/org/jabref/gui/preferences/groups/GroupsTabViewModel.java index 0d1d095108fb..4f9dea580637 100644 --- a/jabgui/src/main/java/org/jabref/gui/preferences/groups/GroupsTabViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/preferences/groups/GroupsTabViewModel.java @@ -13,6 +13,7 @@ public class GroupsTabViewModel implements PreferenceTabViewModel { private final BooleanProperty groupViewModeUnionProperty = new SimpleBooleanProperty(); private final BooleanProperty autoAssignGroupProperty = new SimpleBooleanProperty(); private final BooleanProperty displayGroupCountProperty = new SimpleBooleanProperty(); + private final BooleanProperty showAiChatButtonProperty = new SimpleBooleanProperty(); private final GroupsPreferences groupsPreferences; @@ -31,6 +32,7 @@ public void setValues() { } autoAssignGroupProperty.setValue(groupsPreferences.shouldAutoAssignGroup()); displayGroupCountProperty.setValue(groupsPreferences.shouldDisplayGroupCount()); + showAiChatButtonProperty.setValue(groupsPreferences.showAiChatButton()); } @Override @@ -38,6 +40,7 @@ public void storeSettings() { groupsPreferences.setGroupViewMode(GroupViewMode.INTERSECTION, groupViewModeIntersectionProperty.getValue()); groupsPreferences.setAutoAssignGroup(autoAssignGroupProperty.getValue()); groupsPreferences.setDisplayGroupCount(displayGroupCountProperty.getValue()); + groupsPreferences.setShowAiChatButton(showAiChatButtonProperty.getValue()); } public BooleanProperty groupViewModeIntersectionProperty() { @@ -55,4 +58,8 @@ public BooleanProperty autoAssignGroupProperty() { public BooleanProperty displayGroupCount() { return displayGroupCountProperty; } + + public BooleanProperty showAiChatButtonProperty() { + return showAiChatButtonProperty; + } } diff --git a/jabgui/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTabViewModel.java b/jabgui/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTabViewModel.java index ee8c99aac814..0c8fdb442ee3 100644 --- a/jabgui/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTabViewModel.java +++ b/jabgui/src/main/java/org/jabref/gui/preferences/websearch/WebSearchTabViewModel.java @@ -96,12 +96,14 @@ public WebSearchTabViewModel(CliPreferences preferences, ReadOnlyBooleanProperty } private void setupPlainCitationParsers() { + // [pp->feat~ai.citation-parsing~1] if (!refAiEnabled.get()) { plainCitationParsers.remove(PlainCitationParserChoice.LLM); } refAiEnabled.addListener((_, _, newValue) -> { if (newValue) { + // [impl->feat~ai.citation-parsing~1] plainCitationParsers.add(PlainCitationParserChoice.LLM); } else { PlainCitationParserChoice oldChoice = defaultPlainCitationParser.get(); diff --git a/jabgui/src/main/java/org/jabref/gui/util/BaseDialog.java b/jabgui/src/main/java/org/jabref/gui/util/BaseDialog.java index 2ab1dd71ecc1..81ed5b470f3e 100644 --- a/jabgui/src/main/java/org/jabref/gui/util/BaseDialog.java +++ b/jabgui/src/main/java/org/jabref/gui/util/BaseDialog.java @@ -12,6 +12,7 @@ import javafx.scene.input.KeyCode; import javafx.scene.layout.Region; import javafx.stage.Stage; +import javafx.stage.Window; import org.jabref.gui.icon.IconTheme; import org.jabref.gui.keyboard.KeyBinding; @@ -61,7 +62,7 @@ private void setDialogIcon(Image image) { dialogWindow.getIcons().add(image); } - /// Applies a fix to prevent truncating ButtonBar buttons with larger font sizes + /// Applies a fix to prevent truncating ButtonBar buttons with larger font sizes public static void applyButtonFix(DialogPane pane) { // Force the window to fit the new font content bounds if (pane.getScene() != null && pane.getScene().getWindow() != null) { @@ -83,4 +84,14 @@ public static void applyButtonFix(DialogPane pane) { } } } + + public static void bringToFront(Dialog dialog) { + // Using answers from: and . + + Window window = dialog.getDialogPane().getScene().getWindow(); + if (window instanceof Stage stage) { + stage.setAlwaysOnTop(true); + stage.setAlwaysOnTop(false); + } + } } diff --git a/jabgui/src/main/java/org/jabref/gui/util/BaseWindow.java b/jabgui/src/main/java/org/jabref/gui/util/BaseWindow.java deleted file mode 100644 index d9ff944d9c00..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/util/BaseWindow.java +++ /dev/null @@ -1,43 +0,0 @@ -package org.jabref.gui.util; - -import javafx.collections.ObservableList; -import javafx.scene.Scene; -import javafx.scene.layout.Pane; -import javafx.stage.Modality; -import javafx.stage.Stage; - -import org.jabref.gui.icon.IconTheme; -import org.jabref.gui.keyboard.KeyBinding; -import org.jabref.gui.keyboard.KeyBindingRepository; - -import com.airhacks.afterburner.injection.Injector; - -/// A base class for non-modal windows of JabRef. -/// -/// You can create a new instance of this class and set the title in the constructor. After that you can call -/// {@link org.jabref.gui.DialogService#showCustomWindow(BaseWindow)} in order to show the window. All the JabRef styles -/// will be applied. -/// -/// See {@link org.jabref.gui.ai.components.aichat.AiChatWindow} for example. -public class BaseWindow extends Stage { - public BaseWindow() { - this.initModality(Modality.NONE); - this.getIcons().add(IconTheme.getJabRefImage()); - - setScene(new Scene(new Pane())); - - sceneProperty().addListener((obs, oldValue, newValue) -> { - newValue.setOnKeyPressed(event -> { - KeyBindingRepository keyBindingRepository = Injector.instantiateModelOrService(KeyBindingRepository.class); - if (keyBindingRepository.checkKeyCombinationEquality(KeyBinding.CLOSE, event)) { - close(); - onCloseRequestProperty().get().handle(null); - } - }); - }); - } - - public void applyStylesheets(ObservableList stylesheets) { - this.getScene().getStylesheets().setAll(stylesheets); - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/util/BindingsHelper.java b/jabgui/src/main/java/org/jabref/gui/util/BindingsHelper.java index a65449b51c2d..c2ccf9f83c00 100644 --- a/jabgui/src/main/java/org/jabref/gui/util/BindingsHelper.java +++ b/jabgui/src/main/java/org/jabref/gui/util/BindingsHelper.java @@ -1,15 +1,20 @@ package org.jabref.gui.util; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.function.Consumer; import java.util.function.Function; import java.util.function.Predicate; +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; +import javafx.beans.binding.Bindings; import javafx.beans.binding.BooleanBinding; import javafx.beans.binding.ObjectBinding; import javafx.beans.binding.StringBinding; import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; import javafx.beans.property.Property; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; @@ -19,6 +24,8 @@ import javafx.collections.ObservableList; import javafx.collections.ObservableMap; import javafx.css.PseudoClass; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; import javafx.scene.Node; import javafx.scene.control.Tab; @@ -30,9 +37,51 @@ import com.tobiasdiez.easybind.Subscription; /// Helper methods for javafx binding. Some methods are taken from https://bugs.openjdk.java.net/browse/JDK-8134679 -public class BindingsHelper { - +public final class BindingsHelper { private BindingsHelper() { + throw new UnsupportedOperationException("cannot instantiate a utility class"); + } + + /// A variant of [EasyBind#listen] that uses [Runnable] as listeners + public static Subscription listen(Observable observable, Runnable... runnables) { + InvalidationListener listener = _ -> { + for (Runnable runnable : runnables) { + runnable.run(); + } + }; + + observable.addListener(listener); + + return () -> observable.removeListener(listener); + } + + /// A variant of [EasyBind#listen] that uses [Consumer] as listeners. + @SafeVarargs + public static Subscription listen(ObservableValue observable, Consumer... consumers) { + ChangeListener listener = (_, _, value) -> { + for (Consumer consumer : consumers) { + consumer.accept(value); + } + }; + + observable.addListener(listener); + + return () -> observable.removeListener(listener); + } + + /// A variant of [EasyBind#listen] that uses [Runnable] as a listener and listens to several observables. + public static Subscription listen(Runnable runnable, Observable... observables) { + InvalidationListener listener = _ -> runnable.run(); + + for (Observable observable : observables) { + observable.addListener(listener); + } + + return () -> { + for (Observable observable : observables) { + observable.removeListener(listener); + } + }; } public static Subscription includePseudoClassWhen(Node node, PseudoClass pseudoClass, ObservableValue condition) { @@ -239,6 +288,58 @@ public static void bindContentFiltered(ObservableList source, ObservableLis }); } + /// Bind the enum property value to multiple conditions. Useful for making state machines. + @SafeVarargs + public static > void bindEnum( + Property target, + E otherwise, + Map.Entry>... cases + ) { + ObjectBinding binding = Bindings.createObjectBinding(() -> { + for (var c : cases) { + if (Boolean.TRUE.equals(c.getValue().getValue())) { + return c.getKey(); + } + } + return otherwise; + }, + Arrays.stream(cases) + .map(Map.Entry::getValue) + .toArray(ObservableValue[]::new)); + + target.bind(binding); + } + + /// A shorthand to perform actions on list modification: one function that processes added elements, and one for removed. + public static void listenToListContentChanges(ListProperty listProperty, Consumer onAdded, Consumer onRemoved) { + listProperty.addListener((ListChangeListener) c -> { + while (c.next()) { + if (c.wasRemoved()) { + c.getRemoved().forEach(onRemoved); + } + if (c.wasAdded()) { + c.getAddedSubList().forEach(onAdded); + } + } + }); + } + + /// A shorthand to make a [ListChangeListener] out of the [Runnable]. + public static void listenToListChange(ListProperty listProperty, Runnable... actions) { + listProperty.addListener((ListChangeListener) _ -> { + for (Runnable action : actions) { + action.run(); + } + }); + } + + /// A horthand for calling the properties of action event handlers. + public static void handle(ObjectProperty> handler) { + if (handler.get() != null) { + handler.get().handle(null); + } + } + private static class BidirectionalBinding { private final ObservableValue propertyA; diff --git a/jabgui/src/main/java/org/jabref/gui/util/DynamicallyChangeableNode.java b/jabgui/src/main/java/org/jabref/gui/util/DynamicallyChangeableNode.java deleted file mode 100644 index 473a9c7b1e07..000000000000 --- a/jabgui/src/main/java/org/jabref/gui/util/DynamicallyChangeableNode.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.jabref.gui.util; - -import javafx.scene.Node; -import javafx.scene.layout.Priority; -import javafx.scene.layout.VBox; - -/// A node that can change its content using a setContent(Node) method, similar to {@link javafx.scene.control.Tab}. -/// -/// It is used in places where the content is changed dynamically, but you have to provide a one {@link Node} and set it -/// only once. -/// -/// See {@link org.jabref.gui.ai.components.privacynotice.AiPrivacyNoticeGuardedComponent#rebuildUi()} for example. -public class DynamicallyChangeableNode extends VBox { - protected void setContent(Node node) { - getChildren().clear(); - VBox.setVgrow(node, Priority.ALWAYS); - getChildren().add(node); - } -} diff --git a/jabgui/src/main/java/org/jabref/gui/util/ExceptionsUtil.java b/jabgui/src/main/java/org/jabref/gui/util/ExceptionsUtil.java new file mode 100644 index 000000000000..f6673baf9a5c --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/util/ExceptionsUtil.java @@ -0,0 +1,23 @@ +package org.jabref.gui.util; + +import java.io.PrintWriter; +import java.io.StringWriter; + +import jakarta.annotation.Nullable; + +public final class ExceptionsUtil { + private ExceptionsUtil() { + throw new UnsupportedOperationException("cannot instantiate utility class"); + } + + public static String generateExceptionMessage(@Nullable Throwable throwable) { + if (throwable == null) { + return ""; + } + + StringWriter sw = new StringWriter(); + PrintWriter pw = new PrintWriter(sw); + throwable.printStackTrace(pw); + return sw.toString(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/util/LocaleUtil.java b/jabgui/src/main/java/org/jabref/gui/util/LocaleUtil.java new file mode 100644 index 000000000000..3ee844762baa --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/util/LocaleUtil.java @@ -0,0 +1,26 @@ +package org.jabref.gui.util; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.Locale; + +import jakarta.annotation.Nullable; + +public final class LocaleUtil { + private LocaleUtil() { + throw new UnsupportedOperationException("cannot instantiate a utility class"); + } + + public static String formatInstant(@Nullable Instant instant) { + if (instant == null) { + return ""; + } + + return instant + .atZone(ZoneId.systemDefault()) + .format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM) + .withLocale(Locale.getDefault())); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/util/URLs.java b/jabgui/src/main/java/org/jabref/gui/util/URLs.java index 18550fb9615b..38a31b231966 100644 --- a/jabgui/src/main/java/org/jabref/gui/util/URLs.java +++ b/jabgui/src/main/java/org/jabref/gui/util/URLs.java @@ -43,4 +43,7 @@ public class URLs { public static final String SEMANTIC_SCHOLAR_URL = "https://www.semanticscholar.org/"; public static final String SCITE_REPORTS_URL_BASE = "https://scite.ai/reports/"; public static final String SCITE_URL = "https://scite.ai/"; + + // AI URLs + public static final String DJL_PRIVACY_POLICY_URL = "https://github.com/deepjavalibrary/djl/discussions/3370#discussioncomment-10233632"; } diff --git a/jabgui/src/main/java/org/jabref/gui/util/UiTaskExecutor.java b/jabgui/src/main/java/org/jabref/gui/util/UiTaskExecutor.java index 13b67eea1792..e218dfd58089 100644 --- a/jabgui/src/main/java/org/jabref/gui/util/UiTaskExecutor.java +++ b/jabgui/src/main/java/org/jabref/gui/util/UiTaskExecutor.java @@ -198,7 +198,7 @@ protected V call() throws Exception { // Set to 100% completed on completion task.updateProgress(1, 1); - if (onSuccess != null) { + if (onSuccess != null && !javaTask.isCancelled()) { onSuccess.accept(javaTask.getValue()); } }); diff --git a/jabgui/src/main/java/org/jabref/gui/util/component/HistoryTextArea.java b/jabgui/src/main/java/org/jabref/gui/util/component/HistoryTextArea.java new file mode 100644 index 000000000000..4fa125580434 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/util/component/HistoryTextArea.java @@ -0,0 +1,155 @@ +package org.jabref.gui.util.component; + +import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.event.ActionEvent; +import javafx.event.EventHandler; +import javafx.scene.control.TextArea; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; + +/// A custom JavaFX TextArea that provides shell-like history navigation, smart multiline handling, +/// and auto-expanding height. +public class HistoryTextArea extends TextArea { + + private static final int NEW_MESSAGE_INDEX = -1; + private static final int MAX_EXPAND_ROWS = 10; + + private final ListProperty history = new SimpleListProperty<>(FXCollections.observableArrayList()); + private int currentHistoryIndex = NEW_MESSAGE_INDEX; + private boolean isBrowsingHistory = false; + private final ObjectProperty> onSubmit = new SimpleObjectProperty<>(); + + public HistoryTextArea() { + super(); + this.setWrapText(true); + this.setPrefRowCount(1); + + this.textProperty().addListener((observable, oldValue, newValue) -> { + int newLines = 1; + if (newValue != null) { + for (int i = 0; i < newValue.length(); i++) { + if (newValue.charAt(i) == '\n') { + newLines++; + } + } + } + this.setPrefRowCount(Math.min(newLines, MAX_EXPAND_ROWS)); + }); + + history.addListener((obs, oldVal, newVal) -> resetHistoryState()); + this.addEventFilter(KeyEvent.KEY_PRESSED, this::handleKeyPressed); + } + + public ObjectProperty> onSubmitProperty() { + return onSubmit; + } + + public void setOnSubmit(EventHandler onSubmit) { + this.onSubmit.set(onSubmit); + } + + public EventHandler getOnSubmit() { + return onSubmit.get(); + } + + public ObservableList getHistory() { + return history.get(); + } + + private void handleKeyPressed(KeyEvent keyEvent) { + KeyCode code = keyEvent.getCode(); + + if (code == KeyCode.DOWN) { + handleDownKey(keyEvent); + } else if (code == KeyCode.UP) { + handleUpKey(keyEvent); + } else if (code == KeyCode.ENTER) { + handleEnterKey(keyEvent); + } else { + boolean isNavigation = code.isNavigationKey(); + boolean isModifier = code.isModifierKey(); + + if (!isNavigation && !isModifier) { + isBrowsingHistory = false; + currentHistoryIndex = NEW_MESSAGE_INDEX; + } + } + } + + private void handleEnterKey(KeyEvent event) { + if (event.isShiftDown()) { + this.replaceSelection("\n"); + event.consume(); + return; + } + + event.consume(); + String text = this.getText().trim(); + + if (!text.isEmpty() && onSubmit.get() != null) { + resetHistoryState(); + onSubmit.get().handle(new ActionEvent()); + } + } + + private void handleUpKey(KeyEvent event) { + int caret = getCaretPosition(); + String textBeforeCaret = getText().substring(0, caret); + if (textBeforeCaret.contains("\n")) { + return; + } + + boolean canGoBack = (currentHistoryIndex < history.getSize() - 1); + boolean shouldJump = getText().isEmpty() || isBrowsingHistory; + + if (canGoBack && shouldJump) { + isBrowsingHistory = true; + currentHistoryIndex++; + updateTextFromHistory(false); + event.consume(); + } + } + + private void handleDownKey(KeyEvent event) { + int caret = getCaretPosition(); + String textAfterCaret = getText().substring(caret); + if (textAfterCaret.contains("\n")) { + return; + } + + if (currentHistoryIndex != NEW_MESSAGE_INDEX) { + isBrowsingHistory = true; + currentHistoryIndex--; + + if (currentHistoryIndex == NEW_MESSAGE_INDEX) { + this.clear(); + } else { + updateTextFromHistory(true); + } + event.consume(); + } + } + + private void updateTextFromHistory(boolean setCursorAtStart) { + if (currentHistoryIndex >= 0 && currentHistoryIndex < history.getSize()) { + String msg = history.get(currentHistoryIndex); + this.setText(msg); + + if (setCursorAtStart) { + this.positionCaret(0); + } else { + this.positionCaret(this.getText().length()); + } + } + } + + private void resetHistoryState() { + currentHistoryIndex = NEW_MESSAGE_INDEX; + isBrowsingHistory = false; + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/util/component/ListScrollPane.java b/jabgui/src/main/java/org/jabref/gui/util/component/ListScrollPane.java new file mode 100644 index 000000000000..25412e86b276 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/util/component/ListScrollPane.java @@ -0,0 +1,209 @@ +package org.jabref.gui.util.component; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Function; + +import javafx.application.Platform; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.DoubleProperty; +import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.property.SimpleDoubleProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.collections.FXCollections; +import javafx.collections.ListChangeListener; +import javafx.collections.ObservableList; +import javafx.geometry.Insets; +import javafx.scene.Node; +import javafx.scene.control.ScrollPane; +import javafx.scene.layout.VBox; + +/// A container for rendering a list of items. it is not that smart as [javafx.scene.control.ListView], which can skip drawing components that are not visible, but it is flexible and supports custom spacing and auto-scroll. +/// +/// Mainly used in [org.jabref.gui.ai.chat.AiChatView]. +public class ListScrollPane extends ScrollPane { + + private final VBox contentContainer; + + private final ListProperty itemsProperty = new SimpleListProperty<>(FXCollections.observableArrayList()); + private final ObjectProperty> rendererProperty = new SimpleObjectProperty<>(); + private final BooleanProperty autoScrollToBottomProperty = new SimpleBooleanProperty(false); + private final DoubleProperty spacing = new SimpleDoubleProperty(0.0); + private final ObjectProperty contentPadding = new SimpleObjectProperty<>(Insets.EMPTY); + + private final ListChangeListener listContentListener = this::handleListContentChange; + + public ListScrollPane() { + this.contentContainer = new VBox(); + this.contentContainer.setFillWidth(true); + this.contentContainer.spacingProperty().bind(spacing); + this.contentContainer.paddingProperty().bind(contentPadding); + + setContent(this.contentContainer); + setFitToWidth(true); + setFitToHeight(true); + + setupItemReferenceListener(); + } + + private void setupItemReferenceListener() { + if (itemsProperty.get() != null) { + itemsProperty.get().addListener(listContentListener); + } + + itemsProperty.addListener((obs, oldList, newList) -> { + if (oldList == newList) { + return; + } + + if (oldList != null) { + oldList.removeListener(listContentListener); + } + + contentContainer.getChildren().clear(); + + if (newList != null) { + newList.addListener(listContentListener); + rebuildView(); + } + }); + } + + private void handleListContentChange(ListChangeListener.Change change) { + final Function renderer = getRenderer(); + if (renderer == null) { + return; + } + + boolean hadAdditions = false; + + while (change.next()) { + if (change.wasPermutated()) { + rebuildView(); + return; + } else if (change.wasUpdated()) { + for (int i = change.getFrom(); i < change.getTo(); i++) { + T item = change.getList().get(i); + Node newNode = renderer.apply(item); + if (i < contentContainer.getChildren().size()) { + contentContainer.getChildren().set(i, newNode); + } + } + } else { + if (change.wasRemoved()) { + contentContainer.getChildren().remove(change.getFrom(), change.getFrom() + change.getRemovedSize()); + } + if (change.wasAdded()) { + hadAdditions = true; + List newNodes = new ArrayList<>(); + for (T item : change.getAddedSubList()) { + newNodes.add(renderer.apply(item)); + } + if (change.getFrom() <= contentContainer.getChildren().size()) { + contentContainer.getChildren().addAll(change.getFrom(), newNodes); + } else { + contentContainer.getChildren().addAll(newNodes); + } + } + } + } + + if (isAutoScrollToBottom() && hadAdditions) { + scrollToBottom(); + } + } + + private void rebuildView() { + contentContainer.getChildren().clear(); + ObservableList list = getItems(); + Function renderer = getRenderer(); + + if (list != null && renderer != null) { + List nodes = new ArrayList<>(); + for (T item : list) { + nodes.add(renderer.apply(item)); + } + contentContainer.getChildren().setAll(nodes); + + if (isAutoScrollToBottom()) { + scrollToBottom(); + } + } + } + + public void scrollToBottom() { + // A single Platform.runLater fires before JavaFX's layout pass, so the + // content height may not yet reflect the newly added node. + // Using a double-runLater ensures we set vvalue=1.0 AFTER the layout + // pass in the intermediate pulse has computed the final content height. + Platform.runLater(() -> Platform.runLater(() -> setVvalue(1.0))); + } + + public final ObservableList getItems() { + return itemsProperty.get(); + } + + public final void setItems(ObservableList value) { + itemsProperty.set(value); + } + + public final ListProperty itemsProperty() { + return itemsProperty; + } + + public final Function getRenderer() { + return rendererProperty.get(); + } + + public final void setRenderer(Function value) { + rendererProperty.set(value); + rebuildView(); + } + + public final ObjectProperty> rendererProperty() { + return rendererProperty; + } + + public final boolean isAutoScrollToBottom() { + return autoScrollToBottomProperty.get(); + } + + public final void setAutoScrollToBottom(boolean value) { + autoScrollToBottomProperty.set(value); + + if (value) { + scrollToBottom(); + } + } + + public final BooleanProperty autoScrollToBottomProperty() { + return autoScrollToBottomProperty; + } + + public final DoubleProperty spacingProperty() { + return spacing; + } + + public final double getSpacing() { + return spacing.get(); + } + + public final void setSpacing(double value) { + spacing.set(value); + } + + public final ObjectProperty contentPaddingProperty() { + return contentPadding; + } + + public final Insets getContentPadding() { + return contentPadding.get(); + } + + public final void setContentPadding(Insets value) { + contentPadding.set(value); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/util/MarkdownTextFlow.java b/jabgui/src/main/java/org/jabref/gui/util/component/MarkdownTextFlow.java similarity index 99% rename from jabgui/src/main/java/org/jabref/gui/util/MarkdownTextFlow.java rename to jabgui/src/main/java/org/jabref/gui/util/component/MarkdownTextFlow.java index 7a2341d1f97b..4ee3f44b7264 100644 --- a/jabgui/src/main/java/org/jabref/gui/util/MarkdownTextFlow.java +++ b/jabgui/src/main/java/org/jabref/gui/util/component/MarkdownTextFlow.java @@ -1,4 +1,4 @@ -package org.jabref.gui.util; +package org.jabref.gui.util.component; import java.util.ArrayDeque; import java.util.Deque; @@ -13,6 +13,7 @@ import org.jabref.gui.clipboard.ClipBoardManager; import org.jabref.gui.edit.OpenBrowserAction; import org.jabref.gui.preferences.GuiPreferences; +import org.jabref.gui.util.SelectableTextFlow; import com.airhacks.afterburner.injection.Injector; import com.vladsch.flexmark.ast.BlockQuote; diff --git a/jabgui/src/main/java/org/jabref/gui/util/component/SimpleListView.java b/jabgui/src/main/java/org/jabref/gui/util/component/SimpleListView.java new file mode 100644 index 000000000000..7f3624891c35 --- /dev/null +++ b/jabgui/src/main/java/org/jabref/gui/util/component/SimpleListView.java @@ -0,0 +1,54 @@ +package org.jabref.gui.util.component; + +import java.util.function.Function; + +import javafx.beans.property.ListProperty; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleListProperty; +import javafx.beans.property.SimpleObjectProperty; +import javafx.scene.Node; +import javafx.scene.layout.HBox; + +/// A list view that is implemented using a combobox with customizable items. +/// +/// An equivalent to this component would be to use an [HBox] with [javafx.beans.binding.Bindings#bindContent], but unfortunately, that does not work well and makes duplicate nodes. +public class SimpleListView extends HBox { + private final ListProperty itemsProperty = new SimpleListProperty<>(); + private final ObjectProperty> rendererProperty = new SimpleObjectProperty<>(); + + public SimpleListView() { + this(0); + } + + public SimpleListView(int spacing) { + super(spacing); + + itemsProperty.addListener((_, _, values) -> { + getChildren().clear(); + + if (rendererProperty.get() == null) { + return; + } + + values.forEach(value -> { + getChildren().add(rendererProperty.get().apply(value)); + }); + }); + } + + public ListProperty itemsProperty() { + return itemsProperty; + } + + public ObjectProperty> rendererProperty() { + return rendererProperty; + } + + public void setRenderer(Function renderer) { + rendererProperty.set(renderer); + } + + public Function getRenderer() { + return rendererProperty.get(); + } +} diff --git a/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughRenderer.java b/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughRenderer.java index 622686b045dc..9e89ce182908 100644 --- a/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughRenderer.java +++ b/jabgui/src/main/java/org/jabref/gui/walkthrough/WalkthroughRenderer.java @@ -11,7 +11,7 @@ import org.jabref.gui.icon.IconTheme; import org.jabref.gui.icon.JabRefIconView; -import org.jabref.gui.util.MarkdownTextFlow; +import org.jabref.gui.util.component.MarkdownTextFlow; import org.jabref.gui.walkthrough.declarative.richtext.ArbitraryJFXBlock; import org.jabref.gui.walkthrough.declarative.richtext.InfoBlock; import org.jabref.gui.walkthrough.declarative.richtext.TextBlock; diff --git a/jabgui/src/main/resources/org/jabref/gui/ai/AiPrivacyNotice.fxml b/jabgui/src/main/resources/org/jabref/gui/ai/AiPrivacyNotice.fxml new file mode 100644 index 000000000000..c3ac43a82ecb --- /dev/null +++ b/jabgui/src/main/resources/org/jabref/gui/ai/AiPrivacyNotice.fxml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + diff --git a/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiChatMessage.fxml b/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiChatMessage.fxml new file mode 100644 index 000000000000..a990a2641cd0 --- /dev/null +++ b/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiChatMessage.fxml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiChatStatus.fxml b/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiChatStatus.fxml new file mode 100644 index 000000000000..4d9e562bb67d --- /dev/null +++ b/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiChatStatus.fxml @@ -0,0 +1,141 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiChatStatusWindow.fxml b/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiChatStatusWindow.fxml new file mode 100644 index 000000000000..b1defe1a5d9b --- /dev/null +++ b/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiChatStatusWindow.fxml @@ -0,0 +1,14 @@ + + + + + + + + + diff --git a/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiEntryChat.fxml b/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiEntryChat.fxml new file mode 100644 index 000000000000..981412a8c247 --- /dev/null +++ b/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiEntryChat.fxml @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiGroupChat.fxml b/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiGroupChat.fxml new file mode 100644 index 000000000000..63545fb73bc1 --- /dev/null +++ b/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiGroupChat.fxml @@ -0,0 +1,18 @@ + + + + + + + + + + + + diff --git a/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiGroupChatWindow.fxml b/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiGroupChatWindow.fxml new file mode 100644 index 000000000000..4fc8572a3577 --- /dev/null +++ b/jabgui/src/main/resources/org/jabref/gui/ai/chat/AiGroupChatWindow.fxml @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/AiChatComponent.fxml b/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/AiChatComponent.fxml deleted file mode 100644 index f2144930105a..000000000000 --- a/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/AiChatComponent.fxml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/chathistory/ChatHistoryComponent.fxml b/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/chathistory/ChatHistoryComponent.fxml deleted file mode 100644 index 0c6adb56cb9b..000000000000 --- a/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/chathistory/ChatHistoryComponent.fxml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - - - - - - - diff --git a/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/chatmessage/ChatMessageComponent.fxml b/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/chatmessage/ChatMessageComponent.fxml deleted file mode 100644 index 4b86791eb1b1..000000000000 --- a/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/chatmessage/ChatMessageComponent.fxml +++ /dev/null @@ -1,39 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/chatprompt/ChatPromptComponent.fxml b/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/chatprompt/ChatPromptComponent.fxml deleted file mode 100644 index fdf05978f8dc..000000000000 --- a/jabgui/src/main/resources/org/jabref/gui/ai/components/aichat/chatprompt/ChatPromptComponent.fxml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - -