Class FormAIController

java.lang.Object
com.vaadin.flow.component.ai.form.FormAIController
All Implemented Interfaces:
AIController

public class FormAIController extends Object implements AIController
Populates a layout's fields with values an LLM extracts from a user prompt or attached files. Attach it to an 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 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, not null
    • FormAIController

      public FormAIController(T fieldContainer, Binder<?> binder)
      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 default description when 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, not null
      binder - the binder whose property names default the field descriptions, not null; use the single-argument constructor for the no-binder case
      Throws:
      NullPointerException - if fieldContainer or binder is null
  • Method Details

    • describeField

      public FormAIController describeField(HasValue<?,?> field, String description)
      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, not null
      description - the description text, not null
      Returns:
      this controller, for chaining
    • fieldValueOptions

      public FormAIController fieldValueOptions(ValueOptions<String> config)
      Registers a known set of labels for a String-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-String value type, use the two-argument overload to supply a converter; this is enforced at compile time.

      For MultiSelect fields the controller wraps the chosen labels into a LinkedHashSet before HasValue.setValue(V). Later calls for the same field overwrite earlier ones.

      Parameters:
      config - the field's options registration, not null; must have its label source set via either ValueOptions.options(Collection) or ValueOptions.options(BiFunction)
      Returns:
      this controller, for chaining
      Throws:
      NullPointerException - if config is null
      IllegalArgumentException - if the registration has no label source set; if the developer routed a MultiSelect field through the single-value forField overload (upcast reference); or if the field's value type is a Collection but the field does not implement MultiSelect
    • fieldValueOptions

      public <V> FormAIController fieldValueOptions(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. When the LLM picks a label, the controller calls toValue on it and writes the result to the field. If toValue returns null or 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 a ComboBox<Project>. For MultiSelect fields the converter runs once per chosen label and the controller wraps the resolved elements into a LinkedHashSet before HasValue.setValue(V).

      Later calls for the same field overwrite earlier ones. Use the single-argument overload for String-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, not null; must have its label source set via either ValueOptions.options(Collection) or ValueOptions.options(BiFunction)
      toValue - converts a chosen label to one element of the field's value type, not null
      Returns:
      this controller, for chaining
      Throws:
      NullPointerException - if config or toValue is null
      IllegalArgumentException - if the registration has no label source set; if the developer routed a MultiSelect field through the single-value forField overload (upcast reference); or if the field's value type is a Collection but the field does not implement MultiSelect
    • ignoreField

      public FormAIController ignoreField(HasValue<?,?> field)
      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_form response entirely, so the LLM does not even learn it exists. It can still be exposed through a bean-level cross-field validator: a binder.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, not null
      Returns:
      this controller, for chaining
    • setFieldValuesHidden

      public FormAIController setFieldValuesHidden(boolean valuesHidden)
      Controls whether the current value of every field is sent to the LLM as part of the form state. When true, 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 to false, meaning values are sent.

      Only the value is hidden: a field's description, type, and any option or enum labels 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, use ignoreField(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 - true to hide every field's value, false to 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:
      true when every field's value is hidden, false when 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 by Objects.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 without ui.access(...). A typical use is to flash the AI's edits by calling showFieldHighlight on every changed field.

      Parameters:
      listener - the listener to register, not null
      Returns:
      a Registration that removes the listener when called
      Throws:
      NullPointerException - if listener is null
    • showFieldHighlight

      public void showFieldHighlight(HasValue<?,?> field)
      Paints a highlight on the field via the vaadin-field-highlighter web component. Repeated calls keep exactly one highlight on the field. Call hideFieldHighlight(com.vaadin.flow.component.HasValue<?, ?>) to clear it. The field can be any HasValue Component, 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-highlighter users the application keeps on the field (e.g. from a collaboration session) as long as those consumers also use addUser / removeUser rather than setUsers.

      The first showFieldHighlight call 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 by hideFieldHighlight(com.vaadin.flow.component.HasValue<?, ?>).

      Parameters:
      field - the field to highlight, not null; must be a Component
      Throws:
      NullPointerException - if field is null
      IllegalArgumentException - if field is not a Component
    • hideFieldHighlight

      public void hideFieldHighlight(HasValue<?,?> field)
      Clears any highlight previously applied to the field via showFieldHighlight(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 any HasValue Component, in or out of this controller's form, and clearing one field's highlight has no effect on others. The re-attach listener registered by showFieldHighlight(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, not null; must be a Component
      Throws:
      NullPointerException - if field is null
      IllegalArgumentException - if field is not a Component
    • getTools

      public List<LLMProvider.ToolSpec> getTools()
      Description copied from interface: AIController
      Returns the tools this controller exposes to the LLM.
      Specified by:
      getTools in interface AIController
      Returns:
      list of tools, or empty list if controller provides no tools
    • onRequest

      public void onRequest()
      Description copied from interface: AIController
      Called 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 the RequestListener only 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:
      onRequest in interface AIController
    • onResponse

      public void onResponse(Throwable error)
      Description copied from interface: AIController
      Called 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 error is null; use the call to commit staged state or run deferred UI updates. On failure error carries the cause (stream error, timeout, or any throw between AIController.onRequest() and the start of the stream); release per-turn state captured in onRequest (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:
      onResponse in interface AIController
      Parameters:
      error - the cause of failure, or null on success