AI-Powered Form
- Basic Usage
- Field Discovery
- Hidden, Disabled, and Read-Only Fields
- Field Descriptions
- Options for Selection Fields
- Validation
- What the Model Sees
- Field Locking During a Fill Turn
- Highlighting AI Changes
- Reconnecting after Deserialization
- Composing Multiple Forms
FormAIController populates the fields of a FormLayout (Vaadin’s responsive multi-column form container) or any other layout, using values an LLM extracts from a user prompt or attached files. The controller traverses the layout, discovers every field, and allows the LLM to read the current values, query the available values for selection components like Combo Box or Radio Button Group, and write new values back. Each write is validated through the Binder, or through the component’s built-in validators if Binder is not used. Rejected values are reported back so the model can correct them on the next turn.
The controller works with any combination of standard Vaadin field components, such as TextField, ComboBox, DatePicker, MultiSelectComboBox, and CheckboxGroup. No extra wiring is needed beyond constructing the controller around the layout and attaching it to the orchestrator.
Basic Usage
Build the form, construct a FormAIController for it, and attach the controller to an orchestrator:
Source code
Java
TextField name = new TextField("Name");
EmailField email = new EmailField("Email");
ComboBox<String> country = new ComboBox<>("Country");
country.setItems("Finland", "Germany", "United States");
DatePicker hiredOn = new DatePicker("Hired on");
FormLayout form = new FormLayout(name, email, country, hiredOn);
MessageInput messageInput = new MessageInput();
FormAIController controller = new FormAIController(form);
AIOrchestrator.builder(provider, systemPrompt)
.withInput(messageInput)
.withController(controller)
.build();
add(messageInput, form);Example prompts:
-
"Fill in John Doe, john@acme.com, started in Germany on March 1, 2026."
-
"Maria from Helsinki, hired last Monday." (relative dates are interpreted by the LLM)
-
"Use the information in the attached resume to fill the form." (when the orchestrator has a file receiver configured)
|
Tip
|
Built-In Workflow Instructions
The controller already informs the LLM of the workflow it needs. You can focus your own system prompt on application-specific behavior, such as tone, naming conventions, or which fields the user may leave blank.
|
Field Discovery
The controller walks the container’s component tree on every LLM turn, so fields added or removed between turns are picked up automatically. The container can be any component that implements HasComponents. Any component that implements HasValue is treated as a field, and any nested HasComponents is walked recursively.
PasswordField is always hidden from the LLM. To hide other fields, for example internal IDs or anything sensitive that the user must fill in manually, call ignore():
Source code
Java
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
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 (
setEnabled(false)) or set read-only (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, and the model receives the rejection reason so it can adjust on the next turn.
A common case is a conditional field driven by a 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 describe():
Source code
Java
controller.describe(discount, "Discount as a percentage between 0 and 100.")
.describe(renewalDate, "When the subscription renews, not when it started.");Later calls for the same field overwrite earlier ones.
Binder Integration
When the form is backed by a Binder, pass the binder to the controller as well:
Source code
Java
Binder<Employee> binder = new Binder<>(Employee.class);
binder.bindInstanceFields(this);
FormAIController controller = new FormAIController(form, binder);For every named binding (bind("propertyName"), bindInstanceFields(this), or @PropertyId), the bean property name is used as a default field description, so when the user mentions a field by its bean-side name, the LLM can match the request to the right field. An explicit describe() call always overrides the default. Lambda-bound bindings have no property name and contribute no default field description.
Options for Selection Fields
For ComboBox, Select, and multi-select components like CheckboxGroup whose option set comes from the application rather than a fixed enum on the field, register the options with ValueOptions:
Source code
Java
controller.valueOptions(
ValueOptions.forField(industry)
.options(List.of("Software", "Manufacturing", "Healthcare")));forField() returns a builder. Use options(Collection) for a fixed label list, or options(BiFunction) for a callback the LLM invokes with a filter string and a result-count limit:
Source code
Java
ComboBox<Project> projectSelect = new ComboBox<>("Project");
controller.valueOptions(
ValueOptions.forField(projectSelect)
.options((filter, limit) ->
projectService.search(filter, limit)),
label -> projectService.findByName(label));The second argument is the label-to-value converter. It is required whenever the field’s value type is not String, and a registration that needs one but doesn’t provide it fails to compile. For String-valued fields the converter is omitted; the chosen label is already the value.
projectService here is a placeholder for your own data source — a Spring repository, a REST client, an in-memory list, or whatever your application already uses to look up projects.
|
Note
|
Eager Items as a Fallback
Single- and multi-select fields configured with setItems(…) already share their items with the LLM, so the simple fixed-options case often needs no valueOptions() call. Use valueOptions() when items come from a lazy or remote source rather than an in-memory list, or when you want the LLM to fetch options through a filter callback instead of receiving the full set up front.
|
Multi-Select Fields
MultiSelectComboBox, CheckboxGroup, and any other field that implements MultiSelect are supported. Use the component’s concrete multi-select type for the field reference so the forField(MultiSelect) overload is selected:
Source code
Java
MultiSelectComboBox<Project> projectsField = new MultiSelectComboBox<>("Projects");
controller.valueOptions(
ValueOptions.forField(projectsField)
.options(List.of("Apollo", "Vega", "Helios")),
label -> projectService.findByName(label));The converter runs once per chosen label, and the resolved values are written to the field as a set.
|
Note
|
Multi-Value Fields Must Implement MultiSelect
A field whose value type is a Collection must implement MultiSelect. The controller rejects two cases at registration time: a MultiSelect field passed through the single-value forField(HasValue) overload, and a Collection-valued field that doesn’t implement MultiSelect.
|
Validation
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
Binderis 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
EmailField, or theminandmaxconstraints onNumberFieldandDatePicker) is validated through that validator. -
Cross-field rules registered with
binder.withValidator((bean, ctx) → …)— see Binder-Level Validators — are evaluated against the post-write form, but only when the binder has a bean set (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 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
The controller sends only a defined subset of the form to the LLM. Knowing where that boundary lies matters when the form holds sensitive or domain-specific data.
The model sees:
-
Each visible field’s label, helper text, component type, and any
describe()text orBinderproperty-name default. -
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
valueOptions()query callback for the filter the model supplies.
The model does not see:
-
Any field excluded with
ignore(). Its value, label, and existence are all hidden. -
Any field the application has hidden via
setVisible(false), or that sits inside a hidden container. -
The contents of
PasswordField, which is always excluded. -
Internal data, services, or beans. The model has access only to what the field components themselves show.
|
Important
|
Visible Field Values Are Sent to the Model
Every visible field’s current value is forwarded to the LLM provider on every turn. Hide fields that carry secrets, identifiers, or personally identifiable information with ignore(), or keep them out of the layout passed to the controller.
|
Field Locking During a Fill Turn
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.
|
Note
|
Read-Only Toggles During a Turn
If application code changes a field’s read-only state during a turn, for example from a 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:
-
addFieldValueChangedListener()registers a listener that fires once per successful turn with the fields whose values changed. -
showHighlight()andhideHighlight()toggle a per-field highlight rendered by thevaadin-field-highlighterweb component.
A typical pattern flashes every changed field after each fill:
Source code
Java
controller.addFieldValueChangedListener(changes ->
changes.forEach(change -> controller.showHighlight(change.field())));The listener receives a list of FieldValueChange records in document order, each carrying the field, its pre-turn value, and its post-turn value. Fields the application has marked with 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 Registration removes the listener when its 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 showHighlight(), update components, or any other Vaadin API directly — no ui.access() wrapper is needed.
Repeated 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 showHighlight() does not need to belong to the controller’s form — any HasValue Component works.
Reconnecting after Deserialization
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 describe(), valueOptions(), and ignore() hints, re-register any change listeners, and pass the controller to reconnect():
Source code
Java
FormAIController controller = new FormAIController(form, binder);
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)
.apply();Field ids remain stable across the round-trip because they live on the field components themselves, which Vaadin serializes as part of the UI tree. No separate state object needs saving or restoring; the form fields are the state, and VaadinSession already persists them.
Composing Multiple Forms
FormAIController manages a single container, but a view can host several. Construct one controller per form section and attach each to its own AIOrchestrator. Each orchestrator also needs a dedicated LLMProvider, MessageInput, and MessageList — per the AI Integration overview, none of those instances may be shared across orchestrators.