aiChat

Компонент aiChat представляет собой готовый чат: список сообщений, поле ввода сообщений, пустое состояние и цикл генерации, который связывает их с языковой моделью.

XML-элемент

aiChat

Java-класс

AiChat

Он состоит из компонентов aiMessageList и aiMessageInput и настраивает их автоматически, поэтому большая часть того, что описано на страницах этих компонентов, доступна и отсюда.

Диалог

Основы

Для чата нужны элемент и провайдер. Элемент:

<aichat:aiChat id="chat" width="100%" height="100%"/>

Провайдер — единственная функция, которую компонент запрашивает у приложения. Он описан в разделе Подключение модели:

@Autowired
private ChatClient.Builder chatClientBuilder;

private ChatClient chatClient;

@Subscribe
public void onInit(final InitEvent event) {
    chatClient = chatClientBuilder.build();
}

@Install(to = "chat", subject = "llmProvider")
private Flux<String> llmProvider(final LLMProvider.LLMRequest request) {
    return chatClient.prompt()
            .user(request.userMessage())
            .stream()
            .content();
}

Этого достаточно для работающего чата. Он хранит диалог в памяти и теряет его при закрытии экрана. Свяжите чат с данными, чтобы диалог сохранялся после закрытия экрана.

Системный промпт

Системный промпт сообщает модели, кто она: ее роль, язык ответов, на что она должна отвечать отказом. Объявите его как вложенный элемент, который принимает многострочное значение без изменений:

            <aichat:systemPrompt><![CDATA[You are the support assistant for an order management application.

Answer only questions about orders, customers and deliveries. If a question is
about anything else, say so and suggest the user contact their account manager.

Always answer in the language the question was asked in. Keep answers short:
two or three sentences unless the user asks for detail. When you refer to an
order, always include its number.

Never invent an order number, a delivery date or a price. If the answer is not
in the conversation, say that you do not have it.]]></aichat:systemPrompt>

Атрибут systemPrompt вместо этого принимает однострочное значение и поддерживает ключ локализованного сообщения.

Объявленный системный промпт сам по себе ничего не делает. Чат добавляет его в запрос, а провайдер решает, что отправить. Провайдер, который читает только сообщение пользователя, отбрасывает промпт, и модель отвечает так, как будто промпта нет.

Передайте его дальше:

@Install(to = "chat", subject = "llmProvider")
private Flux<String> llmProvider(final LLMProvider.LLMRequest request) {
    ChatClient.ChatClientRequestSpec prompt = chatClient.prompt();

    String systemPrompt = request.systemPrompt(); (1)
    if (systemPrompt != null && !systemPrompt.isBlank()) {
        prompt = prompt.system(systemPrompt);
    }

    return prompt.user(request.userMessage())
            .stream()
            .content();
}
1 Объявленный промпт приходит сюда и попадает в модель только потому, что провайдер передает его дальше. Если промпт не объявлен, значение отсутствует, а ChatClient.system(String) не принимает пустой текст.

То же самое относится к вложениям, которые передаются в запросе вместе с промптом и сообщением. См. раздел Отправка файлов модели.

Сообщения и данные

Сообщение — это данные. Чат читает его через контракт AiChatMessage: роль, текст, необязательное время и вложения. Чат никогда не изменяет сообщение напрямую.

По умолчанию чат хранит в памяти список встроенных сообщений. Чтобы сохранять диалог, создайте собственную сущность, которая реализует этот контракт:

@JmixEntity
@Table(name = "CHAT_MESSAGE")
@Entity
public class ChatMessage implements AiChatMessage {

    @JmixGeneratedValue
    @Column(name = "ID", nullable = false)
    @Id
    private UUID id;

    @Version
    @Column(name = "VERSION", nullable = false)
    private Integer version;

    @Column(name = "ROLE", nullable = false, length = 50)
    private String role;

    @Column(name = "CONTENT")
    @Lob
    private String content;

