Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 43 additions & 5 deletions articles/flow/ai-support/ai-powered-form.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ controller.ignore(internalIdField);
Ignored fields are hidden from the LLM and stay editable while a fill is in progress.


== Hidden, Disabled, and Read-Only Fields

The controller checks each field's state on every turn:

* A field hidden via [methodname]`setVisible(false)`, or one that sits inside a hidden container, is dropped from the LLM surface entirely. The model cannot read its value and cannot write to it. It reappears the moment a value-change listener or other application code makes it visible again.
* A field the application has disabled ([methodname]`setEnabled(false)`) or set read-only ([methodname]`setReadOnly(true)`) stays in the form state as read-only context. The model sees its current value -- useful as context for writes to other fields -- but any write is rejected with a reason.

A common case is a conditional field driven by a [interfacename]`ValueChangeListener` -- a "Cost center" enabled only when "Trip type" is set to "Business", or a renewal date enabled by a "Renews automatically" checkbox. The built-in workflow instructions tell the model that a disabled or read-only field is usually waiting on a controlling field, so it sets the controlling field first and writes the dependent one in the same turn.


== Field Descriptions

The LLM sees each field's label, helper text, and component type. When those don't fully capture the field's meaning, for example a numeric field that takes a percentage rather than an absolute amount, or a date that means "renewal date" rather than "purchase date", add an explicit description with [methodname]`describe()`:
Expand Down Expand Up @@ -146,12 +156,13 @@ A field whose value type is a [classname]`Collection` must implement [classname]

== Validation

Each value the LLM writes is validated immediately after the field accepts it:
When the model writes back a set of values, the controller commits all of them first and then runs validation once against the resulting form. Each field is checked according to how it is wired:

* A field that is bound through a [classname]`Binder` is validated through its binding, so the converter and every registered validator run as one unit.
* An unbound field with a default validator (for example the email-format check on [classname]`EmailField`, or the `min` and `max` constraints on [classname]`NumberField` and [classname]`DatePicker`) is validated through that validator.
* Cross-field rules registered with `binder.withValidator((bean, ctx) -> ...)` -- see <<{articles}/building-apps/forms-data/add-form/validation#binder-level-validators,Binder-Level Validators>> -- are evaluated against the post-write form, but only when the binder has a bean set ([methodname]`setBean(bean)`) and every per-field check passes first. A failing rule is reported as a form-level rejection rather than against any single field, so the model can adjust the offending values and try again in the same turn.

If validation fails, the value stays in the field, the field's UI error indicator turns on, and the failure is reported back to the model with the field id and the validator's message. The model can supply a corrected value in the same turn, so users typically see only the final, valid state.
If validation fails for a written field, the value stays in the field, the field's UI error indicator turns on, and the failure is reported back to the model with the field id and the validator's message. The model can supply a corrected value in the same turn, so users typically see only the final, valid state. Fields the current turn did not write are not flagged invalid as a side effect -- a required field the user has not reached yet stays clean.


== What the Model Sees
Expand All @@ -161,12 +172,13 @@ The controller sends only a defined subset of the form to the LLM. Knowing where
The model sees:

* Each visible field's label, helper text, component type, and any [methodname]`describe()` text or [classname]`Binder` property-name default.
* The current value of every visible, non-ignored field, so it can decide which entries to overwrite.
* The current value of every visible, non-ignored field, so it can decide which entries to overwrite. Disabled and application-set read-only fields are included for context, with a flag telling the model not to write to them.
* The eager items of a combo box or select, or the labels returned by a [methodname]`valueOptions()` query callback for the filter the model supplies.

The model does not see:

* Any field excluded with [methodname]`ignore()`. Its value, label, and existence are all hidden.
* Any field the application has hidden via [methodname]`setVisible(false)`, or that sits inside a hidden container.
* The contents of [classname]`PasswordField`, which is always excluded.
* Internal data, services, or beans. The model has access only to what the field components themselves show.

Expand All @@ -177,16 +189,40 @@ Every visible field's current value is forwarded to the LLM provider on every tu

== Field Locking During a Fill Turn

While a fill is in progress, every non-ignored field that wasn't already read-only is set to read-only, so the user cannot type into a field the AI is about to overwrite. Locks are released automatically when the turn ends, whether it succeeded or failed.
While a fill is in progress, every field the user can currently edit -- visible, enabled, and not already read-only -- is set to read-only so the user cannot type into a field the AI is about to overwrite. Fields the application had already disabled or set read-only stay as they were. Locks are released automatically when the turn ends, whether it succeeded or failed.

