Building a Desktop AI Chat Application with LangChain4j and Install4j
1. Introduction
In the rapidly evolving landscape of artificial intelligence (AI), there is a growing interest in harnessing the power of AI for various applications. While browser-based AI tools have gained popularity, they often come with limitations in terms of security and access to local computer resources. Java desktop applications, on the other hand, offer significant advantages by providing a secure environment and the ability to interact with local files and network resources. In this article, we will explore how to build a secure desktop AI chat application using LangChain4j and Install4j, leveraging the strengths of Java and Install4j to create a powerful and secure AI solution.
2. Setting up the development environment
To get started, we assume that you have a Java development environment set up on your machine. Note that LangChain4j requires Java 17 or higher to run as of August, 2024.
To include LangChain4j in a Maven project, add the following dependency to the project's pom.xml
file:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j</artifactId>
<version>LATEST_VERSION</version>
</dependency>
Replace LATEST_VERSION
with the actual version number of LangChain4j you want to use. Other ways of including LangChain4j are possible, but beyond the scope of this article.
To make calls to OpenAI, add the following dependency as well:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-open-ai</artifactId>
<version>LATEST_VERSION</version>
</dependency>
LangChain4j offers similar libraries to support Anthropic, Google, AWS, Azure, HuggingFace and many more. All vendors that support the ChatLanguageModel interface are treated the same within LangChain4j, which means you can swap them in and out with ease.
3. Creating the Java application
While JavaFX offers a more modern approach to UI development, we use Java Swing in this example to keep things simple and focused on AI functionality. However, keep in mind that the same UI controls and principles can be applied using JavaFX if you prefer.
3.1 Designing the user interface
The application has a straightforward UI consisting of:
A file picker component to allow users to select a file
A text area for users to enter their prompts or questions
A submit button to initiate AI processing
Here is sample code to create the user interface:
import java.awt.BorderLayout;
import java.awt.Dimension;
import java.awt.FlowLayout;
import java.io.File;
import javax.swing.JButton;
import javax.swing.JFileChooser;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JTextArea;
import javax.swing.JTextField;
import javax.swing.SwingUtilities;
import javax.swing.filechooser.FileNameExtensionFilter;
public class AIChatApplication extends JFrame {
private static final long serialVersionUID = 1L;
private File contextFile;
private JTextField fileNameField;
private JTextArea promptTextArea;
private JButton submitButton;
private JLabel successMessage;
public AIChatApplication() {
setTitle("AI Chat Application");
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setLayout(new BorderLayout());
// Create panel to select an input file
JPanel filePanel = new JPanel(new FlowLayout());
JButton fileButton = new JButton("Select file...");
fileButton.addActionListener(e -> openFileChooser());
fileNameField = new JTextField(15); //15 columns
fileNameField.setEditable(false);
filePanel.add(fileButton);
filePanel.add(fileNameField);
add(filePanel, BorderLayout.NORTH);
// Create the prompt text area
promptTextArea = new JTextArea();
promptTextArea.setLineWrap(true);
promptTextArea.setPreferredSize(new Dimension(500, 300));
JScrollPane scrollPane = new JScrollPane(promptTextArea);
add(scrollPane, BorderLayout.CENTER);
// Create panel for the submit button and success message
JPanel submitPanel = new JPanel(new FlowLayout());
submitButton = new JButton("Submit");
successMessage = new JLabel();
submitPanel.add(submitButton);
submitPanel.add(successMessage);
add(submitPanel, BorderLayout.SOUTH);
pack();
setLocationRelativeTo(null);
}
private JFileChooser createFileChooser() {
JFileChooser fileChooser = new JFileChooser();
fileChooser.setDialogTitle("Select a text file");
fileChooser.setFileSelectionMode(JFileChooser.FILES_ONLY);
fileChooser.setFileFilter(new FileNameExtensionFilter("Text files", "txt", "text"));
return fileChooser;
}
private void openFileChooser() {
JFileChooser fileChooser = createFileChooser();
// Show the dialog; wait until dialog is closed
int returnValue = fileChooser.showOpenDialog(this);
if (returnValue == JFileChooser.APPROVE_OPTION) {
contextFile = fileChooser.getSelectedFile();
fileNameField.setText(contextFile.getName());
} else {
// Treat cancel like a remove file request
contextFile = null;
fileNameField.setText("");
}
}
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
AIChatApplication application = new AIChatApplication();
application.setVisible(true);
});
}
}
Note that the "Select file..." button is used to populate the contextFile variable. If this variable is not null, when the user clicks "Submit" the contents of the selected file will be added to the chat message sent to an AI model.
This example only supports text files, because chat messages (currently) can only contain text-based content. Other file types can be supported by first converting them to text. LangChain4j provides support for loading .txt, .csv, .docx, .xlsx, and .pptx documents, although it relies on the Azure AI Document Intelligence Loader in some cases. The open-source docx4j library provides another alternative for .docx files.
3.2 Implementing the backend functionality
Now we can implement the backend functionality of the application. We'll integrate LangChain4j to handle the AI interactions, process user prompts, and generate responses.
First, create a method to handle a "Submit" button click:
private void processSubmit() {
String prompt = promptTextArea.getText();
if (prompt.length() == 0) {
System.err.println("enter a prompt before calling the AI model");
return;
}
StringBuilder bldr = new StringBuilder();
try {
if (contextFile != null) {
bldr.append("```\n");
bldr.append(Files.readString(contextFile.toPath()));
bldr.append("```\n");
}
} catch (IOException e) {
e.printStackTrace();
// Handle the exception
return;
}
bldr.append(prompt);
String modelName = "gpt-4o";
String apiKey = System.getenv("OPENAI_API_KEY");
String aiResponse = processWithAI(bldr.toString(), modelName, apiKey);
if (aiResponse != null) {
saveResponseToFile(aiResponse);
}
}
Next integrate LangChain4j to process the user prompts and generate AI responses.
This example uses the gpt-4o OpenAI model. As mentioned earlier, other AI vendors can be easily supported.
Note that this example stores the api key in a Java environment variable ("OPENAI_API_KEY"), which must be populated at runtime. This technique avoids hard-coding your secure api key in source code.
Here's a sample code snippet:
private String processWithAI(String prompt, String modelName, String apiKey) {
ArrayList<ChatMessage> messages = new ArrayList<ChatMessage>();
messages.add(new UserMessage(prompt));
OpenAiChatModel chatModel = OpenAiChatModel.builder()
.modelName(modelName)
.apiKey(apiKey)
.build();
try {
return chatModel.generate(messages).content().text();
} catch (Exception e) {
e.printStackTrace();
// Handle the exception
return null;
}
}
Next, implement the saveResponseToFile
method to save the AI-generated response to disk:
private void saveResponseToFile(String aiResponse) {
if (contextFile == null) {
// If no file is selected, just dump response to System.out
System.out.println(aiResponse);
successMessage.setText("response sent to System.out");
return;
}
try {
// Create a new file with the AI-generated content
String fileName = timestampedFileName();
File outputFile = new File(contextFile.getParentFile(), fileName);
Files.writeString(outputFile.toPath(), aiResponse);
successMessage.setText(fileName + " saved");
} catch (IOException e) {
e.printStackTrace();
// Handle the exception
successMessage.setText("error occurred");
}
}
Finally, to ensure the selected file is not overwritten, use the following method to create a unique file name:
private String timestampedFileName() {
StringBuilder bldr = new StringBuilder();
//this looks like 2024-07-05T11:07:36.639Z
String dataTime = ZonedDateTime.now().format(DateTimeFormatter.ISO_INSTANT);
String date = "";
String time = "";
int indexT = dataTime.indexOf('T');
int indexPeriod = dataTime.indexOf('.');
if (indexT != -1 && indexPeriod != -1) {
date = dataTime.substring(0, indexT).replace("-", "");
time = dataTime.substring(indexT + 1, indexPeriod).replace(":", "");
}
int periodIndex = contextFile.getName().indexOf('.');
String fileNameBase = periodIndex != -1 ?
contextFile.getName().substring(0, periodIndex) : contextFile.getName();
bldr.append(fileNameBase);
bldr.append("_");
bldr.append(date);
bldr.append("_");
bldr.append(time);
bldr.append(".txt");
//this looks like myfile_20240705_110736.txt
return bldr.toString();
}
With these backend methods in place, we can now connect them to the user interface. Add an action listener to the submit button to trigger the processSubmit() method:
submitButton.addActionListener(e -> processSubmit());
That's it! We have now implemented the core backend functionality of the application using LangChain4j. The application can now accept user-selected files, process them with an AI model, and save the AI-generated responses to disk.
Give it a try by selecting a file and entering a prompt like: "Tell me a joke".
4. Adding secure indexing
While the application can now process user-selected files and generate responses, we can take it a step further by enabling the AI to access and query a private content index. By creating an index (vector database) of content, we can provide the AI with additional knowledge and context.
This is also a good way to share large volumes of content that are too big to fit in the context window. This is how lots of organizations are building chat bots that function like experts regarding their specific website content.
4.1 Selecting an index
An index stores file embeddings, which are numerical representations of the content and meaning of each file. Embeddings allow for fast and accurate similarity searches, making it easier for the AI to find and utilize relevant information.
There are several options to create an index in LangChain4j:
Local file backed
Database backed
Remote hosted
In-memory
Local file backed indexes are supported with the langchain4j-chroma library. Chroma is an open-source vector store that supports multiple underlying storage options like local files, DuckDB for standalone use, or ClickHouse for scalability.
Database backed indexes are supported by several libraries, including the langchain4j-pgvector library. This is a PostgreSQL extension that enables efficient vector search in a generic PostgreSQL database. It’s often used for tasks like semantic search or example selection.
Graph databases can also be used, for example the langchain4j-neo4j library. Graph databases prioritize relationships between data points, making them useful for modeling densely interconnected data. They excel at navigating relationships and are powerful for complex network analysis. However, they might be less efficient for large-scale processing and cross-database queries.
Another powerful pattern is to let AI models call a SPARQL endpoint. SPARQL is an open standard for querying OWL ontologies or RDF databases. It supports complex pattern matching, filtering, and aggregation, which can be helpful when working with large and intricate ontologies. SPARQL also offers built-in support for ontology reasoning, which can infer additional knowledge based on an ontology's structure and axioms. LangChain4j has libraries for RDF4J and Jena.
Remote hosted indexes are supported by several libraries, including the langchain4j-pinecone library. Pinecone is a managed, cloud-native vector database that provides long-term memory for high-performance AI applications. It offers optimized storage and querying capabilities for embeddings, making it an excellent choice to efficiently store and retrieve vector embeddings. Pinecone also has a free tier to help kick start prototyping.
In-memory indexes are supported by the core langchain4j library using the InMemoryEmbeddingStore class. This approach is used in the following example code.
4.2 Creating an index and adding content
The following method provides an alternative to calling processWithAI(). It creates an in-memory index that stores some hard-coded animal and bird names.
private String processWithIndex(String prompt, String modelName, String apiKey) {
EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder()
.apiKey(apiKey)
.modelName(OpenAiEmbeddingModelName.TEXT_EMBEDDING_ADA_002)
.timeout(Duration.ofSeconds(15))
.build();
int actualMaxResults = 2;//on each interaction we will retrieve the 2 most relevant segments
double actualMinScore = 0.5;//we want to retrieve segments at least somewhat similar to user query
EmbeddingStore<TextSegment> embeddingStore = new InMemoryEmbeddingStore<>();
EmbeddingStoreContentRetriever contentRetriever = EmbeddingStoreContentRetriever.builder()
.embeddingStore(embeddingStore)
.embeddingModel(embeddingModel)
.maxResults(actualMaxResults)//langchain4j default is 3
.minScore(actualMinScore)//langchain4j default is 0.0
.build();
// Add demo content to index
String text = "1. African Elephant\r\n"
+ "2. Bengal Tiger\r\n"
+ "3. Cheetah\r\n"
+ "4. Dingo\r\n"
+ "5. Emperor Penguin\r\n"
+ "6. Fennec Fox\r\n"
+ "7. Gorilla\r\n"
+ "8. Harpy Eagle\r\n"
+ "9. Indian Rhinoceros\r\n"
+ "10. Jaguar\r\n"
+ "11. Koala\r\n"
+ "12. Lion\r\n"
+ "13. Macaw\r\n"
+ "14. Nile Crocodile\r\n"
+ "15. Orangutan\r\n"
+ "16. Peacock\r\n"
+ "17. Quokka\r\n"
+ "18. Red Panda\r\n"
+ "19. Snow Leopard\r\n"
+ "20. Tasmanian Devil\r\n"
+ "21. Uakari\r\n"
+ "22. Vulture\r\n"
+ "23. Walrus\r\n"
+ "24. X-ray Tetra\r\n"
+ "25. Yellow Mongoose\r\n"
+ "26. Zebra\r\n"
+ "27. Albatross\r\n"
+ "28. Bison\r\n"
+ "29. Capybara\r\n"
+ "30. Duckbill Platypus\r\n";
saveEmbeddings(text, embeddingStore, embeddingModel);
ArrayList<UserMessage> messages = new ArrayList<UserMessage>();
messages.add(new UserMessage(prompt));
OpenAiChatModel chatModel = OpenAiChatModel.builder()
.modelName(modelName)
.apiKey(apiKey)
.build();
try {
List<ChatMessage> augmentedMessages = promptWithRetriever(
messages,
embeddingStore,
embeddingModel,
contentRetriever,
modelName,
10000, null, null, null, null);
return chatModel.generate(augmentedMessages).content().text();
} catch (Exception e) {
e.printStackTrace();
// Handle the exception
return null;
}
}
The next method adds embeddings to the index.
private void saveEmbeddings(
String text,
EmbeddingStore<TextSegment> embeddingStore,
EmbeddingModel embeddingModel) {
Document document = new Document(text);
DocumentSplitter splitter = DocumentSplitters.recursive(300, 0);
List<TextSegment> segments = splitter.split(document);
List<Embedding> embeddings = embeddingModel.embedAll(segments).content();
embeddingStore.addAll(embeddings, segments);
}
The next method injects information about the index into the last user message.
It is deliberately more complex than it needs to be, as it accepts 4 parameters which are always null (QueryTransformer, ContentAggregator, ContentInjector and Executor). They are meant to provide avenues for learning more about LangChain4j.
QueryTransformer: enhances retrieval quality by modifying or expanding the original prompt
ContentAggregator: aggregates all content retrieved by all ContentRetrievers using all queries, with the most relevant content appearing at the beginning of the list
ContentInjector: responsible for injecting relevant content into messages, which can be used to ground the chat model's responses
Executor: used to execute agents, which use a language model as a reasoning engine to determine which actions to take and what the inputs to those actions should be
private List<ChatMessage> promptWithRetriever(
List<UserMessage> messages,
EmbeddingStore<TextSegment> embeddingStore,
EmbeddingModel embeddingModel,
EmbeddingStoreContentRetriever contentRetriever,
String modelName,
int maxTokens,
QueryTransformer queryTransformer,
ContentAggregator contentAggregator,
ContentInjector contentInjector,
Executor executor) {
int count = messages.size();
if (count == 0) {
System.err.println("at least 1 user message is required");
return Collections.emptyList();
}
Tokenizer tokenizer = new OpenAiTokenizer(modelName);
UserMessage lastMsg = messages.get(count - 1);
DefaultQueryRouter queryRouter = new DefaultQueryRouter(contentRetriever);
DefaultRetrievalAugmentor retrievalAugmentor = DefaultRetrievalAugmentor.builder()
.queryTransformer(queryTransformer)
.contentAggregator(contentAggregator)
.contentInjector(contentInjector)
.executor(executor)
.queryRouter(queryRouter) //queryRouter cannot be null
.build();
//this will auto-discard old messages if max tokens is exceeded
TokenWindowChatMemory chatMemory =
TokenWindowChatMemory.withMaxTokens(maxTokens, tokenizer);
Metadata metadata = Metadata.from(lastMsg, chatMemory.id(), chatMemory.messages());
//lots can happen here, but it only affects the last message
lastMsg = retrievalAugmentor.augment(lastMsg, metadata);
//may want to retain chatMemory in some way - for now always discard,
//but if any logic improves message list should retain changes
//chatMemory.add(userMessage);
StringBuilder bldr = new StringBuilder();
lastMsg.contents().stream()
.filter(content -> content instanceof TextContent)
.map(content -> (TextContent)content)
.forEach(textContent -> bldr.append(textContent.text() + "\n"));
String lastMsgText = bldr.toString();
UserMessage lastUserMsg = UserMessage.from(lastMsgText);
if (count == 1) {
return Collections.singletonList(lastUserMsg);
}
List<ChatMessage> newMessages = new ArrayList<ChatMessage>();
int max = count - 1;
for (int i=0; i<max; i++) {
newMessages.add(messages.get(i));
}
newMessages.add(lastUserMsg);
return newMessages;
}
To test this index, change the processSubmit() method to call processWithIndex() instead of processWithAI():
String aiResponse = processWithIndex(bldr.toString(), modelName, apiKey);
In this case you can skip selecting a file, and enter a simple prompt like: "Tell me a joke". The chat model will automatically consider the contents of whatever index you share with it. The result will display on the Java console, and should be something "funny" like:
"Why did the Zebra invite the Yellow Mongoose and the Vulture to his party? Because he heard they were the life of the ZOO-keeper's bash!".
Now we're ready to build an installer.
5. Building the installer with Install4j
This section walks through steps to create an Install4j project for the AI chat application. By the end of this section, you will have a ready-to-distribute installer that sets up the application on the user's machine with the appropriate permissions and configurations.
The process of compiling the Java source file into a jar is skipped here. Install4j requires a .jar file and a main method entry point for your application.
5.1 Create and configure an Install4j project
To get started, open Install4j and create a new project. Follow these steps to configure the project settings:
Set the project name and specify the destination directory for the generated installer files.
In the "General Settings" section, provide information about your application, such as the application name, version, publisher, and URL.
Configure the "Media" settings to specify the output media file format (e.g., EXE for Windows, DMG for macOS) and any additional options like compression and signing.
In the "Files" section, add your Java application files to the project. This typically includes the compiled Java .jar file, libraries, and any other required resources.
Configure the "Launchers" settings to create a launcher for your application. Specify the launcher name, icon, and any additional options like VM parameters or command-line arguments. Make sure to specify the main class of your application.
In the "Installer->Screens & Action" section, you can customize the installer user interface by adding or removing screens, modifying screen properties, and configuring actions to be performed during the installation process.
Pay special attention to the "Startup" step in the installer configuration. This is where you can specify the desktop permissions for your application. Install4j allows you to configure permissions based on user credentials, ensuring that the application has the necessary access rights to perform its intended functionality.
For example, you can configure the installer to request administrator privileges during installation, allowing the application to access restricted system resources or perform privileged operations.
You can also specify user-level permissions to control access to specific directories or network resources. One way to manage these permissions is through the "Change Windows file rights" action. This action lets you modify the file system permissions for a particular file or directory on Windows. You can set permissions such as read, write, and execute for specific users or groups.
Install4j also provides a range of options for desktop integration, including creating desktop shortcuts, adding the application to the system menu, and configuring file associations.
Once you have configured the necessary project settings, save the Install4j project file.
The next section explores the process of building the installer executable and preparing the application for deployment and distribution.
5.3 Building the installer executable
Install4j simplifies the process of generating installer files for various platforms.
Follow these steps to build the installer executable using Install4j:
Open your Install4j project and ensure that all the necessary configuration settings, files, and customizations are in place.
In the Install4j IDE, navigate to the "Build" menu and select "Build Project" or click on the "Build" button in the toolbar.
Install4j will start the build process and compile your project into an installer executable. The build progress will be displayed in the console view, showing the status and any relevant messages.
Once the build process is complete, Install4j will generate the installer executable file(s) based on your project configuration. The generated files will be located in the output directory specified in your project settings.
Locate the generated installer executable file(s) in the output directory. The file extension will depend on the target platform (e.g., .exe for Windows, .dmg for macOS).
Test the installer executable on the target platform(s) to ensure that it functions as expected. Install the application using the generated installer and verify that all the features, permissions, and configurations are properly set up.
If you encounter any issues during the installation or runtime, debug and troubleshoot the application using the Install4j IDE's debugging tools and log files. Make necessary adjustments to your project configuration or code to resolve any problems.
Once you are satisfied with the installer executable and have thoroughly tested it, you can proceed to distribute it to your end-users or make it available for download on your application's website or distribution channels.
In the next subsection, we will discuss the deployment and running of the application, focusing on how users can launch and interact with your AI chat application once it is installed on their systems.
5.4 Deploying and running the application
With the installer executable built and tested, your Java AI chat application is ready for deployment and distribution. The installer simplifies the process of setting up the application on users' machines, ensuring that all the necessary files, permissions, and configurations are properly established.
Once users have downloaded and run the installer executable, they can follow the installation wizard to guide them through the setup process. The installer will prompt users for any required information, such as the installation directory or license agreement acceptance, based on your project configuration.
After the installation is complete, users can launch the application using the provided desktop shortcuts or menu entries. The application will start up with the configured permissions and access rights, enabling users to interact with the AI-powered chat interface seamlessly.
By deploying your Java AI chat application using Install4j, you enable users to easily install and run the application on their machines, providing them with a powerful tool for interacting with AI.
6. Conclusion
In this article, we have explored the process of building a secure desktop AI chat application using LangChain4j and Install4j. By leveraging the power of Java and the robustness of Install4j, we have demonstrated the advantages of desktop AI applications over browser-based alternatives.
Desktop AI applications offer a range of benefits, including secure access to local system resources, seamless integration with existing workflows, and enhanced performance. By incorporating secure file indexing and desktop permissions, our Java AI chat application ensures that users can interact with AI technologies while maintaining the confidentiality and integrity of their data.
As AI continues to advance, the potential for desktop AI applications to transform various industries and workflows is immense. By empowering users with secure and intuitive tools, we can unlock new possibilities for enhanced productivity, decision-making, and innovation.
We encourage readers to further explore the capabilities of LangChain4j and Install4j and experiment with building their own desktop AI applications. With the right tools and techniques, developers can create powerful AI solutions that address real-world challenges and revolutionize the way we interact with technology.
Subscribe to my newsletter
Read articles from Rob Brown directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by