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, Web, and channel types defined by the host application).
Channels use joined-table inheritance without a discriminator. The common columns live in as_channel, and the type-specific columns live in five child tables: as_channel_whatsapp (phone_number, meta_phone_id, access_token), as_channel_facebook (page_id, access_token), as_channel_instagram (instagram_account_id, page_id, access_token), as_channel_web (origin, the address of the site that embeds the chat), and as_channel_custom, whose settings live in as_channel_custom_setting as name and value pairs. Each child table's primary key is also a foreign key to as_channel. The channel_type column holds the type key, either a built-in type or one of the application's own (see Channel Types of Your Own); the Java subclass of a row comes from the child table it has a row in, so channel_type is not a closed list.
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_assistant_tools {
bigint assistant_id FK
string assignment_type
string tool_reference
}
as_llm {
bigint id PK
string name
string type
boolean enabled
boolean system
text configuration
}
as_prompt {
bigint id PK
string name
text content
boolean system "deprecated, unused"
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
json offered_tools
json tool_calls
int model_call_count
}
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
text transcript
}
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_assistant ||--o{ as_assistant_tools : "may call"
as_document }o--o{ as_category : "tagged with"
as_document ||--o{ as_embedding : "chunked into"
Database Schema
AI Support manages twenty-five 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 TEXT,
enabled BOOLEAN NOT NULL,
system BOOLEAN NOT NULL,
CONSTRAINT pk_as_llm PRIMARY KEY (id)
);
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, -- deprecated, unused
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)
);
CREATE TABLE as_assistant_tools (
assistant_id BIGINT NOT NULL,
assignment_type VARCHAR(8) NOT NULL,
tool_reference VARCHAR(255) NOT NULL,
CONSTRAINT fk_as_assistant_tools_assistant FOREIGN KEY (assistant_id) REFERENCES as_assistant (id),
CONSTRAINT uq_as_assistant_tools UNIQUE (assistant_id, assignment_type, tool_reference)
);
-- 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)
);
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)
);
CREATE TABLE as_channel_web (
id BIGINT NOT NULL,
origin VARCHAR(255),
CONSTRAINT pk_as_channel_web PRIMARY KEY (id),
CONSTRAINT fk_as_channel_web FOREIGN KEY (id) REFERENCES as_channel (id),
CONSTRAINT uq_as_channel_web_origin UNIQUE (origin)
);
CREATE TABLE as_channel_custom (
id BIGINT NOT NULL,
CONSTRAINT pk_as_channel_custom PRIMARY KEY (id),
CONSTRAINT fk_as_channel_custom FOREIGN KEY (id) REFERENCES as_channel (id)
);
CREATE TABLE as_channel_custom_setting (
channel_id BIGINT NOT NULL,
name VARCHAR(255) NOT NULL,
value TEXT,
CONSTRAINT pk_as_channel_custom_setting PRIMARY KEY (channel_id, name),
CONSTRAINT fk_as_channel_custom_setting FOREIGN KEY (channel_id) REFERENCES as_channel_custom (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)
);
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)
);
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,
transcript TEXT,
CONSTRAINT pk_as_message_attachment PRIMARY KEY (id),
CONSTRAINT fk_as_message_attachment_message FOREIGN KEY (message_id) REFERENCES as_message (id)
);
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,
offered_tools JSONB,
tool_calls JSONB,
tool_input_token_count INTEGER,
tool_output_token_count INTEGER,
model_call_count INTEGER,
unavailable_tool_names JSONB,
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)
);
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)
);
-- Indexes ------------------------------------------------------------------
CREATE INDEX as_session_channel_external_idx ON as_session (channel_id, external_id);
CREATE INDEX as_session_status_activity_idx ON as_session (status, last_total_activity);
CREATE INDEX as_session_user_idx ON as_session (user_id);
CREATE INDEX as_session_assistant_idx ON as_session (assistant_id);
CREATE INDEX as_message_session_event_idx ON as_message (session_id, event_date);
CREATE INDEX as_message_external_id_idx ON as_message (external_id);
CREATE INDEX as_message_answered_by_idx ON as_message (answered_by);
CREATE INDEX as_message_attachment_message_idx ON as_message_attachment (message_id);
CREATE INDEX as_participant_session_user_idx ON as_participant (session_id, user_id);
CREATE INDEX as_participant_user_activity_idx ON as_participant (user_id, last_public_activity_seen);
CREATE INDEX as_participant_last_message_seen_idx ON as_participant (last_message_seen);
CREATE INDEX as_llm_exchange_session_idx ON as_llm_exchange (session_id);
CREATE INDEX as_llm_exchange_request_date_idx ON as_llm_exchange (request_date);
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. The LLM configuration is not part of that snapshot, because it holds the provider credentials:llm_contentis kept only because existing schemas declare itNOT NULL, and it always holds{}.as_assistant_toolsstores tool assignments by name.assignment_typeisBEANwhentool_referenceis a Spring bean name, andTOOLwhen it is the name of a single tool. There is no foreign key to application code, so an assignment survives the bean or tool disappearing from a deployment.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. - Enumerated columns carry no
CHECKconstraint. Columns such asas_llm.type,as_session.status, andas_message.sender_typestore the constant name as text, and the entities tell Hibernate not to generate a constraint listing the allowed values, so a new value never requires a schema change. Databases created by 2.1.0 or earlier still have those constraints; see Upgrading from 2.1.0. as_prompt.systemis deprecated. The flag never had an effect, and the column is kept only for existing schemas.- The indexes are named, and Hibernate's schema update creates any of them a database is missing. On a large or live database, create them beforehand as described in Upgrading from 2.1.0.
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, LlmCheckService, LlmContentTypeService, AudioTranscriptionService, PromptService, CategoryService, LlmExchangeService, ParticipantService, ChatMemoryService, DraftMessageService, ToolCatalogService, ChannelService, CustomChannelType, 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. It opens its own connections to the database ofspring.datasource.url, or to the one named by theappjars.database.*properties when any of them is set (see Database).
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.
The flow module's EmbeddedChatConfiguration registers the beans of the embedded chat only when appjars.aisupport.integrations.embedded-chat.enabled is true. See Embedded Chat.
Configuration Properties
Database
| Property | Default | Description |
|---|---|---|
spring.datasource.url |
(required unless appjars.database.* is set) |
JDBC URL of the application database. The embedding store connects to it when it is a jdbc:postgresql: URL |
spring.datasource.username |
(none — required) | Database user, also used by the embedding store |
spring.datasource.password |
(none — required) | Database password, also used by the embedding store |
appjars.database.host |
(empty) | PostgreSQL host of the embedding store, when it is not the database of spring.datasource.url |
appjars.database.port |
(empty — PostgreSQL's 5432) |
PostgreSQL port of the embedding store |
appjars.database.name |
(empty) | Database name of the embedding store |
The appjars.database.* properties are optional. Set them to keep the embeddings in another PostgreSQL database, or when spring.datasource.url is absent or is not a jdbc:postgresql: URL, as in applications that define several data sources themselves. When any of them is set, spring.datasource.url is ignored by the embedding store and both host and name are required; otherwise startup fails.
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, except the chat memory summarizer, which uses appjars.aisupport.memory.summarize.timeout |
com.appjars.aisupport.models.openai.moderation |
omni-moderation-latest |
OpenAI moderation model used for profanity detection. Anthropic, Gemini, and Ollama have no moderation API, so their chat model moderates by prompt |
appjars.aisupport.models.anthropic.max-response-tokens |
4096 |
Most tokens an Anthropic model may answer with, a limit its API requires on every request |
appjars.aisupport.models.ollama.context-window |
4096 |
Context window in tokens assumed for Ollama models |
appjars.aisupport.models.catalog |
(empty — only the built-in catalog) | Spring resource with the application's own model catalog, such as classpath:models.yml. See Model Catalog |
appjars.aisupport.assistant.rules-file |
(empty — uses the bundled defaults/assistant-rules.md) |
Rules every assistant follows, as a path relative to the classpath /prompts directory. See Assistant Rules |
appjars.aisupport.profanity.check |
true |
Whether profanity detection is applied to incoming messages |
appjars.aisupport.profanity.message |
I can't help you with that. I'm sorry. |
Response message shown when profanity is detected |
appjars.aisupport.profanity.prompt-file |
(empty — uses the bundled defaults/profanity.md) |
Moderation policy prompt, as a path relative to the classpath /prompts directory |
appjars.aisupport.summarize.prompt-file |
(empty — uses the bundled defaults/summary.md) |
Session-summary prompt, as a path relative to the classpath /prompts directory |
appjars.aisupport.summarize.provider |
(empty — uses the default) | Fully qualified class name of a custom SessionSummaryProvider. See Replacing the Session Summary Provider |
appjars.aisupport.summarize.delay |
10m |
How long new messages are gathered before the session summary is regenerated. The first answer of a session is summarised right away |
appjars.aisupport.summarize.max-messages |
8 |
Latest public messages the first summary is generated from; later summaries take every public message since the previous one |
Advanced RAG
| Property | Default | Description |
|---|---|---|
appjars.aisupport.advanced-rag.prompt-file |
(empty — uses the bundled defaults/advanced-rag.md) |
Search-query guidance prompt, as a path relative to the classpath /prompts directory. It does not switch advanced RAG on or off; that is decided per assistant |
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 |
Note
The prompt-file properties, these three and appjars.aisupport.memory.summarize.prompt-file under Chat Memory, name a file below the classpath /prompts directory. When a property is empty, or the file it names cannot be read, AI Support falls back to the prompt bundled with the appjar and logs a warning, so a mistyped path degrades the prompt rather than preventing startup. appjars.aisupport.assistant.rules-file is the exception: a rules file that is missing or blank stops the application from starting, rather than letting assistants answer without rules.
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 |
Chat Messages
| Property | Default | Description |
|---|---|---|
appjars.aisupport.chat.max-message-length |
2000 |
Longest message a user may write, in characters. The input stops at the limit, and the server refuses longer messages |
appjars.aisupport.chat.max-context-length |
500 |
Longest context sent with a message, such as the context of the embedded chat; a longer one is cut |
appjars.aisupport.chat-assistant.fab-position-memory |
0s |
How long the chat bubble stays where the user dragged it after a page reload. 0s keeps the position only for the current page visit |
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. Files received through channels above it are not downloaded |
appjars.aisupport.chat.attachments.base-path |
(empty — ai-support-attachments below java.io.tmpdir) |
Directory where message attachments are stored. The directory must be writable, or startup fails |
appjars.aisupport.attachments.resource-expires-after |
300 |
Seconds an attachment download URL remains valid |
Warning
The default attachment directory is below the system's temporary directory, which the operating system may clean. Set base-path to a persistent directory in production.
Chat Memory
| Property | Default | Description |
|---|---|---|
appjars.aisupport.memory.max-tokens |
25000 |
Estimated tokens above which the memory of a session is compacted; at least 100 |
appjars.aisupport.memory.low-water-tokens |
70% of max-tokens |
Estimated tokens a compaction brings the memory down to, leaving room for the next turns |
appjars.aisupport.memory.summary.max-tokens |
15% of max-tokens, at most half of low-water-tokens |
Estimated tokens the running summary may take; a longer summary is shortened |
appjars.aisupport.memory.tool-result.max-tokens |
50% of max-tokens |
Estimated tokens a single tool result may take; a longer result is shortened when it is stored |
appjars.aisupport.memory.attachments.keep-recent |
3 |
How many of the most recent attachments stay in the memory as they were sent |
appjars.aisupport.memory.attachments.keep-max-tokens |
A third of max-tokens |
Estimated tokens those recent attachments may take together |
appjars.aisupport.memory.attachments.text-max-tokens |
50% of max-tokens |
Estimated tokens the text of the attachments sent in one turn may take together, also limited by the budget left after the user message and the media. Longer texts are cut, and the model is told |
appjars.aisupport.memory.attachments.pdf-max-pages |
10 |
Most pages a PDF is sent to the model with as a file. A longer PDF is sent as its text or, when it has none, as its first pages |
appjars.aisupport.memory.attachments.pdf-max-pages-per-turn |
20 |
Most pages the PDFs of one turn are sent with as files, together. The PDFs past the limit are sent as their text, or left out with a notice when they have none |
appjars.aisupport.memory.attachments.describe |
true |
Shows attachments leaving the memory to the summarizer, when its model can read them, so the summary describes them |
appjars.aisupport.memory.media-tokens.image |
512 |
Estimated tokens of an image attachment |
appjars.aisupport.memory.media-tokens.pdf-page |
800 |
Estimated tokens of each page of a PDF attachment, which the model reads as text and an image |
appjars.aisupport.memory.media-tokens.pdf |
2000 |
Estimated tokens of a PDF attachment whose page count is unknown, such as one sent before page counts were recorded |
appjars.aisupport.memory.media-tokens.audio-second |
32 |
Estimated tokens of each second of an audio attachment whose length is known |
appjars.aisupport.memory.media-tokens.audio |
1000 |
Estimated tokens of an audio attachment whose length is unknown, such as a WebM recording made by a browser |
appjars.aisupport.memory.media-tokens.video |
2000 |
Estimated tokens of a video attachment |
appjars.aisupport.memory.summarize.async |
true |
Compacts in the background once the answer is delivered; when false, before the answer completes, which delays it by the summarizer call |
appjars.aisupport.memory.summarize.timeout |
60 |
Timeout in seconds of the summarizer model |
appjars.aisupport.memory.summarize.wait-timeout |
10 |
Seconds a request waits for the previous compaction of its session before going on without it |
appjars.aisupport.memory.summarize.prompt-file |
(empty, uses the bundled defaults/memory-summary.md) |
Running-summary guidance, as a path relative to the classpath /prompts directory |
Every budget left unset derives from max-tokens, so changing that one property keeps the others consistent. An invalid value, such as a low-water-tokens above max-tokens, is logged as a warning and replaced by its default rather than failing startup. How the budgets are applied is described in Chat Memory.
Warning
Only Ollama models have their budgets scaled to the context window. When an assistant or the summarizer runs on another model whose context window is not well above max-tokens, such as a small model behind an OpenAI-compatible server, set max-tokens to at most half of that window. Otherwise the memory outgrows the window before it is compacted, and the provider rejects every request of the session.
Tool Calling
| Property | Default | Description |
|---|---|---|
appjars.aisupport.tooling.allowedPackages |
(empty, disabled) | Comma-separated packages scanned for @Tool-annotated Spring beans. It bounds which tools can be assigned to an assistant; what each assistant receives is chosen per assistant |
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 |
appjars.aisupport.system-message-pruner.schedule |
(unset — task disabled) | Cron expression controlling when old system messages are deleted |
appjars.aisupport.system-message-pruner.minDayAge |
60 |
Minimum age in days before a system message is deleted; the task refuses to run below 1 |
appjars.aisupport.system-message-pruner.maxToProcessPerRun |
-1 |
Maximum messages deleted per run; -1 means no limit |
appjars.aisupport.system-message-pruner.zone |
UTC |
Time zone of the cron expression |
Channels
| Property | Default | Description |
|---|---|---|
appjars.aisupport.integrations.meta.app-secret |
(none — required) | Meta app secret used to authenticate incoming webhook payloads. It has no default: an application that registers any Meta webhook must set it |
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.
The properties of the embedded chat, which serves Web channels, are listed in Embedded Chat, and those of audio transcription in Transcription.
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. This is used by the chat view, session management, and participant handling.
Only getAuthenticatedUser has to be implemented. User discovery — the lookup behind the mention and participant pickers — is optional: findUsers returns an empty set unless it is overridden, in which case those pickers simply offer no suggestions.
@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> findUsers(UserSearchRequest request) {
String prefix = request.caseInsensitive()
? request.prefix().toLowerCase()
: request.prefix();
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.
Note
Implementations written against findUsersByStartWith(String, boolean) keep working: the default findUsers delegates to it, passing the prefix through unchanged. That method is deprecated for removal, so override findUsers(UserSearchRequest) in new code.
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.
Two further methods are optional defaults that return empty. getProfilePictureUrlByUsername(String) supplies the picture as a URL instead of bytes. getDisplayNameByUsername(String) supplies the name shown for a user in the participant list and in the mention list, with the username underneath; when it returns empty or a blank name, the username is shown instead. Mentions always carry the username, whatever the display name is.
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 --> SM[System message\nassistant rules + assistant prompt]
SM --> M[ChatMemory\nrunning summary + recent turns]
M --> N[Chat model\nOpenAI, Ollama, Anthropic or Gemini]
N --> O{Tool call\nrequested?}
O -->|yes| P[Invoke a tool assigned\nto the assistant\nfeed result back]
P --> N
O -->|no| Q[Response message]
Q --> S[LlmExchange record]
Q --> T[Chat memory compaction\nin the background]
The session summary is not part of this pipeline. The chat views request it themselves: right after the first answer of a session, and then at most once every appjars.aisupport.summarize.delay, from the public messages sent since the previous summary. Code that adds messages outside the chat views calls AssistantService.summarizeSession when it wants a summary.
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. It is applied only when the assistant has at least one enabled category: with none, retrieval can match nothing, so the extra call is skipped even if the assistant has the option turned on.
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.
Assistant Rules
The system message of every answer has two parts: the rules every assistant follows, then the assistant's prompt under an # Assistant instructions heading. The rules rank themselves first, the assistant's prompt second, and the conversation last. They tell the model to treat instructions found in the message context, attachments, document chunks, tool results, or the conversation summary as information unless the prompt says to follow them. Tone, language, and style are left to the prompt.
Administrators write the prompts; the rules belong to the application. They are bundled as prompts/defaults/assistant-rules.md, and an application replaces them by pointing appjars.aisupport.assistant.rules-file at a file of its own below the classpath /prompts directory:
appjars.aisupport.assistant.rules-file=myapp/assistant-rules.md
Replacing them is a deliberate choice: custom rules no longer follow the changes that release notes announce for the default ones, and they keep the guarantees described above only if they state them. They must still refer to the Assistant instructions that AI Support appends after them. An application whose rules file is missing or blank does not start.
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 into a catalog of the tools the application exposes. Each assistant receives only the tools assigned to it, chosen by an administrator in the Tools tab of the assistant dialog (see Assistants), and only those definitions are sent with its requests.
Upgrading from 2.0.1 or earlier
Tools used to be global: every assistant received every discovered tool. Assignments are not created automatically on upgrade, so every existing assistant starts with no tools and has to be configured. Select all in the Tools tab assigns every class, which restores the previous behaviour for that assistant.
Discovery 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
@Description("Order lookups for customer support")
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");
}
}
@Tool is dev.langchain4j.agent.tool.Tool, from langchain4j-core. When that artifact is not on the compile classpath of the application, for example because the AI Support implementation modules are declared with runtime scope, declare it at compile scope with the version AI Support uses:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-core</artifactId>
<version>1.17.1</version>
</dependency>
Spring's @Description is optional. It is the text the Tools tab shows for the class; without it, the tab shows the fully qualified class name. Each tool is described by its @Tool text, which is also what the model reads.
A package is matched exactly or as a parent package, so com.example.tools admits com.example.tools.order but not com.example.toolsets. The allow list bounds what can be assigned: adding a package makes every @Tool method inside it available for assignment, and nothing in it reaches the model until an administrator assigns it. Scope the list narrowly all the same, and treat tool arguments as untrusted input: the model chooses them.
Tool names must be unique across the whole application, because a name is what the model calls. A name declared by more than one bean is left out of the catalog and reported as an error at startup.
Assignments are stored by name in as_assistant_tools and carried by AssistantDto.getToolAssignments() as ToolAssignmentDto records, built with ToolAssignmentDto.ofBean(beanName) or ToolAssignmentDto.ofTool(toolName):
- Assigning a bean is dynamic: the assistant receives whatever that bean exposes, including tools added to it later.
- Assigning a single tool covers only that tool, whatever else its bean exposes.
An assignment whose bean or tool is absent from the running application is kept and reported as unavailable rather than deleted, so it takes effect again if the code comes back. Renaming a bean or a tool is therefore a removal plus an addition. ToolCatalogService exposes the discovered catalog to application code: findToolBeans() lists the beans with their tools, and findUnavailableReferences(assignments) returns the references the current catalog cannot resolve.
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; the exchange records how many, along with every call the model made. In the chat memory a tool result is shortened to appjars.aisupport.memory.tool-result.max-tokens, and it is only evicted together with the turn that requested it, so a tool result never outlives its request.
Background Tasks
Three scheduled maintenance tasks ship with the appjar. All of them are inactive until a cron expression is supplied, and all of them require @EnableScheduling on the host application. An empty cron expression fails startup, so a task is disabled by leaving its schedule property out entirely, not by setting it to an empty value.
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.
System message pruner (SystemMessagePrunerTask) deletes system messages — assistant updates, response errors, participant changes — older than minDayAge:
appjars.aisupport.system-message-pruner.schedule=0 45 3 * * *
appjars.aisupport.system-message-pruner.minDayAge=60
appjars.aisupport.system-message-pruner.maxToProcessPerRun=1000
appjars.aisupport.system-message-pruner.zone=UTC
A message is skipped when something still refers to it: an attachment, a reply, or a participant's last-seen or last-sent pointer. Deletion runs in independent batches, so a conflict on one batch does not roll back the batches that already succeeded. minDayAge must be at least 1; the task logs a warning and does nothing when it is lower, because 0 would delete every eligible system message.
Visitor purge (EmbeddedVisitorPurge) is a fourth task, part of the embedded chat. It deletes the sessions of Web channels in which nobody has written for purge.after-inactivity, and it is only registered when appjars.aisupport.integrations.embedded-chat.purge.schedule is set. Unlike the three tasks above, it runs on a scheduler of its own, so it needs no @EnableScheduling. See Embedded Chat.
LLM Configuration
LLM definitions are stored in the as_llm table and managed through the LLMs view. Each definition has a type (OPEN_AI, OLLAMA, ANTHROPIC, or GEMINI) and a configuration JSON field, mapped by LlmConfiguration, that holds the model name, the API key, the endpoint (url, or host and port for Ollama), and the content types the model accepts. OpenAI, Anthropic, and Gemini are reached with an API key; Ollama, at a host and port. At most one LLM is marked as system = true, and it is used for the moderation, summarization, and transcription 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. Moderation and summaries therefore never lack a model, and an application that never sets the flag behaves correctly. Transcription is the exception: it uses only the system LLM, so without one audio is not transcribed (see Transcription).
LlmCheckService.check(LlmDto) sends an LLM a short request with its configuration and returns the answer, without storing the LLM or the exchange. It is what the Test button of the LLM dialog calls, and it throws a ModelRequestException whose message gives the reason without credentials.
Anthropic and Gemini have no tokenizer, so their token counts are reported as -1 and shown as unknown. An OpenAI model the tokenizer library does not know yet is estimated with the tokenizer of GPT-4o and GPT-5.
Content Types per Model
Each LLM stores the content types its model accepts, editable in the Accepted attachments field of its dialog. Text is always accepted, and the types are limited to what the provider's integration can send:
| LLM type | Content types the integration can send |
|---|---|
OPEN_AI |
Text, image, video, audio, PDF |
GEMINI |
Text, image, video, audio, PDF |
ANTHROPIC |
Text, image, PDF |
OLLAMA |
Text, image |
When an LLM is created or its model name changes, its types are prefilled from what the Ollama server reports about the model, for Ollama, or otherwise from the model catalog. LlmContentTypeService answers which types an LLM accepts and suggests the types to prefill. An LLM saved before content types were stored takes the types of its model in the catalog, or, for a model the catalog does not know, every type its provider's integration can send, until it is edited.
An attachment the assistant's model does not accept still reaches it in another form: a PDF as its text, audio as its transcript when the system LLM transcribes it, and anything else as a note naming the file. The user who attached it is told.
A model that reads audio takes it only in the formats its provider's API reads. OpenAI's audio models take MP3 and WAV. Gemini models also take OGG (WhatsApp voice notes), WebM (voice recorded by browsers), M4A (Messenger and Instagram audio clips), AAC, FLAC, and AIFF. Audio in another format reaches the model as its transcript, or as a note.
Model Catalog
AI Support ships a catalog of known models and the content types they accept. An application can add entries, or replace built-in ones that have the same provider and pattern, with a YAML file of its own named by appjars.aisupport.models.catalog:
appjars.aisupport.models.catalog=classpath:models.yml
models:
- match: deepseek-chat # a model behind an OpenAI-compatible endpoint
provider: OPEN_AI
content-types: [TEXT]
- match: gpt-4o # replaces the built-in entry
provider: OPEN_AI
content-types: [TEXT, IMAGE]
A model name matches the entries of its provider whose match it starts with, ignoring case, and the longest match wins, so gpt-4o-2024-08-06 takes the gpt-4o entry. An unknown model starts with text only.
Transcription
The system LLM transcribes audio, such as voice notes, for a model that does not hear it, which then reads the transcript. Which providers transcribe, and which formats they read:
| System LLM type | Transcribes | Formats read |
|---|---|---|
OPEN_AI |
Yes, with OpenAI's speech-to-text model appjars.aisupport.transcription.model |
MP3, WAV, OGG, WebM, M4A, FLAC |
GEMINI |
Yes, with the LLM's own model | MP3, WAV, OGG, WebM, M4A, AAC, FLAC, AIFF |
ANTHROPIC |
No | — |
OLLAMA |
No | — |
With an Anthropic or Ollama system LLM, or with no system LLM at all, audio reaches a model that does not hear it as a note. The LLM dialog warns about it when such an LLM is marked as system, and the application logs it at startup.
An audio attachment is transcribed once, when it is sent to a model that does not hear it or when someone asks for its transcript in the chat, and the transcript is stored in as_message_attachment.transcript. A transcription that fails is not stored, so it is tried again the next time. Reading the chat memory never transcribes: audio held from a turn a model heard reaches a later model that does not hear it as its stored transcript, or as a note when it has none.
A stored transcript also stands for the text of its message where that text is read: in the retrieval queries, moderation, and the session summary. Audio a model hears is not transcribed for these, so a voice note sent alone to such a model is retrieved with the assistant prompt alone, is not moderated, and is named in the summary without what it says.
Users can record voice notes where the chat offers attachments, as long as the assistant's model hears audio or the system LLM transcribes it.
| Property | Default | Description |
|---|---|---|
appjars.aisupport.transcription.enabled |
true |
Whether audio is transcribed |
appjars.aisupport.transcription.model |
gpt-4o-mini-transcribe |
Speech-to-text model used when the system LLM is an OpenAI LLM |
AudioTranscriptionService exposes transcription to application code: getTranscribableFormats() returns the formats that can be transcribed now, canTranscribe(attachment) tells whether one attachment can, and transcriptOf(attachment) returns its stored transcript or transcribes and stores it.
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. Retrieval reaches enabled categories only, so an assistant with no enabled category assigned retrieves nothing at all: the content retriever returns an empty result and the language model answers without document context. Advanced RAG is skipped in that case as well, since its extra call could not match anything.
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.
The window size is not persisted: it is kept for the page visit, and a refresh restores the default. The button position behaves the same by default. Setting appjars.aisupport.chat-assistant.fab-position-memory to a duration, such as 10m, keeps a dragged button where the user left it across reloads for that long.
Host Controls
AISupportChatAssistant also offers methods that let the host shape what the chat does. The embedded chat is built on them:
// Tie the chat to a channel: the sessions it starts belong to the channel
// and talk to its assistant instead of the default one. Call before attaching
chatAssistant.setChannel(channelDto);
// Context sent with every message written from now on, such as the current page;
// it is trimmed to appjars.aisupport.chat.max-context-length
chatAssistant.setMessageContext("Pricing - https://example.com/pricing/");
// Decide, right before each message is sent, whether it may be. A refused message
// is dropped, and the gate is in charge of telling the user
chatAssistant.setMessageGate(() -> rateLimiter.tryAcquire());
// Switch off optional capabilities. Call before attaching
chatAssistant.setFeatures(ChatFeatures.ALL.withAttachments(false).withChatLinks(false));
ChatFeatures (com.appjars.aisupport.flow.component.chat.ChatFeatures) is a record with four switches — attachments, mentions, sessionFilter, and chatLinks — and ChatFeatures.ALL, the default, turns every one on. A feature switched off is refused by the server as well, not only hidden in the interface.
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.
Embedded Chat
The embedded chat exports the chat bubble as the <ai-support-chat> web component, so another website, such as a static marketing site, can show the assistant to its visitors. The step-by-step setup, from choosing the chat host to testing the result, is described in Setting up the Embedded Chat. This section is the reference for its properties and integration points.
Visitors are anonymous. Each browser gets an HttpOnly cookie, and the chat names the visitor visitor- followed by a short hash derived from that cookie and visitor-key. Administrators answer visitors from the application like any other user. Every session a visitor starts belongs to the Web channel of the site and talks to that channel's assistant; only sites with an enabled Web channel can load the chat, and turning the channel off takes the chat off the site.
Integration Requirements
- A dedicated host on the site's own domain, such as
chat.example.comforexample.com, pointing to the application. Browsers do not send cookies to another site, and without its cookie a visitor loses their chats. The host is listed inhosts, and from then on it serves only the web component: any other request to it, such as the login page or a view, is answered with a 404, so no application session can exist where the site's scripts read responses. Hosts are matched against the request's server name, so a reverse proxy must pass the original host along. - Security: let
/web-component/**through the application's security configuration. -
Component scanning: Vaadin registers web components only from the packages it scans. When the
@SpringBootApplicationclass is outsidecom.appjars.aisupport.flow.embed, add that package with@EnableVaadin. Because@EnableVaadinreplaces the default scan, list the application's own packages too:@SpringBootApplication @EnableVaadin({"com.example", "com.appjars.aisupport.flow.embed"}) public class Application { }Without it,
/web-component/ai-support-chat.jsanswers with the application's page instead of the script. -
Reverse proxy: set
server.forward-headers-strategyso the application sees the visitors' addresses; otherwise every visitor shares the proxy's address limit. The proxy is also the place to cap the rate and body size of the requests to the chat hosts.
The <ai-support-chat> Element
The site adds the element as a child of <body>, and loads its script from the chat host:
<ai-support-chat language="en" context="Pricing - https://example.com/pricing/"></ai-support-chat>
<script type="module" src="https://chat.example.com/web-component/ai-support-chat.js"></script>
| Attribute | Description |
|---|---|
language |
Language the chat is built in, such as en or es |
context |
Where the visitor is. It is sent with every message and trimmed to appjars.aisupport.chat.max-context-length; a single-page application can update it as it navigates |
theme |
dark switches the chat to Lumo's dark theme. The page can restyle the chat through Lumo's CSS custom properties set on ai-support-chat |
The bubble is anchored to the viewport, so the element must not sit inside an ancestor with a CSS transform, filter, or containment, which would trap it. The embedded chat shows no pop-up notifications.
Properties
All property names start with appjars.aisupport.integrations.embedded-chat.:
| Property | Default | Description |
|---|---|---|
enabled |
false |
Serves <ai-support-chat> |
hosts |
(none — required) | Comma-separated hosts dedicated to the embedded chat |
visitor-key |
(none — required) | Secret of at least 32 characters that visitor names are derived from. Changing it renames every visitor |
secure-cookie |
true |
Sends the visitor cookie only over HTTPS, as __Host-ais_visitor. Turn it off only for local HTTP tests |
features.attachments |
false |
Lets visitors attach files |
features.mentions |
false |
Lets visitors mention users |
features.session-filter |
false |
Shows the session filter |
features.chat-links |
false |
Offers links to chats, which point into the application |
fab-position-memory |
2m |
How long the bubble stays where a visitor dragged it after they leave the page; every navigation of the site loads a new page |
limits.visitor-per-minute |
12 |
Messages a visitor may send each minute; 0 for unlimited |
limits.visitor-per-hour |
40 |
Messages a visitor may send each hour; 0 for unlimited |
limits.address-per-hour |
100 |
Messages all visitors from one network address may send each hour; 0 for unlimited |
limits.per-day |
2000 |
Messages all visitors together may send each day; 0 for unlimited |
purge.schedule |
(unset — never) | Cron expression for deleting visitor sessions nobody has written in for purge.after-inactivity |
purge.zone |
UTC |
Time zone of the purge schedule |
purge.after-inactivity |
90d |
How long a Web channel session is kept after its last message |
purge.max-per-run |
-1 |
Sessions deleted per run; -1 for unlimited |
A visitor who reaches a limit loses the bubble: the message is dropped, the bubble hides for the rest of that page, and pages loaded before the limit's window ends show none. The counts live in memory and restart with the application.
Visitors are anonymous, so nobody can ask for their chats to be removed. Set purge.schedule to keep them only as long as they are useful. The purge schedules itself and runs even when the application does not enable scheduling.
Warning
On a database created by 2.1.0 or earlier, saving a Web channel fails until the check constraint on as_channel.channel_type is dropped. The same applies to channels of the application's own types. See Upgrading from 2.1.0.
Channel Types of Your Own
An application can add channel types of its own, such as Telegram or Slack. Their channels are CustomChannelDto objects: the common channel fields plus a map of settings, which AI Support stores in as_channel_custom and as_channel_custom_setting, so the application writes no persistence code. A type is made of these Spring beans:
| Bean | Required | Purpose |
|---|---|---|
CustomChannelType |
Yes | Declares the type key and validates and normalises the settings of a channel before every save |
ChannelTypePresentation |
No | The type's name, icon, identifier, and position in the Channels view. Without it, the type shows its key and a generic icon |
ChannelForm (prototype scope) |
No | The type's tab in the channel dialog. Extending BaseChannelForm<CustomChannelDto> provides the name, status, and assistant fields |
A controller extending ChannelWebhook<P> |
No | Receives the provider's messages and sends the replies |
com.appjars.aisupport.flow.component.channel.ChannelWebhook |
No | A panel in the Webhook dialog, such as the URL to register with the provider |
The type key is at most 31 characters long, unique, and none of the built-in keys (WHATSAPP, FACEBOOK, INSTAGRAM, WEB). The channel service only saves channels of declared types, and passes their settings through normalize first, whoever saves them:
@Component
class TelegramChannelType implements CustomChannelType {
@Override
public String type() {
return "TELEGRAM";
}
@Override
public Map<String, String> normalize(Map<String, String> settings) {
if (settings.getOrDefault("botToken", "").isBlank()) {
throw new ServiceException("acme.channels.telegram.token.required");
}
return settings;
}
}
To tell the administrator why a channel is rejected, throw a com.flowingcode.backendcore.exception.ServiceException whose message key the application translates in its own i18n bundle, as acme.channels.telegram.token.required above. A rule per field can pass a list of ErrorDescription objects instead, each with its own key, and their translations are shown together. The Channels view shows the translated message as a warning when it saves, enables, disables, or restores a channel. A key with no translation, an IllegalArgumentException, or any other exception is logged and shows only the generic An error occurred., so technical messages never reach the screen.
The webhook controller extends com.appjars.aisupport.flow.webhooks.ChannelWebhook<P>, where P is the type of the provider's payload. It overrides getType() to return the type key, and implements the provider-specific methods: parsePayload, sendReply, getVerifyToken, sendTypingIndicator, openIncomingAttachment, and uploadOutgoingAttachment. Its request handlers pass each payload to processPayload, which stores the messages, creates the sessions, and triggers the assistant. ChannelService.findCustomBySetting(type, setting, value) finds the channel a payload belongs to, for example by its bot id.
A provider that limits the length of a message gets long replies in parts when the webhook overrides maxTextLength() and sends the text of a reply through sendText. sendText cuts the text at paragraph, line, or word breaks, sends the parts in order, and stops at the first part that fails. textLength measures the text as the provider counts it, in characters unless overridden. The built-in channels leave a margin below Meta's limits: 1,800 characters for Facebook, 900 bytes for Instagram, and 3,600 characters for WhatsApp.
A channel whose type the application stops declaring keeps its settings. The Channels view can still disable or delete it but no longer edits it, and ChannelService can still change its common fields, such as its name or assistant.
ChannelDto.getType() returns the type key of every channel. The deprecated getChannelType() returns null for a channel of the application's own type.
Chat Memory
Each session maintains a persistent chat memory stored in the as_chat_memory table. The memory ID equals the session ID. The memory is a token window: it holds the recent conversation verbatim and folds older turns into a running summary, so the context of a long conversation survives after its messages leave the window. The budgets are set with the Chat Memory properties, whose names below are relative to appjars.aisupport.memory., and token counts are estimates made with the assistant model's tokenizer when one is available.
The memory is never trimmed while a request runs. Once an answer has been delivered and committed, the memory of the session is compacted in the background if it exceeds max-tokens; the next request of the session waits for that compaction for at most summarize.wait-timeout seconds, and goes on with the memory as it is when the wait runs out. Setting summarize.async to false compacts before the answer completes instead. Compaction follows these rules:
- Whole turns are evicted, oldest first, until the memory fits
low-water-tokenswith room reserved for the summary. A turn is a user message and everything up to the next one, so a question is never separated from its answer and a tool result never outlives the request that produced it. - The latest turn and the system message are never evicted.
- The evicted turns are folded into the running summary by the system LLM, or by the assistant's LLM when no enabled system LLM exists, using the
summarize.prompt-fileguidance and thesummarize.timeout. A summary longer thansummary.max-tokensis shortened. - Messages are only evicted once their summary exists. A failed summary leaves the memory as it was, to be compacted again after the next answer.
Attachments are stored in the memory as references to the message attachment, not as their payload, and are resolved to the payload only when a request is written for the model. Each attachment is charged an estimate (the media-tokens properties): images and videos a fixed amount, PDFs by their page count (media-tokens.pdf-page), and audio by its length in seconds (media-tokens.audio-second). The fixed media-tokens.pdf and media-tokens.audio estimates apply only when the page count or the length is unknown. The text of the attachments of a turn is cut to attachments.text-max-tokens. A PDF longer than attachments.pdf-max-pages is sent as its text, and the PDFs of one turn are sent as files up to attachments.pdf-max-pages-per-turn pages together. When the model receives an attachment only in part, or not at all, the user who attached it is told (see StreamingHandler.onAttachmentsCut under Streaming Responses). The attachments.keep-recent most recent attachments stay available as they were sent, within attachments.keep-max-tokens; older ones leave the context, and the summary records that they were sent or, when attachments.describe is on and the summarizer model can read them, describes them. Memories written by earlier versions hold the payload inline and are used as they are.
Messages visible to administrators only reach the model only for answers that only administrators see. For an answer everyone sees, they are kept in the memory but left out of the request, together with their attachments and with the private answers and tool calls the assistant gave earlier, so no prompt can bring them back. When the memory is compacted, they leave without reaching the running summary, which is part of every answer.
When the assistant or the summarizer of a session runs on Ollama, every budget is scaled down so the memory takes at most half of appjars.aisupport.models.ollama.context-window, because Ollama cuts a larger request instead of rejecting it. No other provider is scaled this way; see the warning under Chat Memory properties.
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. Closing a session deletes its stored memory, so a session that is reopened starts the window afresh.
Streaming Responses
AssistantService.processMessagesWithStreaming() returns a CompletableFuture<MessageDto> and delivers partial response chunks via a StreamingHandler callback:
StreamingHandler handler = new StreamingHandler();
handler.setSessionId(sessionId);
handler.setStartedBy(username);
handler.setOnPartialMessage(partial -> {
// push partial text to UI (e.g., via Vaadin Push)
});
handler.setOnFailedWhileGenerating(failed -> {
// handle generation failure
});
handler.setOnAttachmentsCut(cuts -> {
// tell each uploader which attachments the model receives only in part, or not at all
});
assistantService.processMessagesWithStreaming(
ProcessMessagesRequest.publicResponse(sessionId), handler);
Streaming can be cancelled at any time by calling handler.cancel(username).
onAttachmentsCut is called before the answer starts, with one AttachmentCut per attachment the model receives only in part or not at all: its file name, the user who attached it, and what the model received of it, such as only the beginning of its text or only the text of a PDF.
The future completes with null when the session has no pending message to answer, in which case the model is not called. It completes exceptionally with a ModelRequestException when the model fails to answer; its message gives the reason without credentials.
Answering does not regenerate the session summary. StreamingHandler.onSummaryChanged is deprecated for removal and is never called; call AssistantService.summarizeSession to update the summary.
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 and prompt at the time of the call. The LLM is identified by its id, name, and type only: its configuration holds the provider credentials and is never recorded. Each record also stores the tool definitions sent to the model, the tool calls the model made with their arguments, results, duration, and outcome, the tokens attributable to tools, the number of model calls the exchange took, and the assigned tools that could not be resolved. This allows administrators to inspect the exact reasoning behind any response in the Inspector view.
A request the model fails to answer is recorded as well: its response holds an Error entry with the provider's reason, with the API keys of the LLMs involved removed. When the model failed after answering in part, the response holds the partial answer followed by the error.
Upgrading from 2.0.1 or earlier
The LLM configuration is no longer part of the exchange record. On every startup AI Support resets the llm_content column of existing exchanges to {}, with no action needed, and logs how many rows it updated. LlmExchangeDto.getLlmContent() is deprecated for removal: it always returns null, and a value set on it is not persisted. Use getLlmId(), getLlmName(), and getLlmType() to identify the LLM.
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. AddMessageRequest carries the session, the content,
// an optional context label, whether the assistant should answer, and the author
MessageDto addMessage(AddMessageRequest request);
// Persist an already built message
MessageDto addMessage(MessageDto messageDto);
// Process pending messages and return the AI response
CompletableFuture<MessageDto> processMessages(ProcessMessagesRequest request);
// Process pending messages with real-time streaming
CompletableFuture<MessageDto> processMessagesWithStreaming(
ProcessMessagesRequest request, StreamingHandler handler);
// Regenerate the session summary from the public messages sent after `since`;
// null takes the latest appjars.aisupport.summarize.max-messages. Empty when there
// was nothing new, the summary did not change, or the session is gone
Optional<SessionDto> summarizeSession(Long sessionId, Instant since);
ProcessMessagesRequest reads better through its factory methods — publicResponse(sessionId), adminOnlyResponse(sessionId), or of(sessionId, adminOnly). Both processMessages methods complete with null when the session has no pending message to answer, and exceptionally with a ModelRequestException when the model fails to answer. summarizeSession throws a ModelRequestException when the summary cannot be generated, so the caller can summarize the same messages later.
SessionService
// Create a new session and return its ID
long createSession(SessionDto sessionDto);
// Set only the summary of a session, so a summary generated over a while cannot
// undo other changes made meanwhile; returns whether the session still exists
boolean updateSummary(Long sessionId, String summary);
// 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. SystemMessageRequest also offers
// publicMessage(...) and adminOnly(...) factory methods
MessageDto addSystemMessage(SystemMessageRequest request);
// Delete every message belonging to a session
void deleteBySession(Long sessionId);
// Delete system messages older than the given duration, which must be at least
// one day; returns the number of rows deleted
int deleteSystemOlderThan(Duration duration, int maxToDelete);
// Find messages matching a filter
List<MessageDto> findByFilter(MessageFilter filter);
long countByFilter(MessageFilter filter);
MessageAttachmentService
// Save an attachment; once its bytes are stored, its type is set from their format,
// so a file reported with the wrong type is still sent to the model as what it is
Long save(MessageAttachmentDto attachment);
// Store the transcript of an audio attachment, changing nothing else of it
void updateTranscript(Long attachmentId, String transcript);
ChannelService
// Delete a channel but keep its sessions, which lose the channel and the external
// user's id; delete(channel), instead, deletes the channel together with its sessions
void deleteKeepingSessions(ChannelDto channel);
// Find the web channel of a site by its origin, as browsers send it in the Origin header
Optional<WebChannelDto> findWebByOrigin(String origin);
// Find a channel of the application's own type by one of its settings, such as a bot id
Optional<CustomChannelDto> findCustomBySetting(String type, String setting, String value);
LLM Services
// LlmCheckService: send the LLM a short request and return its answer, storing nothing;
// throws ModelRequestException, without credentials, when the model does not answer
String check(LlmDto llm);
// LlmContentTypeService: the content types an LLM accepts, always including TEXT
Set<ContentType> contentTypesOf(LlmDto llm);
// LlmContentTypeService: the content types to prefill for a model being configured
Set<ContentType> suggestContentTypes(LlmType type, LlmConfiguration configuration);
// AudioTranscriptionService: the stored transcript, or one transcribed and stored now;
// empty when the attachment cannot be transcribed or transcription failed
Optional<String> transcriptOf(MessageAttachmentDto audio);
Note
The pre-2.0 overloads that took loose parameters instead of a request record — addMessage(Long, String, …), processMessages(Long, boolean), processMessagesWithStreaming(Long, boolean, StreamingHandler) and addSystemMessage(SessionDto, String, …) — still exist as deprecated defaults that delegate to the new methods. They are marked for removal, so migrate to the request records.
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
Linking to Filtered Views
The Chat, LLM Inspector, Assistants, and LLMs views load their filters from URL query parameters, so a filtered grid can be linked to directly from application code or from another system. For example, as/chat?session_filter=1&summary_filter=example opens the Chat view on the session with id 1 whose summary contains example.
Filters absent from the URL are cleared, and a value that cannot be parsed, or that names an unknown item, is ignored without affecting the remaining filters. Multiple values are sent either as a repeated parameter or separated by commas (assistant_filter=1,2). Enumerated values are matched by constant name, ignoring case. Dates use the ISO-8601 format (2026-01-31) and times use HH:mm, both read in the browser's time zone.
In the Chat and LLM Inspector views, a URL with filter parameters also clears the default active status, so as/chat?summary_filter=example matches closed sessions too. A URL without any filter parameter keeps the last applied filters.
Chat and LLM Inspector views
| Parameter | Value |
|---|---|
session_filter |
Session id |
summary_filter |
Text contained in the summary |
author_filter |
Usernames of the session authors |
participant_filter |
Usernames of the participants. Applied for administrators only |
active_participants_filter |
true to match only currently active participants. Applied for administrators only |
type_filter |
Session type: AUTOMATIC or MANUAL |
status_filter |
Session status: ACTIVE, ARCHIVED, or CLOSED |
rating_filter |
Rating from 1 to 5, or 0 for unrated. Requires status_filter=CLOSED |
assistant_filter |
Assistant ids |
channel_filter |
Channel ids |
from_filter / from_time_filter |
Start of the date range |
until_filter / until_time_filter |
End of the date range |
activity_date_filter |
true to apply the date range to the last activity instead of the creation date |
Assistants view
| Parameter | Value |
|---|---|
id_filter |
Assistant id, matched as in the Id filter |
name_filter |
Text contained in the name |
categories_filter |
Category ids |
enabled_filter |
true or false |
LLMs view
| Parameter | Value |
|---|---|
id_filter |
LLM id, matched as in the Id filter |
name_filter |
Text contained in the name |
type_filter |
LLM type: OPEN_AI, OLLAMA, ANTHROPIC, or GEMINI |
enabled_filter |
true or false |
Grid Column Keys
The grids of the administration views give each column a key, so application code can reach a column with Grid.getColumnByKey instead of relying on its position. The keys are part of the public contract:
| View | Column keys |
|---|---|
| Assistants | id, name, llm, prompt, categories, tools, default, status, actions |
| LLMs | id, name, type, status, system, actions |
| Prompts | id, name, content, status, revision, actions |
| Categories | id, name, description, status, actions |
| Documents | id, name, categories, status, actions |
| Channels | id, type, name, status, assistant, identifier, sessions, actions |
On a narrow screen the views hide some columns and show them again when the screen widens, so a column visibility set from application code lasts until the next change of screen size.
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 new messages, using the prompt file named by appjars.aisupport.summarize.prompt-file.
The provider is called when a session has new public messages: right after its first answer, and then at most once every appjars.aisupport.summarize.delay. It receives the session, which still holds its previous summary, the latest public message, and the public messages before it since that summary, oldest first. Private and system messages are never passed, because everyone who can see the session sees its summary. Returning null or a blank text clears the summary, and throwing an exception leaves it unchanged.
To generate summaries differently, for example without a model call, implement the interface:
public class FirstMessageSummaryProvider implements SessionSummaryProvider {
@Override
public String generate(SessionDto session, MessageDto latestMessage,
List<MessageDto> previousMessages) {
if (session.getSummary() != null) {
return session.getSummary();
}
MessageDto first = previousMessages.isEmpty() ? latestMessage : previousMessages.getFirst();
return first.getContent().lines().findFirst().orElse(null);
}
}
Then select it by its fully qualified class name:
appjars.aisupport.summarize.provider=com.example.ai.FirstMessageSummaryProvider
AI Support creates the class with its public no-argument constructor, so it is not a Spring bean and cannot have dependencies injected. A provider that needs Spring beans can be registered as a @Primary @Component instead, which takes precedence over the default provider without setting the property. When the property is empty and no such bean exists, or the class cannot be created, the default provider is used; a failure to create it is logged as an error.
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.
Upgrading from 2.1.0
Upgrading to 2.2.0 requires a few one-time database changes, and changes the behaviour and the API in ways an integrating application may notice. Hibernate's spring.jpa.hibernate.ddl-auto=update adds new tables, columns, and indexes, but it never removes a constraint or widens a column, so the statements below have to be run by hand. The constraint names are the ones PostgreSQL gives the check constraints that earlier versions had Hibernate create.
Create the indexes beforehand on a large database
2.2.0 adds thirteen named indexes (see Database Schema), which Hibernate's schema update creates on the first start. A plain CREATE INDEX blocks writes to its table while it is built, so on a large or live database create them before upgrading, with the exact names, outside a transaction, and one table at a time. Hibernate then finds them and leaves them as they are:
CREATE INDEX CONCURRENTLY as_message_session_event_idx ON as_message (session_id, event_date);
CREATE INDEX CONCURRENTLY as_message_external_id_idx ON as_message (external_id);
CREATE INDEX CONCURRENTLY as_message_answered_by_idx ON as_message (answered_by);
CREATE INDEX CONCURRENTLY as_llm_exchange_session_idx ON as_llm_exchange (session_id);
CREATE INDEX CONCURRENTLY as_llm_exchange_request_date_idx ON as_llm_exchange (request_date);
CREATE INDEX CONCURRENTLY as_message_attachment_message_idx ON as_message_attachment (message_id);
CREATE INDEX CONCURRENTLY as_participant_session_user_idx ON as_participant (session_id, user_id);
CREATE INDEX CONCURRENTLY as_participant_user_activity_idx ON as_participant (user_id, last_public_activity_seen);
CREATE INDEX CONCURRENTLY as_participant_last_message_seen_idx ON as_participant (last_message_seen);
CREATE INDEX CONCURRENTLY as_session_channel_external_idx ON as_session (channel_id, external_id);
CREATE INDEX CONCURRENTLY as_session_status_activity_idx ON as_session (status, last_total_activity);
CREATE INDEX CONCURRENTLY as_session_user_idx ON as_session (user_id);
CREATE INDEX CONCURRENTLY as_session_assistant_idx ON as_session (assistant_id);
A concurrent build that fails leaves an invalid index behind, which Hibernate does not repair. Check that every index is valid, and drop and recreate any that is not:
SELECT c.relname FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname LIKE 'as\_%\_idx' AND NOT i.indisvalid;
Indexes are matched by name only: an equivalent index under another name is not recognised, and one with the expected name but other columns is not corrected.
Drop the LLM type constraints and widen the LLM configuration
Databases created by earlier versions only accept the LLM types those versions knew. Drop the constraints before creating an Anthropic or Gemini LLM; otherwise saving it fails, and so does recording the first exchange of an assistant that uses it. The configuration column of as_llm also holds only 255 characters, which is too short for an OpenAI project key together with the content types of its model. Saving such an LLM fails, and the LLMs view reports that the database rejected it.
ALTER TABLE as_llm DROP CONSTRAINT IF EXISTS as_llm_type_check;
ALTER TABLE as_llm_exchange DROP CONSTRAINT IF EXISTS as_llm_exchange_llm_type_check;
ALTER TABLE as_llm ALTER COLUMN configuration TYPE text;
Drop the channel type constraint
The channel type is no longer a closed list, but earlier databases keep a check constraint on as_channel.channel_type. Drop it before creating a Web channel or a channel of the application's own type:
ALTER TABLE as_channel DROP CONSTRAINT IF EXISTS as_channel_channel_type_check;
Add the transcript column when the schema is not managed by Hibernate
Transcripts are stored in the new transcript column of as_message_attachment. Hibernate's schema update adds it; a database whose schema is managed otherwise needs it added once, together with the three new channel tables listed in Database Schema:
ALTER TABLE as_message_attachment ADD COLUMN transcript text;
The chat memory is larger, and downgrading fails
appjars.aisupport.memory.max-tokens defaults to 25000 instead of 10000, so an application that leaves it unset sends up to two and a half times as much memory with each request of a long session. Set it to 10000 to keep the previous size. PDFs are now recorded in the memory with the pages they are sent with, which 2.1.x cannot read: after a downgrade, every session that holds a PDF fails.
Answering no longer regenerates the session summary
AssistantService.processMessages and processMessagesWithStreaming leave the summary as it is, and StreamingHandler.onSummaryChanged is never called. The chat views request summaries themselves; code that adds messages outside them calls AssistantService.summarizeSession when it wants one. A custom SessionSummaryProvider keeps its signature, but it now receives the latest public message and the public messages before it since the previous summary, and it is called at most once every appjars.aisupport.summarize.delay.
Every assistant follows the assistant rules
Every assistant now receives the assistant rules before its prompt. The system flag of prompts (PromptDto.system, PromptFilter.isSystem) never had an effect and is deprecated. The memory of a session opened before the upgrade still mixes the private messages it holds with the public ones until the session is closed. To keep them out of public answers right away, clear those memories once; their assistants then lose the earlier context of those conversations:
DELETE FROM as_chat_memory;
Interfaces gained methods
Applications that implement these interfaces themselves, rather than using the implementations AI Support provides, must implement the new methods:
AssistantService:summarizeSession.SessionServiceandSessionDao:updateSummary.MessageAttachmentServiceandMessageAttachmentDao:updateTranscript.ChannelService:findWebByOrigin,findCustomBySetting, anddeleteKeepingSessions.ChannelDao:findWebChannelByOriginandfindCustomChannelBySetting.
ChannelDto.getChannelType() returns null for channels of the application's own types; getType() returns the type key of every channel. The enum-typed channel accessors, and ChannelForm.channelType(), ChannelWebhook.channelType(), and ChannelWebhook.getChannelType(), are deprecated in favour of their type() and getType() counterparts.
The logging backend is left to the application
appjars-ai-support-business-impl no longer declares spring-boot-starter-log4j2, and appjars-ai-support-flow no longer excludes spring-boot-starter-logging. AI Support logs through SLF4J only, so the application now gets Spring Boot's default backend, Logback, unless it declares another. An application that relied on AI Support to bring in Log4j2 must declare spring-boot-starter-log4j2 itself and exclude spring-boot-starter-logging.