.Read-Only Toggles During a Turn
[NOTE]
If application code changes a field's read-only state during a turn, for example from a [interfacename]`ValueChangeListener` that reacts to one of the model's writes, that change is overridden when the controller releases its own locks at the end of the turn.


== Highlighting AI Changes

When an AI fill changes several fields at once, users benefit from a visual cue that flags which fields the AI wrote. The controller exposes two APIs for this:

* [methodname]`addFieldValueChangedListener()` registers a listener that fires once per successful turn with the fields whose values changed.
* [methodname]`showHighlight()` and [methodname]`hideHighlight()` toggle a per-field highlight rendered by the `vaadin-field-highlighter` web component.

A typical pattern flashes every changed field after each fill:

[source,java]
----
controller.addFieldValueChangedListener(changes ->
changes.forEach(change -> controller.showHighlight(change.field())));
----

The listener receives a list of [classname]`FieldValueChange` records in document order, each carrying the field, its pre-turn value, and its post-turn value. Fields the application has marked with [methodname]`ignore()` are excluded from the list. The listener is not called when the turn ended in error or when no field's value changed. Multiple listeners can be registered; each is independent, and the returned [classname]`Registration` removes the listener when its [methodname]`remove()` is called.

A field hidden at turn start that is revealed and written into the same turn is reported with its real pre-turn value rather than `null`, so cascades into conditional fields show up correctly.

The listener runs on the UI thread with the session lock held, so it can call [methodname]`showHighlight()`, update components, or any other Vaadin API directly -- no [methodname]`ui.access()` wrapper is needed.

Repeated [methodname]`showHighlight()` calls on the same field are idempotent -- exactly one highlight remains. Each controller marks its highlight with an identifier unique to that instance, so the AI highlight coexists with any other `vaadin-field-highlighter` consumers the application keeps on the field, for example a collaboration session showing other users' edits. The highlight survives detach and re-attach: the controller re-applies it whenever the field returns to the DOM. The field passed to [methodname]`showHighlight()` does not need to belong to the controller's form -- any [classname]`HasValue` [classname]`Component` works.


== Reconnecting After Deserialization

[classname]`FormAIController` is not serialized with the orchestrator. After session restore, create a new controller against the same form (and binder, if any), reapply the same [methodname]`describe()`, [methodname]`valueOptions()`, and [methodname]`ignore()` hints, and pass the controller to [methodname]`reconnect()`:
[classname]`FormAIController` is not serialized with the orchestrator. After session restore, create a new controller against the same form (and binder, if any), reapply the same [methodname]`describe()`, [methodname]`valueOptions()`, and [methodname]`ignore()` hints, re-register any change listeners, and pass the controller to [methodname]`reconnect()`:

[source,java]
----
Expand All @@ -195,6 +231,8 @@ controller.describe(discount, "Discount as a percentage between 0 and 100.")
.valueOptions(ValueOptions.forField(industry)
.options(List.of("Software", "Manufacturing", "Healthcare")))
.ignore(internalIdField);
controller.addFieldValueChangedListener(changes ->
changes.forEach(change -> controller.showHighlight(change.field())));

orchestrator.reconnect(provider)
.withController(controller)
Expand Down
84 changes: 84 additions & 0 deletions articles/flow/ai-support/session-context.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
title: Session Context
description: Pass per-turn ambient information -- date and time, tenant, locale, page state -- to the LLM through the AIOrchestrator.

Check failure on line 3 in articles/flow/ai-support/session-context.adoc

View workflow job for this annotation

GitHub Actions / lint

[vale] reported by reviewdog 🐶 [Vale.Spelling] Did you really mean 'AIOrchestrator'? Raw Output: {"message": "[Vale.Spelling] Did you really mean 'AIOrchestrator'?", "location": {"path": "articles/flow/ai-support/session-context.adoc", "range": {"start": {"line": 3, "column": 119}}}, "severity": "ERROR"}
meta-description: Learn how to send per-turn session context to the LLM using the Vaadin AIOrchestrator withMetadata API, with a built-in current date and time default.

Check failure on line 4 in articles/flow/ai-support/session-context.adoc

View workflow job for this annotation

