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:

  1. CLI - Terminal for developers
  2. Chat API - REST endpoint for integration (ZK/Angular)
  3. 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:

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

  1. Fetch API + SSE - Simple, uses existing Chat API

    • Pros: No new dependencies, works today
    • Cons: SSE is unidirectional, limited real-time features
  2. WebSocket (Jakarta) - Standard Jakarta WebSocket API

    • Pros: Standard API, familiar to developers
    • Cons: Verbose boilerplate, older API
  3. 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?

  1. Simpler API - Annotation-driven (@WebSocket, @OnTextMessage) vs verbose Jakarta
  2. Reactive integration - Native support for Multi<String> streaming
  3. Better Quarkus fit - Designed for Quarkus, not a Jakarta port
  4. 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

  1. Real-time streaming - Token-by-token display as AI generates response
  2. Model selection - Switch between Claude, Ollama, Bedrock
  3. Conversation memory - Maintains context within session
  4. Tool execution - AI can use iDempiere tools (search, query, generate)
  5. Cancel support - Abort long-running responses
  6. 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)

Phase 2: Enhanced Features

Phase 3: Production Hardening

Consequences

Positive

Negative

Risks

Mitigations

Alternatives Considered

Server-Sent Events (SSE)

HTMX + SSE

Vaadin/ZK-style Framework

References

Quarkus Documentation

External Articles

Internal ADRs

Path: /docs/developers/architecture/idempiere-hub/069-websocket-chat-panel