Skip to content

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"
Hold "Alt" / "Option" to enable pan & zoom

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 BIGINT identity columns. The only sequence is revinfo_seq, which Envers uses for revision numbers.
  • as_chat_memory.id is not generated: it is the session id, which is what makes the memory of a session directly addressable.
  • as_llm_exchange denormalises 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_content is kept only because existing schemas declare it NOT NULL, and it always holds {}.
  • as_assistant_tools stores tool assignments by name. assignment_type is BEAN when tool_reference is a Spring bean name, and TOOL when 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.mentions and as_llm_exchange.category_names are PostgreSQL array columns, not join tables.
  • as_embedding is 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_categories and as_document_categories — have no primary key of their own, following Hibernate's default for a @ManyToMany collection.
  • Enumerated columns carry no CHECK constraint. Columns such as as_llm.type, as_session.status, and as_message.sender_type store 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.system is 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 to E5SmallV2EmbeddingModel, 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 — a PgVectorEmbeddingStore backed by PostgreSQL with the pgvector extension, using the as_embedding table. It opens its own connections to the database of spring.datasource.url, or to the one named by the appjars.database.* properties when any of them is set (see Database).

Two further components initialise themselves on startup events rather than through this configuration class:

  • ToolScanner scans the application context for @Tool-annotated beans once the application is ready. See Tool Calling.
  • ScoringModelProvider loads 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.

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]
Hold "Alt" / "Option" to enable pan & zoom

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.shouldNotifyMessage passes, 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.com for example.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 in hosts, 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 @SpringBootApplication class is outside com.appjars.aisupport.flow.embed, add that package with @EnableVaadin. Because @EnableVaadin replaces 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.js answers with the application's page instead of the script.

  • Reverse proxy: set server.forward-headers-strategy so 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-tokens with 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-file guidance and the summarize.timeout. A summary longer than summary.max-tokens is 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.
  • SessionService and SessionDao: updateSummary.
  • MessageAttachmentService and MessageAttachmentDao: updateTranscript.
  • ChannelService: findWebByOrigin, findCustomBySetting, and deleteKeepingSessions.
  • ChannelDao: findWebChannelByOrigin and findCustomChannelBySetting.

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.