GitHub Actions / lint

[vale] reported by reviewdog 🐶 [Vale.Spelling] Did you really mean 'withMetadata'? Raw Output: {"message": "[Vale.Spelling] Did you really mean 'withMetadata'?", "location": {"path": "articles/flow/ai-support/session-context.adoc", "range": {"start": {"line": 4, "column": 105}}}, "severity": "ERROR"}
order: 35
---


= [since:com.vaadin:vaadin@V25.2]#Session Context#


The orchestrator can pass a short, free-form string of session context to the LLM on every turn -- the current date and time, the active tenant, the user's locale, the page the user is on, anything else worth giving the model up front. The string is captured at the start of the turn, so it always reflects the state at the time the user sent the prompt.


== Default Current Date and Time

By default, the orchestrator sends a single line with the server's current date and time so the LLM can interpret relative date references like "show me sales from the past two months" or "schedule a follow-up for next Friday" without having to guess. No setup is needed beyond constructing the orchestrator as usual; the line the model receives looks like:

Check warning on line 17 in articles/flow/ai-support/session-context.adoc

View workflow job for this annotation

GitHub Actions / lint

[vale] reported by reviewdog 🐶 [Vaadin.FirstPerson] Use first person (such as 'me') sparingly. Raw Output: {"message": "[Vaadin.FirstPerson] Use first person (such as 'me') sparingly.", "location": {"path": "articles/flow/ai-support/session-context.adoc", "range": {"start": {"line": 17, "column": 151}}}, "severity": "WARNING"}

[source]
----
Current server date and time: 2026-05-28T17:42+03:00 (Friday, Europe/Helsinki)
----


== Custom Context

To pass anything else -- or to compose several pieces of context together -- register a supplier with [methodname]`withMetadata()`:

[source,java]
----
AIOrchestrator.builder(provider, systemPrompt)
.withMessageList(messageList)
.withInput(messageInput)
.withMetadata(() -> {
String tenant = TenantContext.current().getName();
String locale = UI.getCurrent().getLocale().toLanguageTag();
return "Tenant: " + tenant + "\nUser locale: " + locale;
})
.build();
----

The supplier is invoked once per turn, just before the request goes out. The returned string is passed to the model without further interpretation; compose multiple pieces of context with plain string concatenation. Setting a supplier replaces the default current-date-and-time line; include it yourself if you still want it:

Check warning on line 42 in articles/flow/ai-support/session-context.adoc

View workflow job for this annotation

GitHub Actions / lint

[vale] reported by reviewdog 🐶 [Vaadin.JustSimply] Avoid using 'just'. It may be insensitive. Raw Output: {"message": "[Vaadin.JustSimply] Avoid using 'just'. It may be insensitive.", "location": {"path": "articles/flow/ai-support/session-context.adoc", "range": {"start": {"line": 42, "column": 40}}}, "severity": "WARNING"}

[source,java]
----
.withMetadata(() -> "Tenant: " + TenantContext.current().getName()
+ "\nServer time: " + ZonedDateTime.now())
----

A supplier that returns `null` or a blank string skips the context for that turn, which is convenient for "include this only when X" patterns:

[source,java]
----
.withMetadata(() -> {
List<Incident> open = alertService.openIncidentsFor(currentUser());
return open.isEmpty()
? null
: "Open incidents this user owns: " + open;
})
----


== Disabling Session Context

Pass `null` to disable session context entirely, including the default date-and-time line:

[source,java]
----
AIOrchestrator.builder(provider, systemPrompt)
.withMetadata(null)
.build();
----


== Threading and Errors

The supplier runs on the UI thread under the session lock, so it can read [methodname]`UI.getCurrent()` and any session-scoped state. Keep it fast -- anything that touches a database, a remote service, or the file system should be done outside the supplier and cached in session-scoped state.

A supplier that throws aborts the turn through the normal error path: the assistant placeholder is replaced with a generic error message, the [classname]`ResponseListener` (if registered) fires with the thrown exception, and the exception propagates to the caller of the prompt entry point.


== Why Not the System Prompt

The system prompt is sent verbatim on every turn. Many LLM providers use it as the cache prefix to keep latency and cost down between calls; putting per-turn values such as the current time or the active tenant into the system prompt invalidates that cache on every request. Session context is delivered through a separate per-turn channel that lives outside the system prompt, so the system prompt stays cacheable.
Loading