Developer Guide
This section covers the data model, configuration reference, AI pipeline architecture, required integration points, and extension points available to developers integrating AI Support into an application.
Data Model
AI Support persists entities across three domains. The conversation domain covers sessions, messages, participants, and draft messages. The configuration domain covers assistants, LLM definitions, prompts, and categories. The knowledge domain covers documents and their vector embeddings. Supporting entities handle chat memory, LLM exchange audit records, message attachments, prompt revisions, and channel integrations (WhatsApp, Facebook, Instagram).
Channels use joined-table inheritance: the common columns live in as_channel, discriminated by channel_type, and the platform-specific credentials live in as_channel_whatsapp (phone_number, meta_phone_id, access_token), as_channel_facebook (page_id, access_token), and as_channel_instagram (instagram_account_id, page_id, access_token). Each child table's primary key is also a foreign key to as_channel.
A session records three activity timestamps rather than one: last_public_activity is updated by messages everyone can see, last_admin_activity by administrator-only messages, and last_total_activity by both. The scheduled session closer selects on last_total_activity.
---
config:
layout: elk
---
erDiagram
as_session {
bigint id PK
string user_id
string type
string status
instant event_date
int rating
string rating_comment
bigint assistant_id FK
string summary
instant last_public_activity
instant last_admin_activity
instant last_total_activity
bigint channel_id FK
string external_id
}
as_message {
bigint id PK
bigint session_id FK
string user_id
string sender_type
text content
text context
instant event_date
boolean profanity
boolean processed
boolean admin_only
boolean partial
boolean failed_while_generating
boolean early_stop
string early_stopped_by
int content_token_count
bigint answered_by FK
bigint exchange_id
string external_id
}
as_participant {
bigint id PK
bigint session_id FK
string user_id
boolean is_admin
boolean is_assistant
boolean is_active
boolean subscribed
instant join_date
instant leave_date
string added_by
string removed_by
instant last_public_activity_seen
bigint last_message_seen FK
bigint last_message_sent FK
}
as_channel {
bigint id PK
string name
string channel_type
boolean enabled
boolean failed
instant last_status_change
bigint assistant_id FK
}
as_assistant {
bigint id PK
string name
boolean enabled
boolean default_assistant
boolean advanced_rag
bigint prompt_id FK
bigint llm_id FK
}
as_llm {
bigint id PK
string name
string type
boolean enabled
boolean system
string configuration
}
as_prompt {
bigint id PK
string name
text content
boolean system
boolean enabled
}
as_category {
bigint id PK
string name
string description
boolean enabled
}
as_document {
bigint id PK
string name
boolean enabled
boolean partial
boolean external_data
string external_id
instant last_update
}
as_embedding {
uuid id PK
text text
jsonb metadata
vector embedding
}
as_chat_memory {
bigint id PK
text message
}
as_llm_exchange {
bigint id PK
bigint session_id
bigint message_id
bigint assistant_id
string assistant_name
bigint llm_id
string llm_name
string llm_type
bigint prompt_id
text request
text response
int input_token_count
int output_token_count
int prompt_token_count
instant request_date
instant response_date
boolean advanced_rag
json embedding_chunks
}
as_draft_message {
bigint id PK
string user_id
bigint session_id FK
bigint assistant_id FK
text message
}
as_message_attachment {
bigint id PK
bigint message_id FK
string filename
string type
string mime_type
}
as_session ||--o{ as_message : "contains"
as_session ||--o{ as_participant : "has"
as_session }o--|| as_assistant : "uses"
as_session }o--o| as_channel : "arrived through"
as_channel }o--|| as_assistant : "answered by"
as_message }o--o| as_message : "answered by"
as_message ||--o{ as_message_attachment : "has"
as_assistant }o--|| as_llm : "uses"
as_assistant }o--|| as_prompt : "uses"
as_assistant }o--o{ as_category : "scoped to"
as_document }o--o{ as_category : "tagged with"
as_document ||--o{ as_embedding : "chunked into"
Database Schema
AI Support manages twenty-one tables. The schema below represents the DDL generated by Hibernate for PostgreSQL, which is the only supported database for this appjar: the as_embedding table requires the pgvector extension.
Two of the tables are not application entities. as_prompt_aud and revinfo are Hibernate Envers audit tables, and they are what implements the prompt revision history described in the Prompts page: every save appends a row to as_prompt_aud, and revtype distinguishes an insert from an update or a delete.
CREATE EXTENSION IF NOT EXISTS vector;
-- Configuration ------------------------------------------------------------
CREATE TABLE as_llm (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(255) NOT NULL,
type VARCHAR(255) NOT NULL,
configuration VARCHAR(255),
enabled BOOLEAN NOT NULL,
system BOOLEAN NOT NULL,
CONSTRAINT pk_as_llm PRIMARY KEY (id),
CONSTRAINT ck_as_llm_type CHECK (type IN ('OPEN_AI', 'OLLAMA'))
);
CREATE TABLE as_prompt (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
change_comment TEXT,
modified_by VARCHAR(255),
enabled BOOLEAN NOT NULL,
system BOOLEAN NOT NULL,
CONSTRAINT pk_as_prompt PRIMARY KEY (id)
);
CREATE TABLE as_category (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(255) NOT NULL,
description VARCHAR(255) NOT NULL,
enabled BOOLEAN NOT NULL,
CONSTRAINT pk_as_category PRIMARY KEY (id)
);
CREATE TABLE as_assistant (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(255) NOT NULL,
llm_id BIGINT NOT NULL,
prompt_id BIGINT NOT NULL,
enabled BOOLEAN NOT NULL,
default_assistant BOOLEAN NOT NULL,
advanced_rag BOOLEAN DEFAULT FALSE,
CONSTRAINT pk_as_assistant PRIMARY KEY (id),
CONSTRAINT fk_as_assistant_llm FOREIGN KEY (llm_id) REFERENCES as_llm (id),
CONSTRAINT fk_as_assistant_prompt FOREIGN KEY (prompt_id) REFERENCES as_prompt (id)
);
CREATE TABLE as_assistant_categories (
assistant_entity_id BIGINT NOT NULL,
categories_id BIGINT NOT NULL,
CONSTRAINT fk_as_assistant_categories_assistant FOREIGN KEY (assistant_entity_id) REFERENCES as_assistant (id),
CONSTRAINT fk_as_assistant_categories_category FOREIGN KEY (categories_id) REFERENCES as_category (id)
);
-- Channels (joined-table inheritance) --------------------------------------
CREATE TABLE as_channel (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(255),
channel_type VARCHAR(31) NOT NULL,
enabled BOOLEAN NOT NULL,
failed BOOLEAN NOT NULL,
last_status_change TIMESTAMP(6) WITH TIME ZONE,
assistant_id BIGINT,
CONSTRAINT pk_as_channel PRIMARY KEY (id),
CONSTRAINT fk_as_channel_assistant FOREIGN KEY (assistant_id) REFERENCES as_assistant (id),
CONSTRAINT ck_as_channel_type CHECK (channel_type IN ('FACEBOOK', 'INSTAGRAM', 'WHATSAPP'))
);
CREATE TABLE as_channel_whatsapp (
id BIGINT NOT NULL,
phone_number VARCHAR(255),
meta_phone_id VARCHAR(255),
access_token TEXT,
CONSTRAINT pk_as_channel_whatsapp PRIMARY KEY (id),
CONSTRAINT fk_as_channel_whatsapp FOREIGN KEY (id) REFERENCES as_channel (id)
);
CREATE TABLE as_channel_facebook (
id BIGINT NOT NULL,
page_id VARCHAR(255),
access_token TEXT,
CONSTRAINT pk_as_channel_facebook PRIMARY KEY (id),
CONSTRAINT fk_as_channel_facebook FOREIGN KEY (id) REFERENCES as_channel (id)
);
CREATE TABLE as_channel_instagram (
id BIGINT NOT NULL,
instagram_account_id VARCHAR(255),
page_id VARCHAR(255),
access_token TEXT,
CONSTRAINT pk_as_channel_instagram PRIMARY KEY (id),
CONSTRAINT fk_as_channel_instagram FOREIGN KEY (id) REFERENCES as_channel (id)
);
-- Conversations ------------------------------------------------------------
CREATE TABLE as_session (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
user_id VARCHAR(255) NOT NULL,
type VARCHAR(255) NOT NULL,
status VARCHAR(255) NOT NULL,
summary VARCHAR(255),
rating INTEGER NOT NULL,
rating_comment VARCHAR(255),
event_date TIMESTAMP(6) WITH TIME ZONE NOT NULL,
last_public_activity TIMESTAMP(6) WITH TIME ZONE,
last_admin_activity TIMESTAMP(6) WITH TIME ZONE,
last_total_activity TIMESTAMP(6) WITH TIME ZONE,
assistant_id BIGINT NOT NULL,
channel_id BIGINT,
external_id VARCHAR(255),
CONSTRAINT pk_as_session PRIMARY KEY (id),
CONSTRAINT fk_as_session_assistant FOREIGN KEY (assistant_id) REFERENCES as_assistant (id),
CONSTRAINT fk_as_session_channel FOREIGN KEY (channel_id) REFERENCES as_channel (id),
CONSTRAINT ck_as_session_status CHECK (status IN ('ACTIVE', 'ARCHIVED', 'CLOSED')),
CONSTRAINT ck_as_session_type CHECK (type IN ('AUTOMATIC', 'MANUAL'))
);
CREATE TABLE as_message (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
session_id BIGINT NOT NULL,
user_id VARCHAR(255) NOT NULL,
sender_type VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
context TEXT,
event_date TIMESTAMP(6) WITH TIME ZONE NOT NULL,
content_token_count INTEGER,
mentions VARCHAR(255)[],
profanity BOOLEAN NOT NULL,
processed BOOLEAN NOT NULL,
admin_only BOOLEAN NOT NULL,
partial BOOLEAN,
failed_while_generating BOOLEAN,
early_stop BOOLEAN,
early_stopped_by VARCHAR(255),
answered_by BIGINT,
exchange_id BIGINT,
external_id VARCHAR(255),
CONSTRAINT pk_as_message PRIMARY KEY (id),
CONSTRAINT fk_as_message_session FOREIGN KEY (session_id) REFERENCES as_session (id),
CONSTRAINT fk_as_message_answered_by FOREIGN KEY (answered_by) REFERENCES as_message (id),
CONSTRAINT ck_as_message_sender_type CHECK (sender_type IN ('WHATSAPP', 'FACEBOOK', 'INSTAGRAM',
'MANUAL', 'AUTOMATIC', 'ADMINISTRATOR', 'SYSTEM'))
);
CREATE TABLE as_message_attachment (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
message_id BIGINT,
filename VARCHAR(255) NOT NULL,
type VARCHAR(255) NOT NULL,
mime_type VARCHAR(255) NOT NULL,
content_store_type VARCHAR(255) NOT NULL,
text_content OID,
kb_size INTEGER,
CONSTRAINT pk_as_message_attachment PRIMARY KEY (id),
CONSTRAINT fk_as_message_attachment_message FOREIGN KEY (message_id) REFERENCES as_message (id),
CONSTRAINT ck_as_message_attachment_type CHECK (type IN ('TEXT', 'IMAGE', 'AUDIO', 'VIDEO', 'PDF')),
CONSTRAINT ck_as_message_attachment_store CHECK (content_store_type IN ('URL', 'BASE64', 'URI', 'PLAIN_TEXT'))
);
CREATE TABLE as_participant (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
session_id BIGINT,
user_id VARCHAR(255) NOT NULL,
is_admin BOOLEAN NOT NULL,
is_assistant BOOLEAN NOT NULL,
is_active BOOLEAN NOT NULL,
subscribed BOOLEAN,
join_date TIMESTAMP(6) WITH TIME ZONE NOT NULL,
leave_date TIMESTAMP(6) WITH TIME ZONE,
added_by VARCHAR(255),
removed_by VARCHAR(255),
last_public_activity_seen TIMESTAMP(6) WITH TIME ZONE,
last_summary_seen VARCHAR(255),
last_assistant_seen BIGINT,
last_message_seen BIGINT,
last_message_sent BIGINT,
last_public_message_sent BIGINT,
CONSTRAINT pk_as_participant PRIMARY KEY (id),
CONSTRAINT fk_as_participant_session FOREIGN KEY (session_id) REFERENCES as_session (id),
CONSTRAINT uq_as_participant_sent UNIQUE (last_message_sent),
CONSTRAINT uq_as_participant_public UNIQUE (last_public_message_sent)
);
CREATE TABLE as_draft_message (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
session_id BIGINT,
assistant_id BIGINT,
user_id VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
CONSTRAINT pk_as_draft_message PRIMARY KEY (id),
CONSTRAINT uq_as_draft_message UNIQUE (session_id, user_id, assistant_id)
);
CREATE TABLE as_chat_memory (
id BIGINT NOT NULL,
message TEXT,
CONSTRAINT pk_as_chat_memory PRIMARY KEY (id)
);
-- Knowledge base -----------------------------------------------------------
CREATE TABLE as_document (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
name VARCHAR(255) NOT NULL,
owner VARCHAR(255),
enabled BOOLEAN NOT NULL,
partial BOOLEAN DEFAULT FALSE,
external_data BOOLEAN NOT NULL,
external_id VARCHAR(255),
last_update TIMESTAMP(6) WITH TIME ZONE NOT NULL,
CONSTRAINT pk_as_document PRIMARY KEY (id)
);
CREATE TABLE as_document_categories (
document_entity_id BIGINT NOT NULL,
categories_id BIGINT NOT NULL,
CONSTRAINT fk_as_document_categories_document FOREIGN KEY (document_entity_id) REFERENCES as_document (id),
CONSTRAINT fk_as_document_categories_category FOREIGN KEY (categories_id) REFERENCES as_category (id)
);
CREATE TABLE as_embedding (
embedding_id UUID NOT NULL,
text TEXT,
metadata JSONB,
embedding VECTOR(384),
CONSTRAINT pk_as_embedding PRIMARY KEY (embedding_id)
);
-- Audit --------------------------------------------------------------------
CREATE TABLE as_llm_exchange (
id BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL,
session_id BIGINT NOT NULL,
message_id BIGINT,
assistant_id BIGINT NOT NULL,
assistant_name VARCHAR(255) NOT NULL,
llm_id BIGINT NOT NULL,
llm_name VARCHAR(255) NOT NULL,
llm_type VARCHAR(255) NOT NULL,
llm_content TEXT NOT NULL,
prompt_id BIGINT,
prompt_name VARCHAR(255),
prompt_content TEXT,
category_names VARCHAR(255)[],
request TEXT NOT NULL,
response TEXT NOT NULL,
input_token_count INTEGER NOT NULL,
output_token_count INTEGER NOT NULL,
prompt_token_count INTEGER,
advanced_rag BOOLEAN,
rag_request_content TEXT,
rag_queries JSONB,
embedding_chunks JSONB,
rag_input_token_count INTEGER,
rag_output_token_count INTEGER,
rag_time_ms BIGINT,
rag_query_time_ms BIGINT,
request_date TIMESTAMP(6) WITH TIME ZONE NOT NULL,
response_date TIMESTAMP(6) WITH TIME ZONE,
CONSTRAINT pk_as_llm_exchange PRIMARY KEY (id),
CONSTRAINT ck_as_llm_exchange_type CHECK (llm_type IN ('OPEN_AI', 'OLLAMA'))
);
CREATE SEQUENCE revinfo_seq START WITH 1 INCREMENT BY 50;
CREATE TABLE revinfo (
rev INTEGER NOT NULL,
revtstmp BIGINT,
CONSTRAINT pk_revinfo PRIMARY KEY (rev)
);
CREATE TABLE as_prompt_aud (
rev INTEGER NOT NULL,
revtype SMALLINT,
id BIGINT NOT NULL,
name VARCHAR(255),
content TEXT,
change_comment TEXT,
modified_by VARCHAR(255),
CONSTRAINT pk_as_prompt_aud PRIMARY KEY (rev, id),
CONSTRAINT fk_as_prompt_aud FOREIGN KEY (rev) REFERENCES revinfo (rev)
);
A few details are worth calling out:
- Identifiers are
BIGINTidentity columns. The only sequence isrevinfo_seq, which Envers uses for revision numbers. as_chat_memory.idis not generated: it is the session id, which is what makes the memory of a session directly addressable.as_llm_exchangedenormalises deliberately. It stores the assistant, LLM, and prompt names and the prompt content alongside their ids, so an exchange can still be inspected after the configuration behind it has been edited or deleted. This is also why pruning old exchanges is a size concern:request,response, and the RAG columns hold full text.as_message.mentionsandas_llm_exchange.category_namesare PostgreSQL array columns, not join tables.as_embeddingis created and managed by the pgvector store rather than by an application entity, and its vector width must match the embedding model. It is the one table whose shape changes when the model is replaced.- The two join tables —
as_assistant_categoriesandas_document_categories— have no primary key of their own, following Hibernate's default for a@ManyToManycollection.
Module Overview
AI Support is structured as six Maven modules following the AppJars layered architecture:
| Module | Artifact ID | Description |
|---|---|---|
| Model | appjars-ai-support-model |
DTOs, enums, StreamingHandler, AiSupportConfiguration auto-configuration |
| Business API | appjars-ai-support-business |
AssistantService, SessionService, MessageService, MessageAttachmentService, DocumentService, LlmService, PromptService, CategoryService, LlmExchangeService, ParticipantService, ChatMemoryService, DraftMessageService, AuthenticatedUserProvider, UserProfilePictureProvider, SessionSummaryProvider interfaces |
| Business Impl | appjars-ai-support-business-impl |
Service implementations, AiSupportAutoConfiguration, LangChain4j wiring, RAG pipeline, RequestConfigurationFactory, ToolScanner, ScoringModelProvider |
| Data API | appjars-ai-support-data |
DAO interfaces |
| Data Impl | appjars-ai-support-data-impl |
JPA entities and DAO implementations |
| Flow UI | appjars-ai-support-flow |
Vaadin views, RouteConfigurer, chat components |
A monolithic application includes the three implementation modules (-business-impl, -data-impl, -flow). The API modules (-business, -data) are pulled in transitively.
Spring Auto-Configuration
AI Support registers itself through Spring Boot's auto-configuration mechanism. The entry point is AiSupportConfiguration, declared in:
META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
This class scans the com.appjars.aisupport package for all Spring components and JPA entities. The business implementation module's AiSupportAutoConfiguration class registers two additional beans at startup:
EmbeddingModel— defaults toE5SmallV2EmbeddingModel, a local ONNX model with 384-dimensional output. A custom model path can be supplied via properties; supplying a model without its tokenizer, or the other way round, fails at startup.EmbeddingStore— aPgVectorEmbeddingStorebacked by PostgreSQL with the pgvector extension, using theas_embeddingtable.
Two further components initialise themselves on startup events rather than through this configuration class:
ToolScannerscans the application context for@Tool-annotated beans once the application is ready. See Tool Calling.ScoringModelProviderloads the optional ONNX re-ranking model. See Re-ranking Retrieved Chunks.
Configuration Properties
Embedding Model
| Property | Default | Description |
|---|---|---|
appjars.aisupport.embedding.model |
(empty — uses built-in E5SmallV2) | Path to a custom ONNX embedding model file |
appjars.aisupport.embedding.tokenizer |
(empty — uses built-in) | Path to a custom tokenizer for the ONNX model |
appjars.aisupport.embedding.pooling-mode |
mean |
Pooling strategy for embedding generation (mean or cls) |
spring.ai.vectorstore.pgvector.dimensions |
384 |
Dimensionality of the embedding vectors stored in pgvector |
spring.ai.vectorstore.pgvector.table-name |
as_embedding |
Table name used by the pgvector store |
LLM and Moderation
| Property | Default | Description |
|---|---|---|
com.appjars.aisupport.models.timeout |
120 |
Timeout in seconds for LLM API calls |
com.appjars.aisupport.models.openai.moderation |
omni-moderation-latest |
OpenAI moderation model used for profanity detection |
appjars.aisupport.profanity.check |
true |
Whether profanity detection is applied to incoming messages |
appjars.aisupport.profanity.message |
(built-in default) | Response message shown when profanity is detected |
appjars.aisupport.profanity.prompt |
(built-in default) | Custom system prompt used for profanity classification |
appjars.aisupport.summarize.prompt |
(built-in default) | System prompt used for automatic session summarization |
appjars.aisupport.summarize.provider |
(empty — uses the default) | Bean name of a custom SessionSummaryProvider implementation |
appjars.aisupport.models.ollama.context-window |
4096 |
Context window in tokens assumed for Ollama models |
Advanced RAG
| Property | Default | Description |
|---|---|---|
appjars.aisupport.advanced-rag.prompt |
(empty — disabled) | System prompt for semantic query expansion; leave empty to disable advanced RAG |
appjars.aisupport.advanced-rag.queryCount |
5 |
Number of semantic queries generated per user message when advanced RAG is active |
appjars.aisupport.advanced-rag.queryMaxChars |
300 |
Maximum character length of each generated semantic query |
appjars.aisupport.advanced-rag.includeAttachments |
true |
Whether message attachments are included as context in RAG retrieval |
Embedding Search
| Property | Default | Description |
|---|---|---|
appjars.aisupport.embedding-search.maxResults |
16 |
Maximum number of embedding chunks returned per retrieval query |
appjars.aisupport.embedding-search.minScore |
0.7 |
Minimum similarity score for a chunk to be included in retrieval results |
appjars.aisupport.embedding-search.maxResultsAfterScoring |
8 |
Chunks kept per query after the scoring model re-ranks them. Ignored when no scoring model is configured |
appjars.aisupport.embedding-search.maxResultsAfterAggregation |
32 |
Chunks passed to the language model after the results of all queries are merged |
Re-ranking
| Property | Default | Description |
|---|---|---|
appjars.aisupport.rag.scoring-model.path |
(empty — disabled) | Path to an ONNX scoring model used to re-rank retrieved chunks |
appjars.aisupport.rag.scoring-model.tokenizer |
(empty — disabled) | Path to the tokenizer paired with the scoring model |
Content Injection
| Property | Default | Description |
|---|---|---|
appjars.aisupport.content-injector.prefix |
(built-in default) | Text prepended to the block of retrieved document chunks injected into the prompt |
appjars.aisupport.context-injector.prefix |
(built-in default) | Text prepended to the additional session context block |
Chat Attachments
| Property | Default | Description |
|---|---|---|
appjars.aisupport.chat.attachments.max-files |
3 |
Maximum number of files attached to a single message |
appjars.aisupport.chat.attachments.max-size |
10 |
Maximum size in megabytes per attached file |
appjars.aisupport.chat.attachments.base-path |
(empty) | Directory where message attachments are stored |
appjars.aisupport.attachments.resource-expires-after |
300 |
Seconds an attachment download URL remains valid |
Tool Calling
| Property | Default | Description |
|---|---|---|
appjars.aisupport.tooling.allowedPackages |
(empty — disabled) | Comma-separated packages scanned for @Tool-annotated Spring beans |
Background Tasks
| Property | Default | Description |
|---|---|---|
appjars.aisupport.close-inactive-session.schedule |
(unset — task disabled) | Cron expression controlling when inactive sessions are closed |
appjars.aisupport.close-inactive-session.minLastActivityDayAge |
60 |
Minimum days since the last activity before a session is closed |
appjars.aisupport.close-inactive-session.maxToProcessPerRun |
-1 |
Maximum sessions closed per run; -1 means no limit |
appjars.aisupport.close-inactive-session.zone |
UTC |
Time zone of the cron expression |
appjars.aisupport.exchange-pruner.schedule |
(unset — task disabled) | Cron expression controlling when old LLM exchange records are deleted |
appjars.aisupport.exchange-pruner.minDayAge |
60 |
Minimum age in days before an exchange record is deleted |
appjars.aisupport.exchange-pruner.maxToProcessPerRun |
-1 |
Maximum records deleted per run; -1 means no limit |
appjars.aisupport.exchange-pruner.zone |
UTC |
Time zone of the cron expression |
Channels
| Property | Default | Description |
|---|---|---|
appjars.aisupport.integrations.whatsapp.enabled |
false |
Registers the WhatsApp webhook endpoint |
appjars.aisupport.integrations.facebook.enabled |
false |
Registers the Facebook webhook endpoint |
appjars.aisupport.integrations.instagram.enabled |
false |
Registers the Instagram webhook endpoint |
appjars.aisupport.integrations.whatsapp.verify-token |
some_secure_token |
Token Meta uses to verify the WhatsApp webhook subscription |
appjars.aisupport.integrations.facebook.verify-token |
some_secure_token |
Token Meta uses to verify the Facebook webhook subscription |
appjars.aisupport.integrations.instagram.verify-token |
some_secure_token |
Token Meta uses to verify the Instagram webhook subscription |
appjars.aisupport.integrations.locale |
en |
Locale used for the automatic replies sent to external platforms |
appjars.aisupport.meta.resolve-usernames |
true |
Whether display names are resolved from the Meta APIs for incoming messages |
appjars.aisupport.public-url |
(empty) | Public base URL of the application, used to hand attachments to Meta |
Warning
Each integrations.<platform>.enabled property defaults to false, and the corresponding webhook controller is only registered when it is set to true. Creating a channel in the Channels view is not sufficient — without the matching property, the webhook endpoint does not exist and Meta receives a 404.
Display Formats
| Property | Default | Description |
|---|---|---|
appjars.aisupport.config.datepattern |
dd/MM/YY |
Pattern used to render dates |
appjars.aisupport.config.timepattern |
hh::mm a |
Pattern used to render times |
appjars.aisupport.config.datetimepattern |
dd/MM/YY, hh:mm a |
Pattern used to render combined date and time values |
Ollama Tokenizers
| Property | Default | Description |
|---|---|---|
appjars.aisupport.tokenizer.llama3 |
(empty — uses built-in) | Path to a custom Llama 3 tokenizer |
appjars.aisupport.tokenizer.mistral |
(empty — uses built-in) | Path to a custom Mistral tokenizer |
appjars.aisupport.tokenizer.gemma |
(empty — uses built-in) | Path to a custom Gemma tokenizer |
View URLs
| Property | Default | Description |
|---|---|---|
com.appjars.aisupport.url.views.chat |
as/chat |
URL path of the chat view |
com.appjars.aisupport.url.views.assistants |
as/assistants |
URL path of the assistant management view |
com.appjars.aisupport.url.views.documentsview |
as/documents |
URL path of the document management view |
com.appjars.aisupport.url.views.prompts |
as/prompts |
URL path of the prompt management view |
com.appjars.aisupport.url.views.llms |
as/llms |
URL path of the LLM management view |
com.appjars.aisupport.url.views.inspector |
as/inspector |
URL path of the LLM exchange inspector view |
com.appjars.aisupport.url.views.categories |
as/categories |
URL path of the category management view |
com.appjars.aisupport.url.views.channels |
as/channels |
URL path of the unified channels view |
Required Integrations
AuthenticatedUserProvider
AI Support requires an implementation of AuthenticatedUserProvider to identify the current user and resolve user search queries. This is used by the chat view, session management, and participant handling.
@Primary
@Component
public class AppAuthenticatedUserProvider implements AuthenticatedUserProvider {
@Autowired
private UserService userService;
@Override
public Optional<UserDto> getAuthenticatedUser() {
String username = SecurityContextHolder.getContext()
.getAuthentication().getName();
return userService.findByUsername(username)
.map(u -> new UserDto(u.getUsername(), u.hasRole("ADMIN")));
}
@Override
public Set<UserDto> findUsersByStartWith(String match, boolean toLowercase) {
String prefix = toLowercase ? match.toLowerCase() : match;
return userService.findAll().stream()
.filter(u -> u.getUsername().startsWith(prefix))
.map(u -> new UserDto(u.getUsername(), u.hasRole("ADMIN")))
.collect(Collectors.toSet());
}
}
UserDto in this context is com.appjars.aisupport.model.UserDto, which carries a username string and an isAdmin boolean. The @Primary annotation is required to override any default implementation.
UserProfilePictureProvider
UserProfilePictureProvider supplies avatar images for users displayed in the chat interface. It is optional — the default implementation returns empty, which causes the UI to render initials instead of a photo.
@Primary
@Component
public class AppUserProfilePictureProvider implements UserProfilePictureProvider {
@Autowired
private UserProfileService userProfileService;
@Override
public Optional<byte[]> getProfilePictureByUsername(String username) {
return userProfileService.findByUsername(username)
.map(UserProfileDto::getAvatar);
}
}
When User Profile is also integrated, delegate to its service as shown above.
AI Pipeline Architecture
AI Support is built on LangChain4j and uses a retrieval-augmented generation (RAG) pipeline to ground assistant responses in the application's knowledge base.
flowchart TD
A[User message] --> B[Profanity check]
B -->|clean| C{Advanced RAG?}
B -->|flagged| D[Return profanity response]
C -->|yes| E[SemanticQueryTransformer\ngenerates N queries]
C -->|no| F[Original query]
E --> G[EmbeddingStore retrieval\nper query]
F --> G
G --> H{Scoring model\nconfigured?}
H -->|yes| I[Re-rank chunks\nkeep maxResultsAfterScoring]
H -->|no| J[Keep retrieval order]
I --> K[ContentAggregator merges queries\nkeep maxResultsAfterAggregation]
J --> K
K --> L[ContentInjector\nbuilds prompt]
L --> M[ChatMemory\nlast 10 messages]
M --> N[ChatLanguageModel\nOpenAI or Ollama]
N --> O{Tool call\nrequested?}
O -->|yes| P[Invoke @Tool bean\nfeed result back]
P --> N
O -->|no| Q[Response message]
Q --> R[Session summary update]
Q --> S[LlmExchange record]
Standard RAG retrieves embedding chunks whose similarity score exceeds minScore (default 0.7), up to maxResults (default 16) per query, filtered to the categories assigned to the active assistant.
Advanced RAG adds a query transformation step: a secondary LLM call generates queryCount (default 5) semantically varied reformulations of the user's message. Each reformulation is used independently to query the embedding store, and the results are aggregated before injection. This improves recall for ambiguous or short queries.
Retrieval narrows the candidate set in three stages. Each query returns at most maxResults chunks. When a scoring model is configured, those chunks are re-ranked and cut to maxResultsAfterScoring. Finally the results of all queries are merged and cut to maxResultsAfterAggregation before they reach the prompt.
Re-ranking Retrieved Chunks
Similarity search alone ranks chunks by vector distance, which does not always match relevance. Configuring an ONNX scoring model adds a re-ranking pass that scores each retrieved chunk against the original query:
appjars.aisupport.rag.scoring-model.path=reranker/model.onnx
appjars.aisupport.rag.scoring-model.tokenizer=reranker/tokenizer.json
Both properties are required together. When either is missing, ScoringModelProvider yields no model and retrieval keeps the raw similarity ordering. A model that fails to load is logged as an error and treated the same way, so a broken path degrades retrieval quality rather than preventing startup.
Tool Calling
Assistants can call application code during a conversation. ToolScanner inspects the Spring context once the application is ready and collects every bean with methods annotated with LangChain4j's @Tool; those methods are offered to the language model as callable functions.
Scanning is restricted to an explicit allow list, and is disabled when the list is empty:
appjars.aisupport.tooling.allowedPackages=com.example.tools,com.example.crm
A tool is an ordinary Spring bean:
@Component
public class OrderTools {
@Tool("Returns the delivery status of a customer order")
public String orderStatus(String orderNumber) {
return orderService.findByNumber(orderNumber)
.map(order -> order.getStatus().name())
.orElse("Unknown order");
}
}
Only beans whose package starts with one of the allowed prefixes are scanned, so adding a package makes every @Tool method inside it reachable by the model. Scope the list narrowly, and treat tool arguments as untrusted input: the model chooses them.
Tool results re-enter the model as part of the same exchange, which means a single user message can produce several requests to the language model. Messages carrying tool calls are exempt from chat-memory eviction so that a partially completed tool sequence is never truncated.
Background Tasks
Two scheduled maintenance tasks ship with the appjar. Both are inactive until a cron expression is supplied, and both require @EnableScheduling on the host application.
Inactive session closer (CloseInactiveSessionTask) closes active sessions whose last activity — public or administrator-only — is older than minLastActivityDayAge, and deletes their draft messages:
appjars.aisupport.close-inactive-session.schedule=0 0 3 * * *
appjars.aisupport.close-inactive-session.minLastActivityDayAge=60
appjars.aisupport.close-inactive-session.maxToProcessPerRun=500
appjars.aisupport.close-inactive-session.zone=UTC
Exchange pruner (LlmExchangePrunerTask) deletes LLM exchange audit records older than minDayAge:
appjars.aisupport.exchange-pruner.schedule=0 30 3 * * *
appjars.aisupport.exchange-pruner.minDayAge=60
appjars.aisupport.exchange-pruner.maxToProcessPerRun=1000
appjars.aisupport.exchange-pruner.zone=UTC
Pruning exchange records removes the data behind the LLM Inspector for the affected messages; the messages themselves are kept.
LLM Configuration
LLM definitions are stored in the as_llm table and managed through the LLMs view. Each definition has a type (OPEN_AI or OLLAMA) and a configuration JSON field that holds the model name, API key, and endpoint. At most one LLM is marked as system = true, and it is used for the moderation and summarization tasks regardless of which assistant is active. The flag does not reserve the model for those tasks: a system LLM can also be referenced by assistants and used to answer users.
The flag is optional. LlmService.findSystemLlm() is resolved as follows, in RequestConfigurationFactory and in DefaultSessionSummaryProvider:
LlmDto systemLlm = llmService.findSystemLlm()
.map(l -> l.isEnabled() ? l : null)
.orElse(assistantLlm);
When no system LLM exists, or the one marked is disabled, the active assistant's LLM is used instead. These tasks therefore never lack a model, and an application that never sets the flag behaves correctly.
Supported content types per LLM type:
| LLM type | Supported attachment types |
|---|---|
OPEN_AI |
Text, image, video, audio, PDF |
OLLAMA |
Text, image |
Document Ingestion
Documents are ingested through DocumentService. The service splits each document into text segments, generates embeddings using the configured EmbeddingModel, and stores the segments in the PgVectorEmbeddingStore with metadata linking each chunk to its source document and categories.
// Add a document from a file or external source
Long id = documentService.addDocument(documentDto, categories);
// Add with additional metadata applied to all embedding chunks
Long id = documentService.addDocument(documentDto, categories, Map.of("source", "wiki"));
// Process a directory of files
documentService.processFolder("/data/knowledge-base", categories);
// Async processing (returns immediately; processes in background)
CompletableFuture<DocumentDto> future = documentService.processAsync(documentDto, categories);
Documents tagged with one or more categories are only retrieved when an assistant is scoped to those same categories. An assistant with no categories assigned retrieves from the full knowledge base.
Chat Bubble Component
The floating chat window described in The Chat Bubble is the AISupportChatAssistant component. Integrating it is covered in Add the Chat Bubble; this section covers what it is built on and how its appearance is controlled.
Relationship with the Chat view
Both user interfaces are driven by the same ChatCore, which owns the conversation logic — sending, streaming, unread tracking, mentions, participants, drafts, and private mode. Each host declares which of the two it is through ChatType:
| Value | Interface |
|---|---|
ChatType.MAIN_VIEW |
The full ChatView |
ChatType.FAB |
The floating AISupportChatAssistant |
A host implements ChatHost and delegates to ChatCore, which is why the two interfaces stay behaviourally identical: a feature added to the core reaches both. The differences are confined to layout — the view has a session sidebar, the bubble a session combobox in its header.
The underlying add-on
AISupportChatAssistant extends ChatAssistant<ChatAssistantMessage> from the Chat Assistant add-on, an Apache 2.0 licensed Vaadin add-on published by Flowing Code. The add-on contributes the floating button, the draggable and resizable window, and the unread badge; AI Support supplies the conversation behaviour on top of it.
The add-on is a transitive dependency of appjars-ai-support-flow; no separate declaration is required.
The base class provides the appearance and sizing API:
// Replace the floating button icon, optionally with a size in pixels
chatAssistant.setFabIcon(new SvgIcon("/icons/support.svg"), 36);
// Constrain the chat window; any CSS length is accepted
chatAssistant.setWindowWidth("28rem");
chatAssistant.setWindowHeight("40rem");
chatAssistant.setWindowMinWidth("20rem");
chatAssistant.setWindowMaxHeight("80vh");
// Open, close or query the window programmatically
chatAssistant.open();
chatAssistant.close();
boolean opened = chatAssistant.isOpened();
AI Support sets a robot icon at 36 px by default and replaces the add-on's header with its own, so setHeaderComponent should not be called on it. The button is 60 px with a 25 px margin from the viewport edge, and dragging is separated from clicking by a 25 px threshold. The window is resizable from all four edges and all four corners.
Neither the button position nor the window size is persisted: both are per page visit, and a refresh restores the defaults.
Scope
The component is a prototype-scoped Spring bean, because it holds per-user conversation state:
@SpringComponent
@Scope("prototype")
public class AISupportChatAssistant extends ChatAssistant<ChatAssistantMessage>
Inject it into a router layout that is itself scoped per UI, as shown in the getting started guide. Injecting it into a singleton would share one chat window between users.
Notifications
The bubble subscribes to the appjar's Broadcaster and reacts to message, participant, session, and document events. Two behaviours are worth knowing when integrating:
- The floating button's badge aggregates unread counts across every session the user participates in, not only the loaded one.
- A message preview notification is only raised when
ChatStatusHandler.shouldNotifyMessagepasses, which requires the participant to be active and subscribed to the session, the message to be newer than the last one they saw, and — for administrator-only messages — the recipient to be an administrator. The user's own messages, streaming fragments, and system messages never raise one.
Because the component needs server push to receive these events, @Push is required on the host application, as noted in the getting started guide.
Chat Memory
Each session maintains a persistent chat memory stored in the as_chat_memory table. The memory ID equals the session ID. The window keeps the most recent 10 messages; older messages remain in the database but are not included in new LLM requests.
Eviction uses a custom policy rather than LangChain4j's default. Three kinds of message are never evicted automatically: system messages, which are not counted against the limit at all; messages carrying non-text attachments such as images, PDFs, audio, or video; and messages that are part of an unfinished tool-call sequence. As a result a long conversation with several attachments can hold more than 10 messages in its context window.
The chat memory is populated automatically by AssistantService before each LLM call and updated with the response. Application code does not need to manage memory directly.
Streaming Responses
AssistantService.processMessagesWithStreaming() returns a CompletableFuture<MessageDto> and delivers partial response chunks via a StreamingHandler callback:
StreamingHandler handler = StreamingHandler.builder()
.sessionId(sessionId)
.startedBy(username)
.onPartialMessage(partial -> {
// push partial text to UI (e.g., via Vaadin Push)
})
.onFailedWhileGenerating(failed -> {
// handle generation failure
})
.onSummaryChanged(session -> {
// session summary was updated
})
.build();
assistantService.processMessagesWithStreaming(sessionId, false, handler);
Streaming can be cancelled at any time by calling handler.cancel(username).
LLM Exchange Audit
Every LLM interaction is recorded in as_llm_exchange. Each record stores the full request and response text, input and output token counts, the embedding chunks that were retrieved (for RAG calls), and a snapshot of the assistant, LLM, and prompt configuration at the time of the call. This allows administrators to inspect the exact reasoning behind any response in the Inspector view.
Exchange records can be pruned by age:
// Delete exchanges older than 30 days, up to 1000 records per call
int deleted = llmExchangeService.deleteOlderThan(Duration.ofDays(30), 1000);
Service API
AssistantService
// Add a message to a session
MessageDto addMessage(Long sessionId, String content, boolean shouldProcess,
SenderType senderType, String userId);
// Add a message with explicit context (injected alongside RAG content)
MessageDto addMessage(Long sessionId, String content, String context,
boolean shouldProcess, SenderType senderType, String userId);
// Process pending messages and return the AI response
CompletableFuture<MessageDto> processMessages(Long sessionId, boolean adminOnly);
// Process pending messages with real-time streaming
CompletableFuture<MessageDto> processMessagesWithStreaming(Long sessionId,
boolean adminOnly, StreamingHandler handler);
SessionService
// Create a new session and return its ID
long createSession(SessionDto sessionDto);
// Find all sessions where a user is a participant
List<SessionDto> findByParticipant(String username);
// Find sessions matching a filter
List<SessionDto> findByFilter(SessionFilter filter);
long countByFilter(SessionFilter filter);
DocumentService
// Ingest a document and return its ID
Long addDocument(DocumentDto document, List<CategoryDto> categories)
throws CannotAddDocumentException;
// Remove a document and its embeddings
void removeDocument(DocumentDto document);
// Re-process an existing document (re-split and re-embed)
CompletableFuture<DocumentDto> reprocessAsync(DocumentDto document);
// Browse embedding chunks for a document
List<EmbeddingDto> findEmbeddingSegmentsByTextContains(Long documentId,
String filterText, int offset, int limit);
MessageService
// Add a system-generated message to a session, such as an assistant update,
// a response error, or a participant change
MessageDto addSystemMessage(SessionDto session, String content, String userId,
boolean adminOnly, SystemMessageContext context);
// Delete every message belonging to a session
void deleteBySession(Long sessionId);
// Find messages matching a filter
List<MessageDto> findByFilter(MessageFilter filter);
long countByFilter(MessageFilter filter);
Customisation
Assigning a Router Layout
By default, AI Support registers its views without a parent layout. To wrap them in the application's main layout, inject the RouteConfigurer bean using its qualifier and call setViewsRouterLayout in a @PostConstruct method:
@Autowired
@Qualifier("AISupportRouteConfigurer")
private RouteConfigurer routeConfigurer;
@PostConstruct
public void configure() {
routeConfigurer.setViewsRouterLayout(MainLayout.class);
}
The @Qualifier is required because multiple AppJars may contribute a bean named RouteConfigurer.
Customising View URLs
The default URL paths can be overridden in application.properties:
com.appjars.aisupport.url.views.chat=myapp/chat
com.appjars.aisupport.url.views.assistants=myapp/assistants
com.appjars.aisupport.url.views.documentsview=myapp/documents
com.appjars.aisupport.url.views.llms=myapp/llms
com.appjars.aisupport.url.views.inspector=myapp/ai-inspector
Replacing the Session Summary Provider
Session summaries — the short titles shown on each session card — are produced by a SessionSummaryProvider. The default implementation asks the system LLM to summarise the recent messages, using the prompt in appjars.aisupport.summarize.prompt.
To generate summaries differently, for example without a model call, implement the interface and register the bean:
@Component("firstMessageSummaryProvider")
public class FirstMessageSummaryProvider implements SessionSummaryProvider {
// ...
}
Then select it by bean name:
appjars.aisupport.summarize.provider=firstMessageSummaryProvider
When the property is empty, the default provider is used.
Replacing the Embedding Model
The default embedding model (E5SmallV2, 384 dimensions) is suitable for English-language content. To use a different model, provide the ONNX model file and configure the path:
appjars.aisupport.embedding.model=/models/my-model.onnx
appjars.aisupport.embedding.tokenizer=/models/my-tokenizer.json
appjars.aisupport.embedding.pooling-mode=mean
spring.ai.vectorstore.pgvector.dimensions=768
The dimensions property must match the output size of the chosen model. Changing the embedding model requires re-processing all existing documents, as stored embeddings will be incompatible with a model of different dimensionality.