Class FormAIController
- All Implemented Interfaces:
AIController
AIOrchestrator via
withController(...).
var controller = new FormAIController(formLayout, binder);
controller
.describeField(discountField,
"Discount as a percentage, not an amount")
.ignoreField(internalReferenceField);
AIOrchestrator orchestrator = AIOrchestrator
.builder(llmProvider, systemPrompt).withController(controller)
.build();
The controller accepts any HasComponents container. It discovers
fields by walking the container's component tree and collecting every
component that implements HasValue. The walk recurses into nested
HasComponents children so layouts containing layouts are handled.
Per-field configuration: use the chained
describeField,
ignoreField, and
fieldValueOptions methods.
fieldValueOptions takes a ValueOptions built via
forField ? the compiler picks the
MultiSelect overload automatically
for fields statically typed as MultiSelect. For fields whose value
type is anything other than String, the
two-argument overload also
accepts a label-to-value converter, which the controller applies per label;
for multi-select fields the resolved elements are then aggregated into a
LinkedHashSet before HasValue.setValue(V).
Hiding field values: setFieldValuesHidden(boolean) keeps the
current value of every field private while still letting the LLM see and fill
the fields ? useful when the form may already hold data the AI should not
read (for example personal data the user typed in). To hide a single field
entirely, so the LLM does not even learn it exists, use
ignoreField(HasValue).
How the LLM understands fields: everything the LLM knows about a field
comes from the field's label, its helper text, and the
describeField(HasValue, String) hint. Make sure every field carries
a meaningful label, or add a describeField(...) hint for fields whose
purpose is not evident from the label alone.
Binder integration: the two-argument constructor accepts a
Binder, which affects the workflow in two ways. First, for every
named binding (bind("propertyName"),
bindInstanceFields(this), or @PropertyId) the property name
is used as a default field description, so the LLM can recognize what the
field means even when it has no label. The default only applies when no
explicit describeField(HasValue, String) has been registered;
calling describeField(...) always wins. Lambda-bound bindings carry
no property name and contribute no default. Second, the binder drives
validation of the values the LLM writes, including bean-level cross-field
rules ? see Validation below.
Validation: each value the LLM writes is validated immediately after
it is applied. A bound field is validated through its binding, so the
converter and every registered validator run as one unit; an unbound field
that exposes a default validator is validated through that validator. A value
that fails validation stays in the field and the failure is reported back to
the LLM as a rejection, so it can supply a corrected value within the same
turn. When the controller was created with a Binder and a bean is set
(setBean), the binder's bean-level validators
(binder.withValidator(...)) also run after the writes; a cross-field
failure (for example "start date must precede end date") is likewise reported
back to the LLM so it can adjust the offending fields within the same turn.
Field locking: while a fill is in progress, every non-ignored 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. Locks are released when the turn ends, successfully or otherwise. Application code that turns a locked field read-only mid-turn (e.g. from a value-change listener reacting to the LLM's writes) is not honoured: the field already reports read-only because of the lock, so the controller cannot tell the application's toggle apart from its own lock. The AI can still write the field, and the lock is released at turn end regardless. Applications should avoid toggling read-only state during a fill turn, or reapply it after the turn completes.
Change tracking and highlight: a listener registered through
addFieldValueChangedListener(SerializableConsumer) fires once per
successful turn with the fields whose value changed during the turn ? the
common driver for showFieldHighlight(HasValue) /
hideFieldHighlight(com.vaadin.flow.component.HasValue<?, ?>) to flash the AI's edits in the UI.
Serialization: the controller is not serialized with the orchestrator.
After deserialization, create a new controller against the same form (and
binder, if any) and call
orchestrator.reconnect(provider).withController(controller).apply().
Re-register the same describeField / fieldValueOptions /
ignoreField hints on the new controller.
- Author:
- Vaadin Ltd
-
Constructor Summary
ConstructorsConstructorDescriptionFormAIController(T fieldContainer) Creates a new form AI controller for the given container.FormAIController(T fieldContainer, Binder<?> binder) Creates a new form AI controller for the given container and binder. -
Method Summary
Modifier and TypeMethodDescriptionRegisters a listener that is invoked once per successful turn with the fields whose value differs from what was read at the start of the turn.describeField(HasValue<?, ?> field, String description) Adds a free-form description that the LLM sees alongside the field when deciding what to fill in.fieldValueOptions(ValueOptions<String> config) Registers a known set of labels for aString-typed field.<V> FormAIControllerfieldValueOptions(ValueOptions<V> config, Function<String, V> toValue) Registers a known set of labels for a field, paired with a converter that resolves a chosen label to the field's value type.getTools()Returns the tools this controller exposes to the LLM.voidhideFieldHighlight(HasValue<?, ?> field) Clears any highlight previously applied to the field viashowFieldHighlight(com.vaadin.flow.component.HasValue<?, ?>).ignoreField(HasValue<?, ?> field) Hides the given field from the LLM.booleanReturns whether field values are hidden in the form state sent to the LLM.voidCalled synchronously on the UI thread just before the LLM stream opens.voidonResponse(Throwable error) Called on the UI thread under the session lock when the LLM stream has completed ? either successfully or with an error.setFieldValuesHidden(boolean valuesHidden) Controls whether the current value of every field is sent to the LLM as part of the form state.voidshowFieldHighlight(HasValue<?, ?> field) Paints a highlight on the field via thevaadin-field-highlighterweb component.
-
Constructor Details
-
FormAIController
public FormAIController(T fieldContainer) Creates a new form AI controller for the given container. Fields are discovered by walking the container's component tree each time the controller is asked for tools, so fields added or removed between turns are picked up automatically.- Type Parameters:
T- the container type- Parameters:
fieldContainer- the container whose fields the LLM may populate, notnull
-
FormAIController
Creates a new form AI controller for the given container and binder. For every named binding on the binder, the bean property name is used as a defaultdescriptionwhen the developer has not registered one explicitly; lambda-bound bindings carry no property name and contribute no default. The binder also drives validation of the values the LLM writes: bound fields are validated through their bindings (converter and validators as one unit), and bean-level cross-field validators run as well when a bean is set. See the class-level documentation for details.- Type Parameters:
T- the container type- Parameters:
fieldContainer- the container whose fields the LLM may populate, notnullbinder- the binder whose property names default the field descriptions, notnull; use the single-argument constructor for the no-binder case- Throws:
NullPointerException- iffieldContainerorbinderisnull
-
-
Method Details
-
describeField
Adds a free-form description that the LLM sees alongside the field when deciding what to fill in. Use it to add business semantics that are not implied by the field's label, helper text, or component type (for example clarifying that a numeric field expects a percentage rather than an absolute amount). Later calls for the same field overwrite earlier ones.- Parameters:
field- the field to describe, notnulldescription- the description text, notnull- Returns:
- this controller, for chaining
-
fieldValueOptions
Registers a known set of labels for aString-typed field. The labels are presented to the LLM as the field's choices. No converter is needed ? the chosen label is itself the value written to the field. For any non-Stringvalue type, usethe two-argument overloadto supply a converter; this is enforced at compile time.For
MultiSelectfields the controller wraps the chosen labels into aLinkedHashSetbeforeHasValue.setValue(V). Later calls for the same field overwrite earlier ones.- Parameters:
config- the field's options registration, notnull; must have its label source set via eitherValueOptions.options(Collection)orValueOptions.options(BiFunction)- Returns:
- this controller, for chaining
- Throws:
NullPointerException- ifconfigisnullIllegalArgumentException- if the registration has no label source set; if the developer routed aMultiSelectfield through the single-valueforFieldoverload (upcast reference); or if the field's value type is a Collection but the field does not implementMultiSelect
-
fieldValueOptions
Registers a known set of labels for a field, paired with a converter that resolves a chosen label to the field's value type. When the LLM picks a label, the controller callstoValueon it and writes the result to the field. IftoValuereturnsnullor throws ? for example because the LLM picked a label the converter does not recognize ? the write is rejected back to the LLM with a reason and the model can correct on the next turn.A typical converter delegates to a service or repository that looks the domain object up by its display name, for example
label -> projectService.findByName(label)for aComboBox<Project>. ForMultiSelectfields the converter runs once per chosen label and the controller wraps the resolved elements into aLinkedHashSetbeforeHasValue.setValue(V).Later calls for the same field overwrite earlier ones. Use
the single-argument overloadforString-typed fields; the converter is implicit there.- Type Parameters:
V- the per-label item type ? the field's value type for single-value fields, the per-element type for multi-select- Parameters:
config- the field's options registration, notnull; must have its label source set via eitherValueOptions.options(Collection)orValueOptions.options(BiFunction)toValue- converts a chosen label to one element of the field's value type, notnull- Returns:
- this controller, for chaining
- Throws:
NullPointerException- ifconfigortoValueisnullIllegalArgumentException- if the registration has no label source set; if the developer routed aMultiSelectfield through the single-valueforFieldoverload (upcast reference); or if the field's value type is a Collection but the field does not implementMultiSelect
-
ignoreField
Hides the given field from the LLM. The field's value is never exposed to the LLM, the LLM cannot write to it, and it is not locked during a fill. Use this for fields the AI must not read or write (internal IDs, PII). Password fields are excluded automatically and do not need to be ignored.The field is kept out of the form state and the
fill_formresponse entirely, so the LLM does not even learn it exists. It can still be exposed through a bean-level cross-field validator: abinder.withValidator((bean, ctx) -> ...)rule reads the whole bean, so a rejection message it builds is sent to the LLM as-is. Such a message must not reveal anything about an ignored field ? neither its value nor its existence.- Parameters:
field- the field to hide, notnull- Returns:
- this controller, for chaining
-
setFieldValuesHidden
Controls whether the current value of every field is sent to the LLM as part of the form state. Whentrue, each field still appears with its description and type so the LLM can fill it, but its value is hidden. Use this when the form may already hold values the AI should not read (for example personal data the user typed in) but should still be able to populate. Defaults tofalse, meaning values are sent.Only the value is hidden: a field's description, type, and any option or
enumlabels are still sent, since the LLM needs them to fill the field. For choice fields whose option labels are themselves sensitive, or to hide a single field's value or content entirely, useignoreField(HasValue).Values can still reach the LLM through validation rejection messages, which are sent as-is. A field stays fillable while its value is hidden, so its own validators run on what the AI writes, and a bean-level cross-field validator (
binder.withValidator((bean, ctx) -> ...)) reads the whole bean and so can name any field's value. A validator message must not embed a field's value.- Parameters:
valuesHidden-trueto hide every field's value,falseto send values as usual- Returns:
- this controller, for chaining
-
isFieldValuesHidden
public boolean isFieldValuesHidden()Returns whether field values are hidden in the form state sent to the LLM.- Returns:
truewhen every field's value is hidden,falsewhen values are sent- See Also:
-
addFieldValueChangedListener
public Registration addFieldValueChangedListener(SerializableConsumer<List<FieldValueChange>> listener) Registers a listener that is invoked once per successful turn with the fields whose value differs from what was read at the start of the turn. Comparison is byObjects.equals(Object, Object)so multi-select sets, dates, and other value-objects work naturally.Multiple listeners are supported and fire in registration order. If one listener throws, the exception is logged and the remaining listeners still fire.
Only non-ignored fields are tracked, and only changed fields appear in the list. A field's pre-turn value is captured regardless of its current visibility, so a value cascaded into a freshly-revealed field is reported with the field's real pre-turn value rather than a spurious
null. The listener is not called when the turn ended in error or when no field changed. The list iterates in document order; modifying it has no effect on the controller.The listener runs on the UI thread with the session lock held, so it can update components and call
showFieldHighlight(com.vaadin.flow.component.HasValue<?, ?>)/hideFieldHighlight(com.vaadin.flow.component.HasValue<?, ?>)directly withoutui.access(...). A typical use is to flash the AI's edits by callingshowFieldHighlighton every changed field.- Parameters:
listener- the listener to register, notnull- Returns:
- a
Registrationthat removes the listener when called - Throws:
NullPointerException- iflistenerisnull
-
showFieldHighlight
Paints a highlight on the field via thevaadin-field-highlighterweb component. Repeated calls keep exactly one highlight on the field. CallhideFieldHighlight(com.vaadin.flow.component.HasValue<?, ?>)to clear it. The field can be anyHasValueComponent, in or out of this controller's form, and each field's highlight state is independent of the others.The AI user added to the field carries an id unique to this controller, so the highlight coexists with any other
vaadin-field-highlighterusers the application keeps on the field (e.g. from a collaboration session) as long as those consumers also useaddUser/removeUserrather thansetUsers.The first
showFieldHighlightcall on a field also registers an attach listener that re-applies the AI user every time the field re-enters the DOM, so the highlight survives detach/re-attach. The listener is removed byhideFieldHighlight(com.vaadin.flow.component.HasValue<?, ?>).- Parameters:
field- the field to highlight, notnull; must be aComponent- Throws:
NullPointerException- iffieldisnullIllegalArgumentException- iffieldis not aComponent
-
hideFieldHighlight
Clears any highlight previously applied to the field viashowFieldHighlight(com.vaadin.flow.component.HasValue<?, ?>). A no-op when no highlight is currently shown. Only this controller's AI user is removed; other users on the field stay highlighted. The field can be anyHasValueComponent, in or out of this controller's form, and clearing one field's highlight has no effect on others. The re-attach listener registered byshowFieldHighlight(com.vaadin.flow.component.HasValue<?, ?>)is also removed, so the highlight does not come back if the field leaves and returns to the DOM after this call.- Parameters:
field- the field to clear the highlight from, notnull; must be aComponent- Throws:
NullPointerException- iffieldisnullIllegalArgumentException- iffieldis not aComponent
-
getTools
Description copied from interface:AIControllerReturns the tools this controller exposes to the LLM.- Specified by:
getToolsin interfaceAIController- Returns:
- list of tools, or empty list if controller provides no tools
-
onRequest
public void onRequest()Description copied from interface:AIControllerCalled synchronously on the UI thread just before the LLM stream opens. By the time this method fires, the user message and an empty assistant placeholder are already in the message list; the turn is committed to the conversation history and theRequestListeneronly after this method returns successfully. Implementations can prepare for the turn ? locking UI surfaces, snapshotting state the tool definitions depend on, and so on.The default does nothing. Throwing from this method aborts the turn before the commit step: the conversation history is unchanged, the request listener is not notified, the LLM stream is not opened, the assistant placeholder is updated to a generic error message,
AIController.onResponse(Throwable)fires with the thrown exception so per-turn state captured before the throw can still be released, and the exception propagates back to the caller of the prompt entry point.- Specified by:
onRequestin interfaceAIController
-
onResponse
Description copied from interface:AIControllerCalled on the UI thread under the session lock when the LLM stream has completed ? either successfully or with an error. Every turn fires this exactly once.On success
errorisnull; use the call to commit staged state or run deferred UI updates. On failureerrorcarries the cause (stream error, timeout, or any throw betweenAIController.onRequest()and the start of the stream); release per-turn state captured inonRequest(locks, pending writes, snapshots) and discard the staged work.The default does nothing. Exceptions thrown from the hook are caught and logged; Errors propagate.
- Specified by:
onResponsein interfaceAIController- Parameters:
error- the cause of failure, ornullon success
-