diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java index a7af874..0d66762 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/JsonSchemaProducer.java @@ -37,6 +37,7 @@ import com.github.victools.jsonschema.module.jackson.JacksonSchemaModule; import it.cnr.isti.workflow.manager.configurations.annotations.FieldRetriever; import it.cnr.isti.workflow.manager.configurations.annotations.LongText; import it.cnr.isti.workflow.manager.configurations.annotations.Structural; +import it.cnr.isti.workflow.manager.configurations.annotations.UiOptionalGroup; import it.cnr.isti.workflow.manager.configurations.annotations.DynamicSchema; import it.cnr.isti.workflow.manager.configurations.annotations.ConfigurableAsInput; import it.cnr.isti.workflow.manager.configurations.annotations.SchemaAllowedValues; @@ -94,6 +95,7 @@ public class JsonSchemaProducer { Map, Map> dynamicSchemaMap = collectDynamicSchemaMetadata(type); Map, Map> longTextMap = collectLongTextMetadata(type); Map, Map> structuralMap = collectStructuralMetadata(type); + Map, Map> uiOptionalGroupMap = collectUiOptionalGroupMetadata(type); Map, Map> uiEnabledWhenMap = collectUiEnabledWhenMetadata(type); Map, Map> uiOrderMap = collectUiOrderMetadata(type); Map, Map> uiOptionsFromNodeMap = collectUiOptionsFromNodeMetadata(type); @@ -109,6 +111,7 @@ public class JsonSchemaProducer { applyDynamicSchemaMetadata(root, getMergedMetadata(dynamicSchemaMap, type)); applyLongTextMetadata(root, getMergedMetadata(longTextMap, type)); applyStructuralMetadata(root, getMergedMetadata(structuralMap, type)); + applyUiOptionalGroupMetadata(root, getMergedMetadata(uiOptionalGroupMap, type)); applyUiEnabledWhenMetadata(root, getMergedMetadata(uiEnabledWhenMap, type)); applyUiOrderMetadata(root, type, getMergedMetadata(uiOrderMap, type)); applyUiOptionsFromNodeMetadata(root, getMergedMetadata(uiOptionsFromNodeMap, type)); @@ -127,6 +130,7 @@ public class JsonSchemaProducer { metadataClasses.addAll(dynamicSchemaMap.keySet()); metadataClasses.addAll(longTextMap.keySet()); metadataClasses.addAll(structuralMap.keySet()); + metadataClasses.addAll(uiOptionalGroupMap.keySet()); metadataClasses.addAll(uiEnabledWhenMap.keySet()); metadataClasses.addAll(uiOrderMap.keySet()); metadataClasses.addAll(uiOptionsFromNodeMap.keySet()); @@ -151,6 +155,7 @@ public class JsonSchemaProducer { applyDynamicSchemaMetadata(classSchema, getMergedMetadata(dynamicSchemaMap, matchedClass)); applyLongTextMetadata(classSchema, getMergedMetadata(longTextMap, matchedClass)); applyStructuralMetadata(classSchema, getMergedMetadata(structuralMap, matchedClass)); + applyUiOptionalGroupMetadata(classSchema, getMergedMetadata(uiOptionalGroupMap, matchedClass)); applyUiEnabledWhenMetadata(classSchema, getMergedMetadata(uiEnabledWhenMap, matchedClass)); applyUiOrderMetadata(classSchema, matchedClass, getMergedMetadata(uiOrderMap, matchedClass)); applyUiOptionsFromNodeMetadata(classSchema, getMergedMetadata(uiOptionsFromNodeMap, matchedClass)); @@ -775,6 +780,77 @@ public class JsonSchemaProducer { return result; } + private Map, Map> collectUiOptionalGroupMetadata(Class rootClass) { + Map, Map> result = new HashMap<>(); + Set> visited = new HashSet<>(); + Queue> queue = new ArrayDeque<>(); + queue.add(rootClass); + + while (!queue.isEmpty()) { + Class current = queue.poll(); + if (current == null || !visited.add(current) || isTerminalType(current)) { + continue; + } + + Map metadata = new LinkedHashMap<>(); + for (Field field : current.getDeclaredFields()) { + UiOptionalGroup group = field.getAnnotation(UiOptionalGroup.class); + if (group != null) { + metadata.put(field.getName(), group); + } + enqueueRelatedTypes(queue, field.getGenericType(), field.getType()); + } + + if (current.isRecord()) { + for (RecordComponent component : current.getRecordComponents()) { + UiOptionalGroup group = component.getAnnotation(UiOptionalGroup.class); + if (group != null) { + metadata.put(component.getName(), group); + } + enqueueRelatedTypes(queue, component.getGenericType(), component.getType()); + } + } + + if (current.getSuperclass() != null) { + queue.add(current.getSuperclass()); + } + + if (!metadata.isEmpty()) { + result.put(current, metadata); + } + } + + return result; + } + + /** + * Written on the property, never on the shared definition: the label varies by owner, and a + * definition whose JSON differs between schemas silently stops being hoisted into + * sharedDefinitions. The web merges a property's x-ui-* keys over the definition it $refs, so + * the renderer sees them on the object node regardless. + */ + private void applyUiOptionalGroupMetadata(ObjectNode classSchema, Map metadata) { + if (metadata == null || metadata.isEmpty()) { + return; + } + JsonNode propsNode = classSchema.get("properties"); + if (!(propsNode instanceof ObjectNode properties)) { + return; + } + + for (Entry entry : metadata.entrySet()) { + JsonNode propNode = properties.get(entry.getKey()); + if (!(propNode instanceof ObjectNode propertySchema)) { + continue; + } + propertySchema.put("x-ui-optional-group", true); + String label = entry.getValue().label(); + if (label != null && !label.isBlank()) { + propertySchema.put("x-ui-optional-group-label", label); + } + } + } + private void applyStructuralMetadata(ObjectNode classSchema, Map metadata) { if (metadata == null || metadata.isEmpty()) { return; diff --git a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java index b189c58..aeac0c4 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java +++ b/src/main/java/it/cnr/isti/workflow/manager/blocks/configurations/MCPAgentBlockConfiguration.java @@ -15,6 +15,7 @@ import it.cnr.isti.workflow.manager.configurations.annotations.ConfigurableAsInp import it.cnr.isti.workflow.manager.configurations.annotations.UiContextKeys; import it.cnr.isti.workflow.manager.configurations.annotations.SchemaAllowedValues; import it.cnr.isti.workflow.manager.configurations.annotations.UiEnabledWhen; +import it.cnr.isti.workflow.manager.configurations.annotations.UiOptionalGroup; import it.cnr.isti.workflow.manager.configurations.annotations.UiOrder; import it.cnr.isti.workflow.manager.configurations.annotations.UiRequiredWhen; import it.cnr.isti.workflow.manager.blocks.types.MCPAgentBlockType; @@ -46,6 +47,7 @@ public class MCPAgentBlockConfiguration extends BlockConfigurationIt says what the field is, not how to draw it: the editor renders it as one control + * that opens a dialog instead of unfolding its properties inline, while the read-only execution + * view shows only the values that were actually set. Five empty chips for parameters most nodes + * never touch is what this exists to avoid. + * + *

Deliberately a field annotation rather than a type one. Shared definitions are hoisted only + * when their JSON is identical in every schema that contains them, so anything that varies by + * owner - a label, above all - has to live on the property or the definition silently stops being + * shared. + */ +@Target({ElementType.FIELD, ElementType.RECORD_COMPONENT}) +@Retention(RetentionPolicy.RUNTIME) +public @interface UiOptionalGroup { + + /** Name for the control. Falls back to the field's own label when empty. */ + String label() default ""; +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/llms/LLMDescriptor.java b/src/main/java/it/cnr/isti/workflow/manager/llms/LLMDescriptor.java index 083895f..47a8918 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/llms/LLMDescriptor.java +++ b/src/main/java/it/cnr/isti/workflow/manager/llms/LLMDescriptor.java @@ -3,6 +3,7 @@ package it.cnr.isti.workflow.manager.llms; import com.fasterxml.jackson.annotation.JsonProperty; import it.cnr.isti.workflow.manager.configurations.annotations.FieldRetriever; +import it.cnr.isti.workflow.manager.configurations.annotations.UiOptionalGroup; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import lombok.Builder; @@ -23,4 +24,5 @@ public record LLMDescriptor( @NotBlank @JsonProperty(required = true) @FieldRetriever(name = "LLM", url = "/retriever/LLM/models", dependsOn = {"provider"}) String model, - @Valid @JsonProperty(required = false) ModelParameters parameters) {} + @Valid @JsonProperty(required = false) + @UiOptionalGroup(label = "Model parameters") ModelParameters parameters) {} diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java index a0130ae..0ee584d 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/BlocksControllerTest.java @@ -124,6 +124,16 @@ public class BlocksControllerTest { assertEquals(0.0, temperature.path("minimum").asDouble()); assertEquals(2.0, temperature.path("maximum").asDouble()); assertFalse(parameters.path("required").isArray() && !parameters.path("required").isEmpty()); + + // The optional-group marker sits on the *property*, beside its $ref, and never on the + // shared definition. Definitions are hoisted only when their JSON is identical in every + // schema containing them, so a label that varies by owner would silently un-share + // ModelParameters - which the sharedDefinitions assertion above is here to catch. + JsonNode parametersProperty = descriptor.path("properties").path("parameters"); + assertTrue(parametersProperty.path("x-ui-optional-group").asBoolean()); + assertEquals("Model parameters", parametersProperty.path("x-ui-optional-group-label").asText()); + assertEquals("#/sharedDefinitions/ModelParameters", parametersProperty.path("$ref").asText()); + assertFalse(parameters.has("x-ui-optional-group")); } @Test