    @CreatedDate
    @Column(name = "CREATED_DATE")
    private OffsetDateTime createdDate;

    @ElementCollection
    @CollectionTable(name = "CHAT_MESSAGE_ATTACHMENTS",
            joinColumns = @JoinColumn(name = "CHAT_MESSAGE_ID"))
    @OrderColumn(name = "ATTACHMENTS_ORDER")
    @Convert(converter = FileRefConverter.class)
    @Column(name = "ATTACHMENTS", length = 1024)
    private List<FileRef> attachments;

    @Override
    public AiMessageRole getRole() {
        return role == null ? null : AiMessageRole.fromId(role);
    }

    public void setRole(AiMessageRole role) {
        this.role = role == null ? null : role.getId();
    }

    @Override
    public String getContent() {
        return content == null ? "" : content;
    }

    public void setContent(@Nullable String content) {
        this.content = content;
    }

    @Override
    public @Nullable Instant getTime() {
        return createdDate == null ? null : createdDate.toInstant();
    }

    @Override
    public List<FileRef> getAttachments() {
        return attachments == null ? List.of() : attachments; (1)
    }
1 Контракт требует список и никогда не допускает null, а пустая коллекция элементов приходит из базы данных как null.

Затем загрузите сущность в контейнер коллекции и укажите его для чата:

Данные
<collection id="messagesDc" class="com.company.demo.entity.ChatMessage">
    <fetchPlan extends="_base"/>
    <loader id="messagesDl">
        <query>
            <![CDATA[select e from ChatMessage e order by e.createdDate]]>
        </query>
    </loader>
</collection>
Компоновка
        <aichat:aiChat id="chat"
                       dataContainer="messagesDc"
                       autoSave="true"
                       width="100%"
                       height="100%">
            <aichat:actions>
                <aichat:action id="clearAction"
                               text="msg://clearAction.text"
                               icon="TRASH"/>
            </aichat:actions>
            <aichat:systemPrompt><![CDATA[You are the support assistant for an order management application.

Answer only questions about orders, customers and deliveries. If a question is
about anything else, say so and suggest the user contact their account manager.

Always answer in the language the question was asked in. Keep answers short:
two or three sentences unless the user asks for detail. When you refer to an
order, always include its number.

Never invent an order number, a delivery date or a price. If the answer is not
in the conversation, say that you do not have it.]]></aichat:systemPrompt>
            <aichat:inputHeader>
                <span id="contextSpan" text="msg://contextSpan.text"/>
            </aichat:inputHeader>
            <aichat:inputFooter>
                <span text="msg://disclaimer.text"/>
            </aichat:inputFooter>
            <aichat:emptyStateHeader>
                <h3 text="msg://emptyStateHeader.text"/>
            </aichat:emptyStateHeader>
            <aichat:emptyStateFooter>
                <span text="msg://emptyStateFooter.text"/>
            </aichat:emptyStateFooter>
        </aichat:aiChat>

Сортируйте результат запроса по хранимому атрибуту, как в примере выше. Поле time контракта вычисляется из вашей сущности, и JPQL не может его использовать. Сообщениям, сохраненным в одну и ту же секунду, нужен дополнительный критерий сортировки, чтобы их порядок сохранялся после перезагрузки.

Компонент не может самостоятельно создать вашу сущность, поэтому установите фабрику, которая ее создает:

@Install(to = "chat", subject = "messageFactory")
private ChatMessage messageFactory(final AiChatMessageContext context) {
    ChatMessage message = dataManager.create(ChatMessage.class);
    message.setRole(context.getRole());
    message.setContent(context.getContent());
    return message;
}

Фабрика вызывается один раз для каждого зафиксированного сообщения: для сообщения пользователя — при его отправке, для ответа — при его завершении.

Сохранение

Атрибут autoSave="true", показанный в примере выше, заставляет чат сохранять сообщения через DataContext экрана: каждое зафиксированное сообщение объединяется с контекстом и сохраняется, а сообщение, удаленное при откате диалога, удаляется.

autoSave сохраняет весь DataContext, а не только сообщение, поэтому при отправке сообщения также сохраняются несвязанные изменения, сделанные в других частях экрана. Кроме того, это XML-атрибут без эквивалента в Java: в контроллере вместо него устанавливаются делегат сохранения и делегат удаления. Так же поступает приложение, которому нужно сохранять сообщения по-другому.

Без autoSave сохраняйте сообщения сами, установив два делегата. saveDelegate вызывается для каждого зафиксированного сообщения до того, как оно попадет в диалог, и чат использует экземпляр, который возвращает делегат. Слой хранения обычно возвращает другой объект, чем тот, который получил, и именно этот объект далее отслеживают контейнер и DataContext. removeDelegate вызывается для каждого сообщения, которое удаляется при откате диалога, начиная с самого нового.

Приложение, которому нужно сохранять сообщения по-своему, может не устанавливать ни один из делегатов и слушать событие MessageChangeEvent, которое сообщает о каждом добавленном или удаленном сообщении.

Пустое состояние

До первого сообщения чат размещает поле ввода сообщений в центре и предоставляет вокруг него два слота для приветствия, подсказок или предупреждения:

<aichat:emptyStateHeader>
    <h3 text="msg://emptyStateHeader.text"/>
</aichat:emptyStateHeader>
<aichat:emptyStateFooter>
    <span text="msg://emptyStateFooter.text"/>
</aichat:emptyStateFooter>
Пустое состояние

Как только появляется первое сообщение, пустое состояние сменяется обычной компоновкой, в которой поле ввода закреплено внизу.

Полосы поля ввода сообщений

inputHeader и inputFooter размещают содержимое приложения внутри поля ввода сообщений, но вне его рамки, например строку контекста над полем или предупреждение под ним:

XML
<aichat:inputHeader>
    <span id="contextSpan" text="msg://contextSpan.text"/>
</aichat:inputHeader>
<aichat:inputFooter>
    <span text="msg://disclaimer.text"/>
</aichat:inputFooter>
Пакет сообщений
com.company.demo.view.chat/disclaimer.text=AI can make mistakes. Check important information.

Пока чат пуст, обе полосы скрыты, потому что для этого места у пустого состояния есть свои слоты. После отправки сообщения полоса появляется. Чтобы полоса была видна и в пустом чате, примените вариант темы input-header-component-always-visible или input-footer-component-always-visible.

Генерация

Чат переходит между тремя состояниями (ожидание, генерация и ошибка) и сообщает о каждом переходе:

@Subscribe("chat")
public void onChatGenerationStateChange(
        final GenerationStateChangeEvent<SimpleAiChatMessage> event) {
    stateSpan.setText(event.getNewState().name());
    stopButton.setEnabled(event.getNewState() == GenerationState.GENERATING);
}
Чат генерирует ответ

Остановка и повторная генерация доступны пользователю без дополнительного кода: во время генерации ответа кнопка отправки в поле ввода сообщений становится кнопкой остановки, а на баннере ошибки появляется кнопка Retry. Частичный ответ остановленной генерации остается на экране, но никогда не фиксируется.

Те же операции доступны из приложения, если чат должен предлагать их и в другом месте, например на панели инструментов над диалогом, в меню или по сочетанию клавиш. В примере ниже подключены две такие кнопки. Для работы встроенных элементов управления ничего из этого не нужно:

@Subscribe("stopButton")
public void onStopButtonClick(final ClickEvent<JmixButton> event) {
    chat.stop();
}

@Subscribe("regenerateButton")
public void onRegenerateButtonClick(final ClickEvent<JmixButton> event) {
    chat.regenerate();
}

Чтобы пользователь мог повторно сгенерировать любой ответ, а не только последний, объявите встроенное действие сообщения. Оно откатывает диалог к этому сообщению и запускает генерацию заново:

<aichat:assistantActions>
    <aichat:action id="assistantRegenerate"
                   type="aichat_messageRegenerate"/>
</aichat:assistantActions>

При ошибке генерации список сообщений показывает баннер ошибки с кнопкой Retry, а приложение получает уведомление с ошибкой и текстом, который пришел до нее:

@Subscribe("chat")
public void onChatGenerationFailed(
        final GenerationFailedEvent<SimpleAiChatMessage> event) {
    notifications.show(messageBundle.formatMessage("generationFailed.notification",
            event.getError().getMessage()));
}
Ошибка сохранения и ошибка генерации используют одно и то же состояние ошибки, и кнопка Retry заново генерирует ответ, а не повторяет сохранение.

Действия чата

Чат содержит обычные действия Jmix, которые можно связать с любым компонентом экрана:

Объявление
<aichat:actions>
    <aichat:action id="clearAction"
                   text="msg://clearAction.text"
                   icon="TRASH"/>
</aichat:actions>
Связывание
<button id="clearButton" action="chat.clearAction"/>

Это собственные действия чата, а не действия отдельных сообщений, описанные в разделе Действия над сообщениями.

Настройка составных частей

Все возможности списка сообщений и поля ввода сообщений доступны через чат. Атрибуты maxRows, enterAction и атрибуты вложений настраивают поле ввода сообщений; announceMessages настраивает список сообщений; элементы thinkingIndicator, thinkingStages, userActions и assistantActions объявляются в чате и применяются к списку сообщений; а inputPrefix и inputSuffix заполняют слоты поля ввода сообщений. Назначение каждого из них описано на страницах aiMessageList и aiMessageInput.

Стилизация

У aiChat нет собственного пользовательского элемента. Он отображается как обычный div, который содержит список сообщений и поле ввода сообщений. Поэтому он не предоставляет shadow parts, и его нельзя выбрать по имени тега в CSS или найти по нему в инспекторе браузера. Задавайте стили через класс или id, которые вы ему назначили, а к его частям обращайтесь через компоненты внутри него:

XML
<aichat:aiChat id="chat"
               classNames="branded-chat"
               themeNames="fixed-toolbar"
               width="100%"
               height="100%"/>
CSS
.branded-chat jmix-ai-message-input::part(input-container) {
    border-radius: 0;
    border-color: var(--vaadin-border-color-secondary);
}

Следующие свойства стилей можно использовать в таблицах стилей CSS для настройки внешнего вида компонента.

Имя Описание

--jmix-ai-chat-max-width

Ширина колонки диалога. Значение передается в список сообщений и поле ввода сообщений, поэтому оно одновременно меняет размер диалога, поля ввода и пустого состояния.

Все остальное стилизуется в составляющих его компонентах. См. aiMessageList, aiMessageInput и aiCodeBlock.

Варианты темы

Вариант Описание

fixed-toolbar

Всегда размещает элементы управления поля ввода сообщений в отдельной строке. Передается в поле ввода сообщений.

input-footer-component-always-visible

Оставляет полосу inputFooter видимой, пока чат пуст. См. Полосы поля ввода сообщений.

input-header-component-always-visible

Оставляет полосу inputHeader видимой, пока чат пуст. См. Полосы поля ввода сообщений.

no-header-footer-gap

Убирает промежуток между полосами и рамкой поля ввода. Передается в поле ввода сообщений.

Локализация

Собственные объявления чата берутся из пакета сообщений дополнения по ключам aiChat.i18n.*. Его вложенный элемент i18n повторяет дерево компонентов: атрибуты самого элемента меняют тексты чата, а вложенные элементы messageList, messageInput и codeBlock относятся к частям, из которых он состоит. См. Локализация.

Вложения

Чат поддерживает вложения на всем пути: поле ввода сообщений собирает их, фабрика сообщений сохраняет их, а список сообщений отображает их. См. Вложения.

Атрибуты

aiChat имеет следующие уникальные атрибуты:

Имя Описание По умолчанию

acceptedFileExtensions

Разрешает вложения только с указанными расширениями файлов. См. Вложения.

—

acceptedMimeTypes

Разрешает вложения только с указанными типами медиа. См. Вложения.

—

announceMessages

Определяет, объявляются ли новые сообщения программам чтения с экрана. См. Доступность.

false

attachmentsEnabled

Определяет, принимает ли поле ввода сообщений вложения. См. Вложения.

false

autoSave

Сохраняет зафиксированные сообщения через DataContext экрана. См. Сохранение.

false

dataContainer

Связывает чат с контейнером коллекции сообщений. См. Сообщения и данные.

—

enterAction

Определяет, что делает клавиша Enter в поле ввода сообщений. См. Текстовое поле.

SEND

maxFileSize

Задает максимальный размер одного вложения в байтах. См. Вложения.

26214400

maxFiles

Задает, сколько файлов можно прикрепить к одному сообщению. См. Вложения.

10

maxRows

Задает, до скольких строк увеличивается поле ввода сообщений, прежде чем появится прокрутка. См. Текстовое поле.

10

systemPrompt

Задает системный промпт, который отправляется с каждым запросом. Для многострочного значения его также можно объявить как вложенный элемент.

—

themeNames

Применяет варианты темы, в том числе input-header-component-always-visible и input-footer-component-always-visible, которые оставляют полосу видимой, пока чат пуст. См. Полосы поля ввода сообщений.

—

uploadDropZoneEnabled

Определяет, можно ли перетаскивать файлы в поле ввода сообщений. См. Вложения.

true

aiChat имеет следующие общие атрибуты:

Обработчики

aiChat имеет следующие уникальные обработчики:

Имя Описание

AttachmentClickEvent

Срабатывает, когда пользователь нажимает на вложение в сообщении. См. Скачивание из диалога.

AttachmentRejectedEvent

Срабатывает, когда файл отклонен одним из ограничений на вложения. См. Отклонённые файлы.

GenerationFailedEvent

Срабатывает при ошибке генерации. Содержит ошибку и текст, полученный до нее. См. Генерация.

GenerationStateChangeEvent

Срабатывает при каждом переходе между состояниями ожидания, генерации и ошибки. См. Генерация.

MessageChangeEvent

Срабатывает, когда сообщение зафиксировано или удалено. См. Сохранение.

llmProvider

Возвращает ответ на запрос в виде потока текста. Обязателен. См. Основы.

messageFactory

Создает экземпляр сообщения для связанного источника. Обязателен, если чат связан с вашей собственной сущностью. См. Сообщения и данные.

removeDelegate

Удаляет сообщение, которое было убрано из диалога при откате. См. Сохранение.

saveDelegate

Сохраняет зафиксированное сообщение и возвращает экземпляр, который чат будет использовать дальше. См. Сохранение.

aiChat поддерживает следующие общие обработчики:

Элементы

aiChat может содержать следующие вложенные элементы: actions, systemPrompt, inputPrefix, inputSuffix, inputHeader, inputFooter, thinkingIndicator, thinkingStages, userActions, assistantActions, emptyStateHeader, emptyStateFooter и i18n.

actions

Элемент actions содержит собственные элементы action чата. См. Действия чата.

systemPrompt

Элемент systemPrompt содержит многострочный системный промпт и используется вместо одноименного атрибута.

inputPrefix, inputSuffix, inputHeader, inputFooter

Эти элементы заполняют слоты поля ввода сообщений: первые два — внутри его рамки, последние два — как полосы вне ее. См. Полосы поля ввода сообщений и Префикс и суффикс.

emptyStateHeader, emptyStateFooter

Эти элементы содержат контент, который отображается над полем ввода сообщений и под ним, пока чат пуст. См. Пустое состояние.

thinkingIndicator, thinkingStages, userActions, assistantActions

Эти элементы настраивают список сообщений и работают так, как описано для aiMessageList.

i18n

Элемент i18n меняет тексты этого чата, а через вложенные элементы messageList, messageInput и codeBlock — тексты частей, из которых он состоит. См. Локализация.