Building a Real-Time Collaborative AI Editor with Quarkus, CRDTs, and Local LLMs
Learn how to combine CRDTs, WebSockets, and a local LLM to build a fast, conflict-free collaborative text editor with AI suggestions—entirely in Java.
The modern developer doesn’t just write code anymore, they shape collaborative, intelligent systems. In this tutorial, you'll build one such system: a real-time collaborative text editor with AI-powered writing suggestions. It's a full-stack Java application using Quarkus, LangChain4j, WebSockets, and a simplified CRDT model to keep concurrent edits conflict-free.
This isn't just another CRUD app. You're going to explore WebSockets for real-time collaboration, CRDTs for distributed consistency, and local large language models (LLMs) for AI-powered suggestions, all in a single Java-based project.
What You’ll Build
This project combines classic backend skills with modern AI tools:
A Quarkus backend using WebSockets for real-time collaboration
A conflict-free editing model using a sequence-based CRDT
A simple but powerful AI assistant built with LangChain4j
A local language model served automatically using Ollama via Dev Services
A lightweight HTML and JavaScript frontend that ties everything together
Prerequisites
Make sure you have the following installed before you continue:
JDK 17 or newer
Apache Maven 3.8+
Podman (or even Docker) Running and accessible. Quarkus Dev Services will spin up a local Ollama container automatically.
Project Setup: Generate Your Quarkus App
If you don’t want to follow along you are more than welcome to grab the source from my Github repository. When you do that, please also leave a star there :)
First, generate a new Quarkus project with the necessary extensions:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId="org.crdt.ai" \
-DprojectArtifactId="collaborative-editor" \
-Dextensions="websockets-next, langchain4j-ollama, rest-jackson"
cd collaborative-editor
Here's what each extension gives you:
websockets-next
: Modern WebSocket support with lifecycle annotationslangchain4j-ollama
: AI integration powered by local models using Ollamarest-jackson
: JSON handling over WebSocket messages
Backend Foundation: Sequence CRDT and AI Suggestions
Let’s start building the server-side logic.
Modeling Characters with CRDTs
Instead of managing the entire document as a string, we’ll model it as a list of characters, each with a unique ID. This ensures that concurrent insertions and deletions are unambiguous and conflict-free.
Create: src/main/java/org/crdt/ai/CrdtCharacter.java
package org.crdt.ai;
import java.util.UUID;
public record CrdtCharacter(char value, UUID id) {}
Next, create a singleton to manage shared state across WebSocket sessions:
src/main/java/org/crdt/ai/DocumentState.java
package org.crdt.ai;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.stream.Collectors;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class DocumentState {
private final List<CrdtCharacter> document = new CopyOnWriteArrayList<>();
public void insert(int index, CrdtCharacter character) {
if (index >= 0 && index <= document.size()) {
document.add(index, character);
}
}
public void delete(UUID characterId) {
document.removeIf(c -> c.id().equals(characterId));
}
public String getTextContent() {
return document.stream()
.map(c -> String.valueOf(c.value()))
.collect(Collectors.joining());
}
public List<CrdtCharacter> getFullDocument() {
return List.copyOf(document);
}
}
Structuring WebSocket Messages
To keep message handling clean, define a wrapper for all WebSocket messages.
Create: src/main/java/org/crdt/ai/websocket/WsMessage.java
package org.crdt.ai.websocket;
import com.fasterxml.jackson.databind.JsonNode;
public record WsMessage(String type, JsonNode payload) {}
Creating the AI Assistant
Now let’s add our AI capability. LangChain4j makes this simple. Define an interface annotated with @RegisterAiService
and provide a system prompt. Create: src/main/java/org/crdt/ai/AiAssistant.java
package org.crdt.ai;
import dev.langchain4j.service.SystemMessage;
import io.quarkiverse.langchain4j.RegisterAiService;
@RegisterAiService
public interface AiAssistant {
@SystemMessage("""
You are a helpful and creative writing assistant.
The user will provide you with a piece of text from their document.
Your task is to continue the text with one or two concise and creative sentences.
DO NOT repeat the user's text in your response. Just provide the new sentences.
Your response must be plain text, without any formatting.
""")
String suggest(String text);
}
Handling WebSocket Events
Now let’s wire everything together. This class will manage all WebSocket connections, message routing, document updates, and AI responses.
src/main/java/org/crdt/ai/websocket/DocumentSocket.java
package org.crdt.ai.websocket;
import java.util.UUID;
import org.crdt.ai.AiAssistant;
import org.crdt.ai.CrdtCharacter;
import org.crdt.ai.DocumentState;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.quarkus.logging.Log;
import io.quarkus.websockets.next.OnOpen;
import io.quarkus.websockets.next.OnTextMessage;
import io.quarkus.websockets.next.WebSocket;
import io.quarkus.websockets.next.WebSocketConnection;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@WebSocket(path = "/editor")
@ApplicationScoped
public class DocumentSocket {
@Inject
DocumentState documentState;
@Inject
AiAssistant assistant;
@Inject
ObjectMapper objectMapper; // For JSON conversion
@OnOpen
public void onOpen(WebSocketConnection connection) {
// When a new user connects, send them the current document state
// so they are in sync.
try {
WsMessage syncMessage = new WsMessage("SYNC_DOCUMENT",
objectMapper.valueToTree(documentState.getFullDocument()));
connection.sendText(objectMapper.writeValueAsString(syncMessage));
} catch (JsonProcessingException e) {
// Log error
}
}
@OnTextMessage(broadcast = true) // Broadcast messages to all connected clients
public String onMessage(WebSocketConnection connection, String message) {
try {
WsMessage wsMessage = objectMapper.readValue(message, WsMessage.class);
JsonNode payload = wsMessage.payload();
switch (wsMessage.type()) {
case "INSERT":
CrdtCharacter character = objectMapper.treeToValue(payload.get("char"), CrdtCharacter.class);
int index = payload.get("index").asInt();
documentState.insert(index, character);
// This message will be broadcasted to all clients, including the sender
return message;
case "DELETE":
UUID charId = UUID.fromString(payload.get("charId").asText());
documentState.delete(charId);
// Broadcast the deletion instruction
return message;
case "GET_SUGGESTION":
// AI suggestions should not be broadcasted.
// We handle this separately and send it only to the requester.
handleSuggestionRequest(connection);
return null; // Returning null prevents broadcasting
}
} catch (JsonProcessingException e) {
// Log error
}
return null; // Do not broadcast malformed messages
}
private void handleSuggestionRequest(WebSocketConnection connection) {
String currentText = documentState.getTextContent();
String suggestion;
Log.infof("Current text: '%s' (length: %d, isBlank: %s)",
currentText, currentText.length(), currentText.isBlank());
if (currentText.isBlank()) {
suggestion = "Start by typing a few words, and I'll help you continue!";
} else {
suggestion = assistant.suggest(currentText);
}
Log.infof("Sending suggestion: %s", suggestion);
try {
// Create a payload for the suggestion message
ObjectNode payload = objectMapper.createObjectNode();
payload.put("suggestion", suggestion);
WsMessage suggestionMessage = new WsMessage("SUGGESTION", payload);
// Send the suggestion back to only the requesting client
String messageJson = objectMapper.writeValueAsString(suggestionMessage);
Log.infof("Sending message: %s", messageJson);
connection.sendText(messageJson).subscribe().asCompletionStage();
} catch (JsonProcessingException e) {
Log.errorf(e, "Error sending suggestion: %s", e.getMessage());
}
}
}
This class handles:
Broadcasting real-time document updates (
INSERT
,DELETE
)Syncing the full document state when a client connects
Routing AI suggestions only to the requesting user
Frontend: Collaborative Editing with JavaScript
Now that the backend is functional, let’s create the user interface. This app requires no build step. Just place an HTML file in the resources/
META-INF/resources
directory.
Create: src/main/resources/META-INF/resources/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Collaborative AI Editor</title>
<style>
<!-- omitted. Check repository -->
</style>
</head>
<body>
<div class="container">
<h1>Collaborative AI Editor</h1>
<textarea id="editor" spellcheck="false"></textarea>
<div class="controls">
<button id="suggestBtn">Get Suggestion</button>
</div>
<div id="suggestion-box"></div>
</div>
<script>
const editor = document.getElementById('editor');
const suggestBtn = document.getElementById('suggestBtn');
const suggestionBox = document.getElementById('suggestion-box');
// This holds the local representation of the CRDT document
let localDocument = [];
let isSyncing = false; // A flag to prevent sending updates while processing one
let pendingOperations = new Set(); // Track operations we've sent but haven't received back
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const ws = new WebSocket(`${protocol}//${location.host}/editor`);
ws.onopen = () => {
console.log('WebSocket connection established.');
// Initialize previousText when connection opens
previousText = editor.value;
};
ws.onclose = () => console.log('WebSocket connection closed.');
ws.onerror = (error) => console.error('WebSocket error:', error);
ws.onmessage = (event) => {
isSyncing = true;
try {
const msg = JSON.parse(event.data);
switch (msg.type) {
case 'SYNC_DOCUMENT':
localDocument = msg.payload;
pendingOperations.clear(); // Clear any pending operations on sync
updateEditorFromLocalDoc();
// Update previousText to match the synced document
previousText = editor.value;
break;
case 'INSERT':
const { char, index } = msg.payload;
// Check if this is our own operation that we already applied
if (pendingOperations.has(char.id)) {
pendingOperations.delete(char.id);
// Don't apply again - we already did it locally
} else {
// This is from another client - apply it
const currentCursor = editor.selectionStart;
const currentText = editor.value;
// Insert at the specified index, but validate it's within bounds
const insertIndex = Math.min(index, localDocument.length);
localDocument.splice(insertIndex, 0, char);
// Smart cursor adjustment: if insertion was before cursor, move cursor forward
const newCursor = insertIndex <= currentCursor ? currentCursor + 1 : currentCursor;
updateEditorFromLocalDoc(false); // Don't preserve cursor, we'll set it manually
editor.setSelectionRange(newCursor, newCursor);
}
break;
case 'DELETE':
const { charId } = msg.payload;
// Check if this is our own operation that we already applied
if (pendingOperations.has(charId)) {
pendingOperations.delete(charId);
// Don't apply again - we already did it locally
} else {
// This is from another client - apply it
const currentCursor = editor.selectionStart;
const deleteIndex = localDocument.findIndex(c => c.id === charId);
if (deleteIndex > -1) {
localDocument.splice(deleteIndex, 1);
// Smart cursor adjustment: if deletion was before cursor, move cursor back
const newCursor = deleteIndex < currentCursor ? Math.max(0, currentCursor - 1) : currentCursor;
updateEditorFromLocalDoc(false); // Don't preserve cursor, we'll set it manually
editor.setSelectionRange(newCursor, newCursor);
}
}
break;
case 'SUGGESTION':
const suggestion = msg.payload.suggestion;
suggestionBox.textContent = suggestion;
// If it's not the default empty message, insert the suggestion into the editor
if (suggestion && !suggestion.includes("Start by typing")) {
insertSuggestionIntoEditor(suggestion);
}
break;
}
} catch (e) {
console.error("Failed to process message", e);
} finally {
// Use a short timeout to allow the DOM to update before re-enabling input handling
setTimeout(() => { isSyncing = false; }, 50);
}
};
function updateEditorFromLocalDoc(preserveCursor = true) {
const text = localDocument.map(c => c.value).join('');
const cursorPos = preserveCursor ? editor.selectionStart : 0;
const selectionEnd = preserveCursor ? editor.selectionEnd : 0;
// Only update if text actually changed to avoid unnecessary cursor jumps
if (editor.value !== text) {
editor.value = text;
// Restore cursor position, but ensure it's within bounds
const maxPos = text.length;
const newCursorPos = Math.min(cursorPos, maxPos);
const newSelectionEnd = Math.min(selectionEnd, maxPos);
editor.setSelectionRange(newCursorPos, newSelectionEnd);
}
}
let previousText = "";
let isLocalChange = false;
editor.addEventListener('input', (e) => {
if (isSyncing) return;
isLocalChange = true;
const newText = editor.value;
const newCursorPos = editor.selectionStart;
// Find the differences between old and new text
const changes = findTextDifferences(previousText, newText);
// Process each change
changes.forEach(change => {
if (change.type === 'insert') {
// Handle character insertions
for (let i = 0; i < change.text.length; i++) {
const charValue = change.text[i];
const crdtChar = { value: charValue, id: crypto.randomUUID() };
const insertIndex = change.position + i;
// Apply locally AND send to server - but track this operation
localDocument.splice(insertIndex, 0, crdtChar);
pendingOperations.add(crdtChar.id);
sendMessage('INSERT', { char: crdtChar, index: insertIndex });
}
} else if (change.type === 'delete') {
// Handle character deletions
for (let i = 0; i < change.length; i++) {
const deletedChar = localDocument[change.position];
if (deletedChar) {
localDocument.splice(change.position, 1);
pendingOperations.add(deletedChar.id);
sendMessage('DELETE', { charId: deletedChar.id });
}
}
}
});
previousText = newText;
isLocalChange = false;
});
// Better text diffing function
function findTextDifferences(oldText, newText) {
const changes = [];
let oldIndex = 0;
let newIndex = 0;
while (oldIndex < oldText.length || newIndex < newText.length) {
if (oldIndex < oldText.length && newIndex < newText.length &&
oldText[oldIndex] === newText[newIndex]) {
// Characters match, move forward
oldIndex++;
newIndex++;
} else if (newIndex < newText.length &&
(oldIndex >= oldText.length || oldText[oldIndex] !== newText[newIndex])) {
// Character was inserted
let insertText = '';
const startPos = newIndex;
while (newIndex < newText.length &&
(oldIndex >= oldText.length || oldText[oldIndex] !== newText[newIndex])) {
insertText += newText[newIndex];
newIndex++;
}
changes.push({ type: 'insert', position: oldIndex, text: insertText });
} else if (oldIndex < oldText.length) {
// Character was deleted
let deleteCount = 0;
while (oldIndex < oldText.length &&
(newIndex >= newText.length || oldText[oldIndex] !== newText[newIndex])) {
oldIndex++;
deleteCount++;
}
changes.push({ type: 'delete', position: oldIndex - deleteCount, length: deleteCount });
}
}
return changes;
}
editor.addEventListener('focus', () => {
// Sync previousText with current editor content to ensure accurate diffing
previousText = editor.value;
});
suggestBtn.onclick = () => {
suggestionBox.textContent = '🤖 Getting suggestion...';
sendMessage('GET_SUGGESTION', {});
};
function sendMessage(type, payload) {
if (ws.readyState === WebSocket.OPEN) {
const msg = { type, payload };
ws.send(JSON.stringify(msg));
}
}
function insertSuggestionIntoEditor(suggestion) {
// Get current cursor position
const cursorPos = editor.selectionStart;
// Insert the suggestion at cursor position
const currentText = editor.value;
const beforeCursor = currentText.substring(0, cursorPos);
const afterCursor = currentText.substring(cursorPos);
// Add a space before the suggestion if needed
const spaceBefore = (beforeCursor.length > 0 && !beforeCursor.endsWith(' ')) ? ' ' : '';
const textToInsert = spaceBefore + suggestion;
// Temporarily disable syncing to prevent conflicts
isSyncing = true;
// Insert each character individually to maintain CRDT consistency
for (let i = 0; i < textToInsert.length; i++) {
const charValue = textToInsert[i];
const crdtChar = { value: charValue, id: crypto.randomUUID() };
const insertIndex = cursorPos + i;
// Apply locally AND send to server - but track this operation
localDocument.splice(insertIndex, 0, crdtChar);
pendingOperations.add(crdtChar.id);
sendMessage('INSERT', { char: crdtChar, index: insertIndex });
}
// Update the editor
updateEditorFromLocalDoc();
// Set cursor to end of inserted text
const newCursorPos = cursorPos + textToInsert.length;
editor.setSelectionRange(newCursorPos, newCursorPos);
// Update previousText and re-enable syncing
previousText = editor.value;
setTimeout(() => { isSyncing = false; }, 100);
}
</script>
</body>
The frontend handles:
Tracking the document state using CRDT characters
Listening to user input and converting changes into
INSERT
orDELETE
operationsDisplaying AI-generated suggestions
Maintaining sync across clients via WebSocket
The code is robust, and you’ve already accounted for edge cases like cursor restoration and basic diffing. No further changes needed.
Run the Application
Now it’s time to test everything.
./mvnw quarkus:dev
On the first run, Quarkus will automatically spin up an Ollama container (or use your local Ollama installation) and pull a local model (like llama3
). This might take a few seconds.
Once the server is running, open http://localhost:8080
in two browser windows. Start typing in one, and watch it instantly appear in the other.
Click “Get Suggestion” and the local AI assistant will generate a continuation based on the current document.
What You’ve Built
You now have a fully functional collaborative editor:
Real-time updates across clients using WebSockets
A simplified CRDT model that avoids data conflicts
Seamless AI suggestions via a local LLM
A frontend that interacts with the backend over a single WebSocket connection
This architecture is easily extendable. You could add user presence, document versioning, persistence with PostgreSQL, or a toolbar of AI commands.
This is more than a demo. It’s a blueprint for what modern Java-powered, AI-enhanced collaborative systems can look like.