ADR-069: WebSocket Chat Panel for Web UI
Status
Proposed
Date
2025-12-27
Context
The iDempiere Hub currently has three interfaces (CLI, Chat API, MCP Server) as defined in ADR-048. Users interact via:
- CLI - Terminal for developers
- Chat API - REST endpoint for integration (ZK/Angular)
- MCP Server - AI assistant integration (Claude Code/Desktop)
However, there's no native web UI for direct browser-based chat with the AI assistant. Users must either:
- Use the CLI (terminal required)
- Integrate Chat API into external apps (development effort)
- Use Claude Desktop with MCP (requires Claude subscription)
We need a simple, browser-based chat panel on port 8080 that allows users to interact with the iDempiere AI assistant directly.
Current Infrastructure
| Component | Status | Notes |
|---|---|---|
| Qute templates | Available | templates/ directory, base.html layout |
| REST Chat API | Available | /v1/chat/completions with SSE streaming |
| LangChain4j | Configured | Claude, Ollama, Bedrock providers |
| Port 8080 | In use | docs profile serves documentation |
| WebSocket | Not available | Need to add extension |
Options Evaluated
-
Fetch API + SSE - Simple, uses existing Chat API
- Pros: No new dependencies, works today
- Cons: SSE is unidirectional, limited real-time features
-
WebSocket (Jakarta) - Standard Jakarta WebSocket API
- Pros: Standard API, familiar to developers
- Cons: Verbose boilerplate, older API
-
WebSocket Next - Modern Quarkus-native WebSocket API
- Pros: Annotation-driven, reactive, simpler code, better Quarkus integration
- Cons: Not Jakarta standard (but this is internal)
Decision
We will implement a WebSocket-based chat panel using Quarkus WebSocket Next extension for real-time bidirectional communication.
Why WebSocket Next?
- Simpler API - Annotation-driven (
@WebSocket,@OnTextMessage) vs verbose Jakarta - Reactive integration - Native support for
Multi<String>streaming - Better Quarkus fit - Designed for Quarkus, not a Jakarta port
- Production-ready - Actively maintained, used in Quarkus quickstarts
Architecture
┌─────────────────────────────────────────────────────────────────────────────┐
│ CHAT PANEL ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ Browser Quarkus (port 8080) │
│ ══════ ═══════════════════ │
│ │
│ ┌──────────────┐ ┌──────────────────────────────────┐ │
│ │ chatPanel. │ WebSocket │ ChatWebSocket │ │
│ │ html │ ─────────────────│ @WebSocket("/ws/chat") │ │
│ │ │ ws://8080/ws/ │ │ │
│ │ - Message UI │ chat │ @OnTextMessage │ │
│ │ - Input form │ ◄───────────────│ Multi<String> onMessage(msg) │ │
│ │ - Stream │ Token stream │ │ │ │
│ └──────────────┘ │ ▼ │ │
│ │ ┌──────────────┐ │ │
│ │ │ ChatService │ │ │
│ │ │ (LangChain4j)│ │ │
│ │ └──────────────┘ │ │
│ └──────────────────────────────────┘ │
│ │
│ Page request: │
│ GET /chat ────────────────────────► Qute Template (chatPanel.html) │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Message Protocol
JSON-based protocol for structured communication:
// Client → Server
interface ChatRequest {
type: "message" | "cancel" | "clear";
content?: string;
conversationId?: string;
model?: string; // "claude-sonnet-4-5", "llama3.2", etc.
}
// Server → Client
interface ChatResponse {
type: "token" | "complete" | "error" | "tool_call" | "tool_result";
content?: string;
toolName?: string;
conversationId?: string;
timestamp: string;
}
Implementation
1. Add WebSocket Next Extension
<!-- pom.xml -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-websockets-next</artifactId>
</dependency>
2. WebSocket Endpoint
// src/main/java/org/idempiere/cli/chatapi/ws/ChatWebSocket.java
package org.idempiere.cli.chatapi.ws;
import io.quarkus.websocket.next.*;
import io.smallrye.mutiny.Multi;
import jakarta.inject.Inject;
@WebSocket(path = "/ws/chat")
public class ChatWebSocket {
@Inject
ChatStreamService chatStreamService;
@OnOpen
public void onOpen(WebSocketConnection connection) {
Log.infof("Chat session opened: %s", connection.id());
}
@OnTextMessage
public Multi<String> onMessage(String message, WebSocketConnection connection) {
ChatRequest request = parseRequest(message);
return switch (request.type()) {
case "message" -> chatStreamService
.streamChat(request.content(), request.model())
.map(this::formatResponse);
case "cancel" -> {
chatStreamService.cancel(connection.id());
yield Multi.createFrom().item(formatComplete());
}
case "clear" -> {
chatStreamService.clearHistory(connection.id());
yield Multi.createFrom().item(formatCleared());
}
};
}
@OnClose
public void onClose(WebSocketConnection connection) {
chatStreamService.cleanup(connection.id());
Log.infof("Chat session closed: %s", connection.id());
}
@OnError
public void onError(WebSocketConnection connection, Throwable error) {
Log.errorf(error, "WebSocket error for session: %s", connection.id());
}
private String formatResponse(StreamToken token) {
return new ChatResponse("token", token.content(), Instant.now()).toJson();
}
}
3. Chat Stream Service
// src/main/java/org/idempiere/cli/chatapi/ws/ChatStreamService.java
package org.idempiere.cli.chatapi.ws;
import dev.langchain4j.model.chat.StreamingChatLanguageModel;
import io.smallrye.mutiny.Multi;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class ChatStreamService {
@Inject
StreamingChatLanguageModel chatModel;
@Inject
AiToolRegistry toolRegistry;
public Multi<StreamToken> streamChat(String userMessage, String model) {
return Multi.createFrom().emitter(emitter -> {
chatModel.generate(
buildMessages(userMessage),
new StreamingResponseHandler<AiMessage>() {
@Override
public void onNext(String token) {
emitter.emit(new StreamToken(token));
}
@Override
public void onComplete(Response<AiMessage> response) {
emitter.complete();
}
@Override
public void onError(Throwable error) {
emitter.fail(error);
}
}
);
});
}
}
4. Chat Panel Template
<!-- src/main/resources/templates/chatPanel.html -->
{#include base.html}
{#title}Chat{/title}
{#content}
<div class="chat-container">
<header class="chat-header">
<h1>iDempiere AI Assistant</h1>
<select id="modelSelect">
<option value="claude-sonnet-4-5">Claude Sonnet 4.5</option>
<option value="llama3.2">Llama 3.2 (Local)</option>
</select>
</header>
<div id="messages" class="messages-container">
<div class="welcome-message">
<p>Ask me anything about iDempiere - tables, processes, callouts,
best practices, or code generation.</p>
</div>
</div>
<form id="chatForm" class="chat-form">
<textarea id="userInput"
placeholder="Ask about iDempiere..."
rows="2"></textarea>
<div class="form-actions">
<button type="button" id="clearBtn" class="btn-secondary">Clear</button>
<button type="submit" id="sendBtn" class="btn-primary">Send</button>
</div>
</form>
</div>
<style>
.chat-container {
display: flex;
flex-direction: column;
height: calc(100vh - 60px);
max-width: 900px;
margin: 0 auto;
padding: 1rem;
}
.chat-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 1rem 0;
}
.message {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
border-radius: 8px;
max-width: 85%;
}
.message.user {
background: var(--primary);
color: white;
margin-left: auto;
}
.message.assistant {
background: var(--code-bg);
}
.message.streaming::after {
content: "▋";
animation: blink 1s infinite;
}
@keyframes blink {
50% { opacity: 0; }
}
.chat-form {
border-top: 1px solid var(--border);
padding-top: 1rem;
}
.chat-form textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border);
border-radius: 8px;
resize: none;
font-family: inherit;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
.btn-primary {
background: var(--primary);
color: white;
border: none;
padding: 0.5rem 1.5rem;
border-radius: 6px;
cursor: pointer;
}
.btn-secondary {
background: transparent;
border: 1px solid var(--border);
padding: 0.5rem 1rem;
border-radius: 6px;
cursor: pointer;
}
</style>
<script>
const ws = new WebSocket(`ws://${location.host}/ws/chat`);
const messages = document.getElementById('messages');
const form = document.getElementById('chatForm');
const input = document.getElementById('userInput');
const modelSelect = document.getElementById('modelSelect');
let currentAssistantMessage = null;
ws.onopen = () => console.log('Connected to chat');
ws.onmessage = (event) => {
const response = JSON.parse(event.data);
if (response.type === 'token') {
if (!currentAssistantMessage) {
currentAssistantMessage = addMessage('assistant', '', true);
}
currentAssistantMessage.textContent += response.content;
} else if (response.type === 'complete') {
if (currentAssistantMessage) {
currentAssistantMessage.classList.remove('streaming');
}
currentAssistantMessage = null;
} else if (response.type === 'error') {
addMessage('assistant', `Error: ${response.content}`);
currentAssistantMessage = null;
}
};
ws.onerror = (error) => console.error('WebSocket error:', error);
ws.onclose = () => {
addMessage('system', 'Disconnected. Refresh to reconnect.');
};
form.addEventListener('submit', (e) => {
e.preventDefault();
const content = input.value.trim();
if (!content) return;
addMessage('user', content);
ws.send(JSON.stringify({
type: 'message',
content: content,
model: modelSelect.value
}));
input.value = '';
});
document.getElementById('clearBtn').addEventListener('click', () => {
ws.send(JSON.stringify({ type: 'clear' }));
messages.innerHTML = '';
});
function addMessage(role, content, streaming = false) {
const div = document.createElement('div');
div.className = `message ${role}${streaming ? ' streaming' : ''}`;
div.textContent = content;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
return div;
}
// Handle Enter to send, Shift+Enter for newline
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
form.dispatchEvent(new Event('submit'));
}
});
</script>
{/content}
{/include}
5. Chat Resource (Page Endpoint)
// src/main/java/org/idempiere/cli/chatapi/ws/ChatPanelResource.java
package org.idempiere.cli.chatapi.ws;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/chat")
public class ChatPanelResource {
@Inject
Template chatPanel;
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance get() {
return chatPanel.instance();
}
}
6. Configuration
# application.properties
# WebSocket configuration
quarkus.websockets-next.server.max-message-size=65536
# Chat panel profile
%chat-panel.quarkus.http.port=8080
%chat-panel.quarkus.http.host=0.0.0.0
Integration with Existing Chat Infrastructure
The WebSocket endpoint reuses existing infrastructure:
┌─────────────────────────────────────────────────────────────────────────────┐
│ SHARED INFRASTRUCTURE (74%) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ChatWebSocket ───────┐ │
│ (new, WebSocket) │ │
│ ▼ │
│ ChatApiResource ─────┬───► ChatService ───► LangChain4j ───► AI Models │
│ (existing, REST) │ │ │
│ │ ├───► AiToolRegistry │
│ McpChatTools ────────┘ ├───► RagService │
│ (existing, MCP) └───► ConversationMemory │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Features
- Real-time streaming - Token-by-token display as AI generates response
- Model selection - Switch between Claude, Ollama, Bedrock
- Conversation memory - Maintains context within session
- Tool execution - AI can use iDempiere tools (search, query, generate)
- Cancel support - Abort long-running responses
- Mobile responsive - Works on tablets and phones
URL Structure
| Path | Method | Description |
|---|---|---|
/chat |
GET | Chat panel HTML page |
/ws/chat |
WebSocket | Real-time chat endpoint |
Security Considerations
For production deployment:
# OIDC protection for chat panel
quarkus.http.auth.permission.chat.paths=/chat,/ws/chat
quarkus.http.auth.permission.chat.policy=authenticated
Implementation Phases
Phase 1: Basic Chat (MVP)
- [ ] Add
quarkus-websockets-nextdependency - [ ] Create
ChatWebSocketendpoint - [ ] Create
ChatStreamServicewith streaming - [ ] Add
chatPanel.htmltemplate - [ ] Register in
docsprofile
Phase 2: Enhanced Features
- [ ] Model selection dropdown
- [ ] Conversation memory per session
- [ ] Tool call visualization
- [ ] Markdown rendering in responses
Phase 3: Production Hardening
- [ ] OIDC authentication
- [ ] Rate limiting per user
- [ ] Connection management (heartbeat, reconnect)
- [ ] Audit logging
Consequences
Positive
- Direct browser access to AI assistant
- Real-time streaming UX (feels responsive)
- Reuses 74% shared infrastructure
- No external dependencies for users
- Mobile-friendly interface
Negative
- New extension dependency (
websockets-next) - WebSocket connections require careful lifecycle management
- Not Jakarta WebSocket standard (but internal use only)
Risks
- WebSocket connections may timeout behind proxies
- Memory pressure from many concurrent sessions
- LLM rate limits with high traffic
Mitigations
- Implement heartbeat/ping to keep connections alive
- Set max concurrent connections per server
- Add rate limiting and queue for LLM calls
- Consider SSE fallback for proxy-challenged environments
Alternatives Considered
Server-Sent Events (SSE)
- Pros: Simpler, works through most proxies
- Cons: Unidirectional (client → server needs separate POST)
- Verdict: Good fallback, but WebSocket provides better UX
HTMX + SSE
- Pros: No custom JavaScript, progressive enhancement
- Cons: Less control over streaming UX
- Verdict: Could be Phase 2 enhancement
Vaadin/ZK-style Framework
- Pros: Rich widget library
- Cons: Heavy, not aligned with Quarkus philosophy
- Verdict: Overkill for chat panel