Enforce schema bounds in the editors, and say what an empty field does

Every bound was already in the schema and already enforced by the server,
but nothing passed it to the control: a temperature of 5 was typeable and
only failed on save. The settings dialog and the inline node editor now
share one validator, so a bound declared once reads the same wherever a
value can be typed. Numeric properties finally get a numeric control.

Arrow increment and required granularity are kept apart: step says what
the value must be a multiple of - 1 on an integer, nothing on a decimal -
while stepIncrement only moves the spinner. Arrows on a 0-to-1 field used
to jump by 1, reaching only the two ends of the range; they now move by a
tenth without making 0.35 wrong. FieldValueConstraints omits stepIncrement
so the increment cannot reach the validator to try.

An empty optional field now states that it is using the default, with a
reset beside the control that stays in place and greys out rather than
appearing once a value is typed. Going back to unset is the one thing a
filled box cannot express: clearing it by hand looks identical to never
having decided. Generic - it follows from the schema not requiring the
field, on all three editing surfaces, container included.

Also fixes the dialog reading as broken: descriptions were rendered twice,
once as a mat-hint and once below in error red, and the wrapping hint
overflowed the fixed-height subscript area onto the button beside it.

An optional group now sits in the fieldset of the object that owns it, so
a node holding two LLM descriptors cannot show two identical "Model
parameters" controls with nothing to tell them apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-07 11:53:40 +02:00
parent 5464ad952f
commit a6a811549a
16 changed files with 1145 additions and 52 deletions

View File

@ -21,6 +21,31 @@ export type NodeSettingField = {
/** Bounds for a `number` field, so the input refuses out-of-range values as you type. */
min?: number;
max?: number;
/**
* Granularity the value must respect, checked by {@link validateFieldValue}: 1 on an integer
* field, absent on a decimal, where the schema declares no granularity and 0.35 is as valid as
* 0.3. Deliberately not what the spinner arrows move by - see {@link stepIncrement}.
*/
step?: number;
/**
* How far one press of a spinner arrow moves the value. A presentation detail, never a
* constraint: `type="number"` with no step arrows by 1, which on a 0-to-1 temperature means the
* arrows can only jump between the two ends of the range.
*/
stepIncrement?: number;
/** Length bounds and format for a text field, checked by {@link validateFieldValue}. */
minLength?: number;
maxLength?: number;
pattern?: string;
/**
* Empty is a meaningful state here: nothing is sent, and whatever default applies takes over.
* Set on every optional field, so an empty box reads as a decision rather than as unfinished
* work - and so there is a way back to it once a value has been typed, which a text box on its
* own cannot express.
*/
defaultsWhenEmpty?: boolean;
/** What that default is, when the schema declares one. */
defaultValue?: string;
/**
* Puts the field in a collapsible section of this name, closed until opened. For settings that
* are optional and rarely touched, so they stop competing with the ones you came here for.
@ -31,6 +56,72 @@ export type NodeSettingField = {
export type NodeSettingsValues = Record<string, string | boolean | number>;
/** What can make a value wrong, independently of where it is being edited. */
export type FieldValueConstraints = Pick<
NodeSettingField,
'type' | 'required' | 'min' | 'max' | 'step' | 'minLength' | 'maxLength' | 'pattern'
>;
/**
* Why a value cannot be saved, or null when it can.
*
* <p>Shared between the settings dialog and the inline node editor so a bound declared once is
* enforced everywhere it can be typed. The native input attributes stop most of it, but nothing
* stops a paste, and a browser that refuses to show the value is not the same as one that refuses
* to save it - the server rejecting a temperature of 5 after the fact is the failure this avoids.
*
* <p>An empty value is only an error when the field is required: everywhere else empty means
* "unset, leave it to the default", which is the whole contract of an optional parameter.
*/
export function validateFieldValue(
constraints: FieldValueConstraints,
value: string | boolean | number | null | undefined
): string | null {
if (constraints.type === 'checkbox' || constraints.type === 'display') return null;
const text = typeof value === 'string' ? value.trim() : value == null ? '' : String(value);
if (text.length === 0) {
return constraints.required === true ? 'Required' : null;
}
if (constraints.type === 'number') {
const numeric = Number(text);
if (!Number.isFinite(numeric)) return 'Must be a number';
if (constraints.min != null && constraints.max != null && (numeric < constraints.min || numeric > constraints.max)) {
return `Must be between ${constraints.min} and ${constraints.max}`;
}
if (constraints.min != null && numeric < constraints.min) return `Must be ${constraints.min} or more`;
if (constraints.max != null && numeric > constraints.max) return `Must be ${constraints.max} or less`;
if (typeof constraints.step === 'number' && constraints.step > 0) {
// Native step semantics: multiples of step counted from the lower bound. A step of 1 is how
// an integer field says so, and 'Must be a whole number' is what that means to read.
const offset = (numeric - (constraints.min ?? 0)) / constraints.step;
if (Math.abs(offset - Math.round(offset)) > 1e-9) {
return constraints.step === 1 ? 'Must be a whole number' : `Must be a multiple of ${constraints.step}`;
}
}
return null;
}
if (constraints.minLength != null && text.length < constraints.minLength) {
return `At least ${constraints.minLength} characters`;
}
if (constraints.maxLength != null && text.length > constraints.maxLength) {
return `At most ${constraints.maxLength} characters`;
}
if (constraints.pattern) {
// A pattern the schema got wrong must not lock the field: an uncompilable regex is our bug,
// and the server still has the real say.
try {
if (!new RegExp(constraints.pattern).test(text)) return 'Invalid format';
} catch {
return null;
}
}
return null;
}
export type NodeSettingsDialogRefresh = {
fields: NodeSettingField[];
initial?: NodeSettingsValues;

View File

@ -36,6 +36,17 @@ export abstract class FieldRetrieverCallServiceBase {
retrieverUrl?: string | null
): Observable<boolean>;
/**
* Whether the retrieved values are an incomplete list, so the field takes a typed value too.
* True for a hosted LLM catalogue, which cannot be enumerated without a credential.
*/
abstract isFieldOpen(
blockType: string,
key: string,
context?: Record<string, string>,
retrieverUrl?: string | null
): Observable<boolean>;
abstract retrieveSchema(
schemaUrl: string,
context?: Record<string, string>

View File

@ -7,11 +7,15 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase
};
private readonly modelsByProvider: Record<string, string[]> = {
OpenAI: ["gpt-4.1-mini", "gpt-4.1"],
Anthropic: ["claude-3-5-sonnet", "claude-3-7-sonnet"],
OllamaTestProvider: ["sam860/gemma3:270m", "llama3.2:3b"]
};
/**
* The hosted providers list nothing, exactly as the real ones do: their catalogues cannot be
* enumerated without a credential, so the editor takes a typed model name instead.
*/
private readonly openModelProviders = new Set(["OpenAI", "Anthropic", "Gemini"]);
private readonly subFlowItems = [
{
descriptor: {
@ -140,6 +144,19 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase
return of(false);
}
override isFieldOpen(
_blockType: string,
key: string,
context?: Record<string, string>,
_retrieverUrl?: string | null
): Observable<boolean> {
if (key !== "models") {
return of(false);
}
const provider = context?.["provider"] ?? "";
return of(this.openModelProviders.has(provider));
}
override retrieveSchema(
schemaUrl: string,
context?: Record<string, string>

View File

@ -4,6 +4,9 @@ import { environment } from "@environment";
import { map, Observable, of } from "rxjs";
import { FieldRetrieverCallServiceBase, RetrieverStructuredItem } from "./field-retriever-call.base";
/** The boolean questions a retriever answers about a field, each on its own sibling endpoint. */
type RetrieverQuestion = 'required' | 'open';
export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
private readonly http = inject(HttpClient);
@ -37,8 +40,19 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
context?: Record<string, string>,
retrieverUrl?: string | null
): Observable<boolean> {
const requiredRetrieverUrl = this.appendRequiredSuffix(retrieverUrl);
const { url, params } = this.resolveRequest(blockType, key, context, requiredRetrieverUrl, true);
const requiredRetrieverUrl = this.appendSuffix(retrieverUrl, 'required');
const { url, params } = this.resolveRequest(blockType, key, context, requiredRetrieverUrl, 'required');
return this.http.get<boolean>(url, { params });
}
override isFieldOpen(
blockType: string,
key: string,
context?: Record<string, string>,
retrieverUrl?: string | null
): Observable<boolean> {
const openRetrieverUrl = this.appendSuffix(retrieverUrl, 'open');
const { url, params } = this.resolveRequest(blockType, key, context, openRetrieverUrl, 'open');
return this.http.get<boolean>(url, { params });
}
@ -70,9 +84,9 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
key: string,
context?: Record<string, string>,
retrieverUrl?: string | null,
isRequired = false
suffix?: RetrieverQuestion
) {
const fallbackUrl = `${environment.apiUrl}/retriever/${encodeURIComponent(blockType)}/${encodeURIComponent(key)}${isRequired ? '/required' : ''}`;
const fallbackUrl = `${environment.apiUrl}/retriever/${encodeURIComponent(blockType)}/${encodeURIComponent(key)}${suffix ? `/${suffix}` : ''}`;
const baseUrl = this.resolveApiUrl(retrieverUrl) ?? fallbackUrl;
const parsed = this.parseUrl(baseUrl);
let params = parsed.params;
@ -109,10 +123,14 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
return { url, params };
}
private appendRequiredSuffix(rawUrl?: string | null): string | null {
/**
* The yes/no endpoints sit beside the values one, so their URL is the configured retriever URL
* with a suffix - no second URL to declare on the field.
*/
private appendSuffix(rawUrl: string | null | undefined, suffix: RetrieverQuestion): string | null {
if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) return null;
const [path, queryString] = rawUrl.split('?', 2);
const normalizedPath = path.endsWith('/required') ? path : `${path}/required`;
const normalizedPath = path.endsWith(`/${suffix}`) ? path : `${path}/${suffix}`;
return queryString ? `${normalizedPath}?${queryString}` : normalizedPath;
}

View File

@ -51,6 +51,20 @@ export class FieldRetriever {
);
}
isFieldOpen(
blockType: string,
key: string,
context?: Record<string, string>,
retrieverUrl?: string | null
) {
return this.fieldRetrieverCallService.isFieldOpen(blockType, key, context, retrieverUrl).pipe(
catchError((err) => {
console.error('Field open check failed', err);
return throwError(() => err);
})
);
}
retrieveSchema(
schemaUrl: string,
context?: Record<string, string>

View File

@ -22,7 +22,11 @@
(click)="toggleGroup(group.name, $event)">
<mat-icon [fontIcon]="isGroupOpen(group.name) ? 'expand_less' : 'expand_more'" class="!h-4 !w-4 !text-base !leading-4"></mat-icon>
<span>{{ group.name }}</span>
@if (!isGroupOpen(group.name) && groupSetCount(group.fields) > 0) {
@if (groupErrorCount(group.fields) > 0) {
<span class="ml-auto rounded-full bg-red-50 px-2 text-[10px] font-bold text-red-700">
{{ groupErrorCount(group.fields) }} to fix
</span>
} @else if (!isGroupOpen(group.name) && groupSetCount(group.fields) > 0) {
<span class="ml-auto rounded-full bg-indigo-50 px-2 text-[10px] font-bold text-indigo-700">
{{ groupSetCount(group.fields) }} set
</span>
@ -44,7 +48,7 @@
<button type="button" mat-stroked-button (click)="cancel($event)">Close</button>
} @else {
<button type="button" mat-stroked-button (click)="cancel($event)">Cancel</button>
<button type="button" mat-flat-button (click)="save($event)">Save</button>
<button type="button" mat-flat-button [disabled]="hasInvalidField()" (click)="save($event)">Save</button>
}
</div>
</div>
@ -54,6 +58,13 @@
<!-- One field control, so the plain list and the collapsible sections cannot drift apart. -->
<ng-template #fieldControl let-field>
<div class="text-sm font-medium text-slate-700">
<!--
The control and its reset on one row: the button keeps its place and greys out rather than
appearing when a value is typed, so the field never changes height and the option is
visible before there is anything to reset.
-->
<div class="flex items-start gap-2">
<div class="min-w-0 grow">
@switch (field.type) {
@case ('display') {
<fieldset class="mt-1 border border-slate-200 rounded-md bg-slate-50 p-3">
@ -75,7 +86,7 @@
</fieldset>
}
@case ('textarea') {
<mat-form-field appearance="outline" class="mt-1">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="mt-1">
<mat-label>{{ field.label }}</mat-label>
<textarea
matInput
@ -90,7 +101,7 @@
</mat-form-field>
}
@case ('select') {
<mat-form-field appearance="outline" class="mt-1">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="mt-1">
<mat-label>{{ field.label }}</mat-label>
<mat-select
[disabled]="field.readonly === true"
@ -116,7 +127,7 @@
</div>
}
@case ('number') {
<mat-form-field appearance="outline" class="mt-1">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="mt-1">
<mat-label>{{ field.label }}</mat-label>
<input
matInput
@ -124,21 +135,20 @@
[placeholder]="field.placeholder ?? ''"
[attr.min]="field.min ?? null"
[attr.max]="field.max ?? null"
[attr.step]="field.stepIncrement ?? field.step ?? null"
[readonly]="field.readonly === true"
[ngModel]="draft[field.key]"
(ngModelChange)="setFieldValue(field.key, $event)" />
@if (field.tip) {
<mat-hint>{{ field.tip }}</mat-hint>
}
</mat-form-field>
}
@default {
<mat-form-field appearance="outline" class="mt-1">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="mt-1">
<mat-label>{{ field.label }}</mat-label>
<input
matInput
[type]="field.type === 'password' && !isPasswordVisible(field.key) ? 'password' : 'text'"
[placeholder]="field.placeholder ?? ''"
[attr.maxlength]="field.maxLength ?? null"
[readonly]="field.readonly === true"
[attr.autofocus]="field.autofocus ? '' : null"
[attr.data-autofocus]="field.autofocus ? 'true' : null"
@ -158,8 +168,39 @@
}
}
@if (field.tip) {
<small class="text-red-600 mt-1 block">{{ field.tip }}</small>
</div>
@if (showUseDefault(field)) {
<button
type="button"
mat-stroked-button
class="mt-2 shrink-0 !min-h-0 !min-w-0 !px-2 !py-0.5 !text-[11px] !leading-4"
[disabled]="!canUseDefault(field)"
[matTooltip]="usesDefault(field) ? 'Already using the default' : 'Clear this field so the default applies'"
(click)="useDefault(field, $event)">
Use default
</button>
}
</div>
<!--
What is under the control, ranked: what is wrong, then what the field is doing, then what
it is for. Outside the switch so every control type reports the same way, select included.
The tip used to be rendered twice - once inside the form field as a mat-hint, once here in
error red - so an ordinary description read as a failure and overlapped the button.
-->
@if (hasFieldMeta(field)) {
<div class="mt-1.5 text-[11px] leading-4">
@if (fieldError(field); as error) {
<span class="font-semibold text-red-700">{{ error }}</span>
} @else if (defaultHint(field); as hint) {
<span class="font-normal text-slate-500">{{ hint }}</span>
}
</div>
}
@if (field.tip) {
<p class="mt-1 text-[11px] font-normal leading-4 text-slate-500">{{ field.tip }}</p>
}
</div>
</ng-template>

View File

@ -1,5 +1,6 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NodeSettingField, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { vi } from 'vitest';
import { NodeSettingField, NodeSettingsDialogService, validateFieldValue } from '@services/dialogs/node-settings-dialog';
import { NodeSettingsDialogHostComponent } from './node-settings-dialog';
const PARAMETER_GROUP = 'Model parameters';
@ -8,7 +9,7 @@ function fields(): NodeSettingField[] {
return [
{ key: 'provider', label: 'Provider', type: 'select', options: [{ label: 'p', value: 'p' }], required: true },
{ key: 'model', label: 'Model', type: 'select', options: [{ label: 'm', value: 'm' }], required: true },
{ key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 2, group: PARAMETER_GROUP },
{ key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 1, group: PARAMETER_GROUP },
{ key: 'seed', label: 'Seed', type: 'number', group: PARAMETER_GROUP }
];
}
@ -81,6 +82,190 @@ describe('NodeSettingsDialogHostComponent collapsible groups', () => {
expect(fixture.nativeElement.textContent).not.toContain('set');
});
it('refuses to save an out-of-range value, and says why', async () => {
// The bound is declared on the field; the server would reject it anyway. Catching it here is
// the difference between a message under the input and a failed save after the dialog is gone.
await open({ provider: 'p', model: 'm', temperature: 5 });
component.toggleGroup(PARAMETER_GROUP);
fixture.detectChanges();
expect(component.hasInvalidField()).toBe(true);
expect(fixture.nativeElement.textContent).toContain('Must be between 0 and 1');
const closed = vi.fn();
dialog.state()!.resolve = closed;
component.save();
expect(closed).not.toHaveBeenCalled();
component.setFieldValue('temperature', 0.7);
fixture.detectChanges();
expect(component.hasInvalidField()).toBe(false);
});
it('marks a closed section that holds the bad value, so Save is not refusing invisibly', async () => {
await open({ provider: 'p', model: 'm', temperature: 5 });
expect(component.isGroupOpen(PARAMETER_GROUP)).toBe(false);
expect(component.groupErrorCount(component.fieldGroups()[0].fields)).toBe(1);
expect(fixture.nativeElement.textContent).toContain('1 to fix');
});
it('never reports a readonly field, which the user could not fix anyway', async () => {
dialog.open({
title: 'Fixed',
fields: [{ key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 1, readonly: true }],
initial: { temperature: 5 }
});
fixture.detectChanges();
await fixture.whenStable();
expect(component.hasInvalidField()).toBe(false);
});
it('says an empty optional field is using the default, rather than looking unfinished', async () => {
dialog.open({
title: 'Parameters',
fields: [
{ key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 1, defaultsWhenEmpty: true },
{ key: 'mode', label: 'Mode', type: 'text', defaultsWhenEmpty: true, defaultValue: 'PATH_END' },
{ key: 'model', label: 'Model', type: 'select', options: [{ label: 'm', value: 'm' }] }
],
initial: {}
});
fixture.detectChanges();
await fixture.whenStable();
expect(component.defaultHint(component.fields[0])).toBe('Using the default');
expect(component.defaultHint(component.fields[1])).toBe('Using the default: PATH_END');
// A required field has no such state, so it says nothing.
expect(component.defaultHint(component.fields[2])).toBeNull();
expect(fixture.nativeElement.textContent).toContain('Using the default: PATH_END');
});
it('keeps the reset beside the field always, greyed out when there is nothing to reset', async () => {
// Clearing the box by hand does the same thing, but nothing tells you that it does - and an
// empty box you cleared looks exactly like one you never decided about. It stays in place
// rather than appearing, so the field never changes height as you type.
dialog.open({
title: 'Parameters',
fields: [{ key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 1, defaultsWhenEmpty: true }],
initial: { temperature: 0.7 }
});
fixture.detectChanges();
await fixture.whenStable();
const button = () => [...(fixture.nativeElement as HTMLElement).querySelectorAll('button')]
.find((element) => element.textContent?.trim() === 'Use default') as HTMLButtonElement | undefined;
expect(component.showUseDefault(component.fields[0])).toBe(true);
expect(component.canUseDefault(component.fields[0])).toBe(true);
expect(button()?.disabled).toBe(false);
expect(component.defaultHint(component.fields[0])).toBeNull();
component.useDefault(component.fields[0]);
fixture.detectChanges();
expect(component.usesDefault(component.fields[0])).toBe(true);
expect(component.canUseDefault(component.fields[0])).toBe(false);
// Still there, just inert.
expect(button()).toBeTruthy();
expect(button()?.disabled).toBe(true);
expect(component.defaultHint(component.fields[0])).toBe('Using the default');
});
it('shows no reset at all on a field that has no default to go back to', async () => {
dialog.open({
title: 'Parameters',
fields: [
{ key: 'model', label: 'Model', type: 'select', options: [{ label: 'm', value: 'm' }] },
{ key: 'fixed', label: 'Fixed', type: 'number', defaultsWhenEmpty: true, readonly: true }
],
initial: {}
});
fixture.detectChanges();
await fixture.whenStable();
// Required, and readonly: neither is the user's to reset.
expect(component.showUseDefault(component.fields[0])).toBe(false);
expect(component.showUseDefault(component.fields[1])).toBe(false);
expect([...(fixture.nativeElement as HTMLElement).querySelectorAll('button')]
.filter((element) => element.textContent?.trim() === 'Use default')).toHaveLength(0);
});
it('keeps a zero as a value, not as a fallback to the default', async () => {
// The reason the whole thing is built on empty rather than on a flag: 0 is a real temperature.
dialog.open({
title: 'Parameters',
fields: [{ key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 1, defaultsWhenEmpty: true }],
initial: { temperature: 0 }
});
fixture.detectChanges();
await fixture.whenStable();
expect(component.usesDefault(component.fields[0])).toBe(false);
expect(component.canUseDefault(component.fields[0])).toBe(true);
});
it('never offers the default on a readonly field', async () => {
dialog.open({
title: 'Parameters',
fields: [{ key: 'temperature', label: 'Temperature', type: 'number', defaultsWhenEmpty: true, readonly: true }],
initial: { temperature: 0.7 }
});
fixture.detectChanges();
await fixture.whenStable();
expect(component.canUseDefault(component.fields[0])).toBe(false);
});
it('shows a description once, and not in error red', async () => {
// It used to render twice: as a mat-hint inside the form field and again below in red, so an
// ordinary description read as a failure and overlapped the button beside it.
const tip = 'Higher values make the output more varied.';
dialog.open({
title: 'Model parameters',
fields: [{ key: 'temperature', label: 'Temperature', type: 'number', min: 0, max: 1, tip, defaultsWhenEmpty: true }],
initial: {}
});
fixture.detectChanges();
await fixture.whenStable();
const host = fixture.nativeElement as HTMLElement;
const occurrences = [...host.querySelectorAll('*')]
.filter((element) => element.children.length === 0 && element.textContent?.trim() === tip);
expect(occurrences).toHaveLength(1);
expect(occurrences[0].className).not.toContain('red');
expect(host.querySelectorAll('mat-hint').length).toBe(0);
});
it('ranks the status above the description, and keeps the button out of both', async () => {
dialog.open({
title: 'Model parameters',
fields: [{
key: 'temperature',
label: 'Temperature',
type: 'number',
min: 0,
max: 1,
tip: 'Higher values make the output more varied.',
defaultsWhenEmpty: true
}],
initial: { temperature: 5 }
});
fixture.detectChanges();
await fixture.whenStable();
const host = fixture.nativeElement as HTMLElement;
const text = host.textContent ?? '';
// The error first, the description after it, and one button that is not inside either.
expect(text.indexOf('Must be between 0 and 1')).toBeLessThan(text.indexOf('Higher values'));
const buttons = [...host.querySelectorAll('button')]
.filter((element) => element.textContent?.trim() === 'Use default');
expect(buttons).toHaveLength(1);
// Beside the control, not in the meta block under it.
expect(buttons[0].closest('.mt-1\\.5')).toBeNull();
});
it('reopens a later dialog with the group closed again', async () => {
await open();
component.toggleGroup(PARAMETER_GROUP);
@ -92,3 +277,65 @@ describe('NodeSettingsDialogHostComponent collapsible groups', () => {
expect(component.isGroupOpen(PARAMETER_GROUP)).toBe(false);
});
});
describe('validateFieldValue', () => {
it('accepts an empty optional value, because empty means "leave it to the default"', () => {
expect(validateFieldValue({ type: 'number', min: 0, max: 1 }, '')).toBeNull();
expect(validateFieldValue({ type: 'text' }, ' ')).toBeNull();
expect(validateFieldValue({ type: 'text' }, null)).toBeNull();
});
it('reports an empty value only when the caller declared the field required', () => {
expect(validateFieldValue({ type: 'select', required: true }, '')).toBe('Required');
});
it('keeps a bounded number inside its bounds', () => {
// The bound the server enforces anyway. Learning about it on save, after the dialog is gone,
// is what this is for.
const temperature = { type: 'number' as const, min: 0, max: 1 };
expect(validateFieldValue(temperature, '0')).toBeNull();
expect(validateFieldValue(temperature, '0.7')).toBeNull();
expect(validateFieldValue(temperature, '1')).toBeNull();
expect(validateFieldValue(temperature, '1.5')).toBe('Must be between 0 and 1');
expect(validateFieldValue(temperature, '-0.1')).toBe('Must be between 0 and 1');
});
it('names the single bound when only one is declared', () => {
expect(validateFieldValue({ type: 'number', min: 1 }, '0')).toBe('Must be 1 or more');
expect(validateFieldValue({ type: 'number', max: 10 }, '11')).toBe('Must be 10 or less');
});
it('rejects something that is not a number at all', () => {
expect(validateFieldValue({ type: 'number' }, 'warm')).toBe('Must be a number');
});
it('rejects a decimal where the step says whole numbers', () => {
// How an integer field says so. Truncating 2.5 to 2 in silence is the alternative.
expect(validateFieldValue({ type: 'number', step: 1 }, '2.5')).toBe('Must be a whole number');
expect(validateFieldValue({ type: 'number', step: 1 }, '2')).toBeNull();
});
it('leaves a decimal alone when no granularity is declared', () => {
// The arrows move by 0.1 for convenience, which must never make 0.35 wrong. FieldValueConstraints
// does not even include stepIncrement, so the increment cannot reach this function to try.
expect(validateFieldValue({ type: 'number', min: 0, max: 1 }, '0.35')).toBeNull();
});
it('checks length and format on text', () => {
expect(validateFieldValue({ type: 'text', minLength: 3 }, 'ab')).toBe('At least 3 characters');
expect(validateFieldValue({ type: 'text', maxLength: 3 }, 'abcd')).toBe('At most 3 characters');
expect(validateFieldValue({ type: 'text', pattern: '^[a-z]+$' }, 'Abc')).toBe('Invalid format');
expect(validateFieldValue({ type: 'text', pattern: '^[a-z]+$' }, 'abc')).toBeNull();
});
it('does not lock a field behind a pattern we got wrong', () => {
// An uncompilable regex is our bug, and the server still has the real say.
expect(validateFieldValue({ type: 'text', pattern: '([unclosed' }, 'anything')).toBeNull();
});
it('has nothing to say about a checkbox or a read-only display', () => {
expect(validateFieldValue({ type: 'checkbox', required: true }, false)).toBeNull();
expect(validateFieldValue({ type: 'display', required: true }, '')).toBeNull();
});
});

View File

@ -11,7 +11,8 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import {
NodeSettingField,
NodeSettingsDialogService,
NodeSettingsValues
NodeSettingsValues,
validateFieldValue
} from '@services/dialogs/node-settings-dialog';
@Component({
@ -64,9 +65,70 @@ export class NodeSettingsDialogHostComponent {
save(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.hasInvalidField()) return;
this.dialog.close({ ...this.draft });
}
/**
* Why this field cannot be saved, or null. A readonly field is never reported: its value is not
* the user's to fix, and a dialog that cannot be saved or corrected is a dead end.
*/
fieldError(field: NodeSettingField): string | null {
if (field.readonly === true) return null;
return validateFieldValue(field, this.draft[field.key]);
}
hasInvalidField(): boolean {
return this.fields.some((field) => this.fieldError(field) !== null);
}
/** Whether anything needs saying under the control, so the row is never an empty gap. */
hasFieldMeta(field: NodeSettingField): boolean {
return this.fieldError(field) !== null || this.defaultHint(field) !== null;
}
/**
* Whether the field has a default to go back to at all. Kept apart from {@link canUseDefault},
* which says whether pressing it would do anything: the control stays in place and greys out
* instead of appearing and disappearing, so the field's height never changes as you type and
* the option is discoverable before you have typed anything.
*/
showUseDefault(field: NodeSettingField): boolean {
return field.defaultsWhenEmpty === true && field.readonly !== true;
}
/** Whether an optional field is currently unset, so its default applies. */
usesDefault(field: NodeSettingField): boolean {
if (field.defaultsWhenEmpty !== true) return false;
const value = this.draft[field.key];
return value === undefined || value === null || value === '';
}
/** What an empty optional field is doing, so it does not read as unfinished. */
defaultHint(field: NodeSettingField): string | null {
if (!this.usesDefault(field)) return null;
return field.defaultValue ? `Using the default: ${field.defaultValue}` : 'Using the default';
}
/**
* Offered only on a field that holds a value. Going back to unset is the one thing a filled box
* cannot express on its own: clearing it by hand looks the same as never having decided.
*/
canUseDefault(field: NodeSettingField): boolean {
return this.showUseDefault(field) && !this.usesDefault(field);
}
useDefault(field: NodeSettingField, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.setFieldValue(field.key, '');
}
/** So a bad value inside a closed section is not invisible while Save refuses to work. */
groupErrorCount(fields: NodeSettingField[]): number {
return fields.filter((field) => this.fieldError(field) !== null).length;
}
isPreviewOnly(): boolean {
return this.state()?.previewOnly === true;
}

View File

@ -1,4 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { extractSchemaRequirements } from '../schema-requirements';
import { DEFAULT_NODE_CAPABILITIES } from '@models/flow';
import { of } from 'rxjs';
import { vi } from 'vitest';
@ -146,6 +147,46 @@ describe('ContainerNodeComponent', () => {
]);
});
it('gives a container number field a numeric control and the schema bounds', async () => {
// The container has its own field dialog, built by hand. It has to say the same things the
// block's does, because it is the same schema saying them.
const schema = {
type: 'object',
required: ['label'],
properties: {
label: { type: 'string' },
retries: { type: 'integer', minimum: 1, maximum: 5 },
threshold: { type: 'number', minimum: 0, maximum: 1 },
mode: { type: 'string', default: 'STRICT' }
}
};
(component as any).containerSchema = schema;
(component as any).containerFieldDefinitions = (component as any).buildContainerFieldDefinitions(schema);
// Populated by the schema load in the running app; the required list is what tells an optional
// field from one that cannot be left empty.
(component as any).schemaRequirements = extractSchemaRequirements(schema);
const openSpy = vi.spyOn(settingsDialog, 'open').mockResolvedValue(null);
await component.openParameterEditor('retries');
expect(openSpy.mock.calls.at(-1)?.[0].fields[0]).toMatchObject({
type: 'number', min: 1, max: 5, step: 1, stepIncrement: 1, defaultsWhenEmpty: true
});
await component.openParameterEditor('threshold');
expect(openSpy.mock.calls.at(-1)?.[0].fields[0]).toMatchObject({
type: 'number', min: 0, max: 1, step: undefined, stepIncrement: 0.1, defaultsWhenEmpty: true
});
await component.openParameterEditor('mode');
expect(openSpy.mock.calls.at(-1)?.[0].fields[0]).toMatchObject({
type: 'text', defaultsWhenEmpty: true, defaultValue: 'STRICT'
});
// Required, so empty is not a state it can be left in.
await component.openParameterEditor('label');
expect(openSpy.mock.calls.at(-1)?.[0].fields[0].defaultsWhenEmpty).toBe(false);
});
describe('an optional group', () => {
// The same annotation, honoured the same way as in a block: one control that opens the whole
// object, and a write-back that can return the group to "nothing set".

View File

@ -542,14 +542,27 @@ export class ContainerNodeComponent implements OnDestroy {
if (!definition || !this.isFieldEnabled(definition.path)) return;
const initialValue = this.getEditorInitialValue(definition);
const dialogFieldType = this.toDialogFieldType(definition);
const required = this.isPathRequired(definition.path);
const field: NodeSettingField = {
key: definition.path,
label: definition.label,
type: this.toDialogFieldType(definition),
type: dialogFieldType,
required: this.missingRequiredParams.includes(definition.label),
placeholder: definition.ui.placeholder,
tip: definition.ui.tip,
rows: definition.ui.widget === 'textarea' ? definition.ui.rows ?? 6 : undefined,
// The bounds the schema declares, and what an empty box means - the same on every editing
// surface, because it is the same schema saying it.
min: dialogFieldType === 'number' ? definition.ui.minimum : undefined,
max: dialogFieldType === 'number' ? definition.ui.maximum : undefined,
step: definition.type === 'integer' ? 1 : undefined,
stepIncrement: dialogFieldType === 'number' ? (definition.type === 'integer' ? 1 : 0.1) : undefined,
minLength: dialogFieldType === 'number' ? undefined : definition.ui.minLength,
maxLength: dialogFieldType === 'number' ? undefined : definition.ui.maxLength,
pattern: dialogFieldType === 'number' ? undefined : definition.ui.pattern,
defaultsWhenEmpty: !required && dialogFieldType !== 'checkbox',
defaultValue: this.schemaDeclaredDefault(definition.path) ?? undefined,
options: await this.resolveSelectableOptions(definition)
};
@ -923,6 +936,26 @@ export class ContainerNodeComponent implements OnDestroy {
.map((field) => field.path);
}
/**
* Whether the schema insists on this path, evaluated the same way refreshValidationState does -
* a conditional requirement counts only while its condition holds.
*/
private isPathRequired(path: string): boolean {
if (this.schemaRequirements.required.some((field) => field.path === path)) return true;
if (this.schemaRequirements.requiredObjects.some((field) => field.path === path)) return true;
const config = this.configuration ?? {};
return this.schemaRequirements.conditional.some((field) =>
field.path === path
&& field.requiredWhen != null
&& evaluateUiConditionRule(field.requiredWhen, config, (candidate) => this.resolveFieldSchema(candidate)));
}
private schemaDeclaredDefault(path: string): string | null {
const schema = resolveSchemaPath(this.containerSchema, path);
const declared = schema?.['default'];
return declared == null ? null : String(declared);
}
private isContainerTypeField(path: string): boolean {
return [
'type',
@ -953,6 +986,8 @@ export class ContainerNodeComponent implements OnDestroy {
if (definition.type === 'boolean') return 'checkbox';
if (definition.ui.widget === 'textarea') return 'textarea';
if (definition.enumOptions.length || definition.nodeOptionsSource || definition.retrieverKey) return 'select';
// A bounded number after the select check: an enum of numbers is still a list to pick from.
if (definition.type === 'number' || definition.type === 'integer') return 'number';
return 'text';
}

View File

@ -596,6 +596,7 @@
}
<div class="llm-modal-field">
<label>{{ localEditorLabel }}</label>
<div class="llm-modal-control-row">
@if (localEditorUseInput && localEditorPath !== 'name') {
<input
type="text"
@ -624,7 +625,7 @@
[attr.maxlength]="localEditorMaxLength"
[placeholder]="localEditorMaxLength ? 'Max ' + localEditorMaxLength + ' characters' : 'Value...'"
(pointerdown)="$event.stopPropagation()"></textarea>
} @else if (localEditorHasRetriever) {
} @else if (localEditorHasRetriever && !localEditorFreeText) {
<select [(ngModel)]="localEditorValue" (pointerdown)="$event.stopPropagation()">
<option value="">{{ localEditorOptions.length ? ('Select ' + (localEditorLabel | lowercase) + '...') : 'No options available' }}</option>
@for (option of localEditorOptions; track option.value) {
@ -638,9 +639,27 @@
[attr.maxlength]="localEditorMaxLength"
[attr.min]="localEditorMin"
[attr.max]="localEditorMax"
[attr.step]="localEditorType === 'integer' ? 1 : (localEditorType === 'number' ? 0.1 : null)"
[placeholder]="editorPlaceholder()"
(pointerdown)="$event.stopPropagation()" />
}
@if (showLocalEditorDefault()) {
<button
type="button"
class="llm-btn llm-btn-ghost llm-modal-default-btn"
[disabled]="!canUseLocalEditorDefault()"
[attr.title]="localEditorUsesDefault() ? 'Already using the default' : 'Clear this field so the default applies'"
(pointerdown)="$event.stopPropagation()"
(click)="useLocalEditorDefault($event)">
Use default
</button>
}
</div>
@if (localEditorError(); as error) {
<small class="llm-modal-error">{{ error }}</small>
} @else if (localEditorDefaultHint(); as hint) {
<small class="llm-modal-hint">{{ hint }}</small>
}
</div>
<div class="llm-modal-actions">
<button type="button" class="llm-btn llm-btn-ghost" (pointerdown)="$event.stopPropagation()" (click)="closeSimpleParamEditor($event)">Cancel</button>

View File

@ -5,7 +5,7 @@ import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialo
import { FieldRetriever } from '@services/retriever/field-retriever';
import { EditorStateHolder } from '@stores/flow-editor';
import { vi } from 'vitest';
import { of } from 'rxjs';
import { of, throwError } from 'rxjs';
import { GenericNodeComponent } from './generic-node';
@ -40,7 +40,9 @@ describe('GenericNodeComponent', () => {
useValue: {
retrieveSchema: vi.fn(),
retrieveStructuredData: vi.fn(),
retrieveText: vi.fn()
retrieveText: vi.fn(),
retrieveValues: vi.fn(() => of([])),
isFieldOpen: vi.fn(() => of(false))
}
},
{
@ -116,6 +118,77 @@ describe('GenericNodeComponent', () => {
expect(config['llmDescriptor'].parameters.temperature).toBe(0);
});
it('refuses to save a value outside the schema bounds', () => {
// Same check the settings dialog runs, on the other place the value can be typed.
const component = fixture.componentInstance as any;
openNumericEditor('llmDescriptor.parameters.temperature', undefined);
component.localEditorMin = 0;
component.localEditorMax = 1;
component.localEditorValue = '5';
expect(component.localEditorError()).toBe('Must be between 0 and 1');
expect(component.canSaveLocalEditor()).toBe(false);
component.localEditorValue = '0.7';
expect(component.localEditorError()).toBeNull();
expect(component.canSaveLocalEditor()).toBe(true);
});
it('still lets an emptied optional field be saved, which is how it is unset', () => {
const component = fixture.componentInstance as any;
openNumericEditor('llmDescriptor.parameters.temperature', undefined);
component.localEditorMin = 0;
component.localEditorMax = 1;
component.localEditorValue = '';
expect(component.localEditorError()).toBeNull();
expect(component.canSaveLocalEditor()).toBe(true);
});
it('says an empty optional field is using the default, and offers the way back once set', () => {
const component = fixture.componentInstance as any;
openNumericEditor('llmDescriptor.parameters.temperature', undefined);
component.localEditorDefaultsWhenEmpty = true;
component.localEditorValue = '';
expect(component.localEditorUsesDefault()).toBe(true);
expect(component.localEditorDefaultHint()).toBe('Using the default');
// Present but inert, the same as in the settings dialog: it keeps its place beside the field.
expect(component.showLocalEditorDefault()).toBe(true);
expect(component.canUseLocalEditorDefault()).toBe(false);
component.localEditorValue = '0.7';
expect(component.localEditorDefaultHint()).toBeNull();
expect(component.canUseLocalEditorDefault()).toBe(true);
component.useLocalEditorDefault();
expect(component.localEditorUsesDefault()).toBe(true);
expect(component.showLocalEditorDefault()).toBe(true);
});
it('offers no reset while the value comes from a workflow input', () => {
// The value is not the user's to set here, so there is nothing to hand back to a default.
const component = fixture.componentInstance as any;
openNumericEditor('llmDescriptor.parameters.temperature', undefined);
component.localEditorDefaultsWhenEmpty = true;
component.localEditorUseInput = true;
expect(component.showLocalEditorDefault()).toBe(false);
expect(component.canUseLocalEditorDefault()).toBe(false);
});
it('treats a zero as a value there too, so the way back to the default stays offered', () => {
const component = fixture.componentInstance as any;
openNumericEditor('llmDescriptor.parameters.temperature', undefined);
component.localEditorDefaultsWhenEmpty = true;
component.localEditorValue = '0';
expect(component.localEditorUsesDefault()).toBe(false);
expect(component.canUseLocalEditorDefault()).toBe(true);
});
it('saves an ordinary value unchanged', () => {
const config = openNumericEditor('llmDescriptor.parameters.temperature', undefined);
config['llmDescriptor'] = { provider: 'p', model: 'm', parameters: {} };
@ -165,11 +238,44 @@ describe('GenericNodeComponent', () => {
const dialog = open.mock.calls.at(-1)?.[0];
expect(dialog.fields.map((field: any) => [field.key, field.type])).toEqual([
['name', 'text'], ['weight', 'text'], ['enabled', 'checkbox']
['name', 'text'], ['weight', 'number'], ['enabled', 'checkbox']
]);
expect(dialog.fields[0].label).toBe('Skill name');
});
it('gives an integer a whole-number step, so the spinner and the check agree', async () => {
const component = withArrayField();
const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType<typeof vi.fn>;
open.mockResolvedValue(null);
await component.addArrayItem('skills');
const weight = open.mock.calls.at(-1)?.[0].fields.find((field: any) => field.key === 'weight');
expect(weight.step).toBe(1);
expect(weight.stepIncrement).toBe(1);
});
it('moves a decimal by a tenth per arrow press, without making a tenth the rule', async () => {
// An arrow that jumps by 1 on a 0-to-1 field can only reach the two ends of the range. The
// increment is a convenience, so 0.35 has to stay as valid as 0.3.
const component = fixture.componentInstance as any;
component.arrayFieldDefinitions = [{
path: 'skills',
label: 'Skills',
itemSchema: { type: 'object', properties: { ratio: { type: 'number', minimum: 0, maximum: 1 } } },
uniqueBy: null,
ui: { structural: false, visibleWhen: [], enabledWhen: [] }
}];
const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType<typeof vi.fn>;
open.mockResolvedValue(null);
await component.addArrayItem('skills');
const ratio = open.mock.calls.at(-1)?.[0].fields[0];
expect(ratio.stepIncrement).toBe(0.1);
expect(ratio.step).toBeUndefined();
});
it('writes the parsed item into the array', async () => {
const component = withArrayField();
const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType<typeof vi.fn>;
@ -267,6 +373,84 @@ describe('GenericNodeComponent', () => {
});
});
describe('a retriever whose values are an incomplete list', () => {
/** What the editor holds for a retriever-backed scalar, as openParameterEditor builds it. */
function modelFieldDefinition() {
return {
path: 'llmDescriptor.model',
label: 'Model',
retrieverBlockType: 'LLM',
retrieverKey: 'models',
retrieverUrl: '/retriever/LLM/models',
retrieverStructuredData: false,
retrieverDependsOn: []
};
}
function stubRetriever(values: string[], open: boolean) {
const retriever = TestBed.inject(FieldRetriever) as any;
retriever.retrieveValues = vi.fn(() => of(values));
retriever.isFieldOpen = vi.fn(() => of(open));
return retriever;
}
it('takes a typed value when the provider cannot list its models', async () => {
// Gemini and the other hosted providers cannot be enumerated without a credential, so a
// select would be a dead end: an empty list with nowhere to type the model that does exist.
stubRetriever([], true);
await (component as any).loadLocalEditorOptions(modelFieldDefinition());
expect(component.localEditorFreeText).toBe(true);
expect(component.localEditorOptions).toEqual([]);
});
it('keeps the select when the provider lists its models', async () => {
stubRetriever(['llama3.2:3b'], false);
await (component as any).loadLocalEditorOptions(modelFieldDefinition());
expect(component.localEditorFreeText).toBe(false);
expect(component.localEditorOptions).toEqual([{ label: 'llama3.2:3b', value: 'llama3.2:3b' }]);
});
it('stays a select when the values call fails, so a closed list is never opened by an error', () => {
// An unreachable Ollama returns nothing too. Turning that into a free text box would hide a
// broken provider and invite a model name it does not have.
const retriever = TestBed.inject(FieldRetriever) as any;
retriever.retrieveValues = vi.fn(() => throwError(() => new Error('unreachable')));
retriever.isFieldOpen = vi.fn(() => of(false));
return (component as any).loadLocalEditorOptions(modelFieldDefinition()).then(() => {
expect(component.localEditorFreeText).toBe(false);
expect(component.localEditorOptions).toEqual([]);
expect(component.localEditorLoading).toBe(false);
});
});
it('still offers a text field when only the values call fails on an open retriever', async () => {
// The two answers are independent on purpose: the one that says "type it" must survive the
// one that had nothing to list.
const retriever = TestBed.inject(FieldRetriever) as any;
retriever.retrieveValues = vi.fn(() => throwError(() => new Error('no catalogue')));
retriever.isFieldOpen = vi.fn(() => of(true));
await (component as any).loadLocalEditorOptions(modelFieldDefinition());
expect(component.localEditorFreeText).toBe(true);
});
it('falls back to a select when the open check itself fails', async () => {
stubRetriever(['llama3.2:3b'], false);
const retriever = TestBed.inject(FieldRetriever) as any;
retriever.isFieldOpen = vi.fn(() => throwError(() => new Error('down')));
await (component as any).loadLocalEditorOptions(modelFieldDefinition());
expect(component.localEditorFreeText).toBe(false);
});
});
it('should create', () => {
expect(component).toBeTruthy();
});
@ -502,8 +686,8 @@ describe('GenericNodeComponent', () => {
const groupSchema = {
type: 'object',
properties: {
temperature: { type: 'number', 'x-ui-label': 'Temperature' },
topK: { type: 'integer', 'x-ui-label': 'Top K' }
temperature: { type: 'number', 'x-ui-label': 'Temperature', minimum: 0, maximum: 1 },
topK: { type: 'integer', 'x-ui-label': 'Top K', minimum: 1 }
}
};
@ -533,6 +717,59 @@ describe('GenericNodeComponent', () => {
expect(dialog.initial).toEqual({ temperature: '0.7', topK: '' });
});
it('carries the schema bounds onto the dialog fields, so the modal can refuse a bad value', async () => {
// The bounds were already in the schema and already enforced by the server; nothing was
// passing them to the control, so a temperature of 5 was typeable and only failed on save.
const component = withOptionalGroup();
const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType<typeof vi.fn>;
open.mockResolvedValue(null);
await component.openOptionalGroupEditor('llmDescriptor.parameters');
const fields = open.mock.calls.at(-1)?.[0].fields;
expect(fields).toEqual([
expect.objectContaining({ key: 'temperature', type: 'number', min: 0, max: 1, stepIncrement: 0.1 }),
expect.objectContaining({ key: 'topK', type: 'number', min: 1, step: 1, stepIncrement: 1 })
]);
});
it('marks every optional property as defaulting when empty, whatever its type', async () => {
// Not a parameter-specific rule: any field the schema does not require means "leave it to
// the default" when empty, and the control has to be able to say so.
const component = fixture.componentInstance as any;
component.optionalGroupFieldDefinitions = [{
path: 'llmDescriptor.parameters',
label: 'Model parameters',
objectSchema: {
type: 'object',
required: ['mode'],
properties: {
mode: { type: 'string' },
note: { type: 'string' },
temperature: { type: 'number' },
verbose: { type: 'boolean' },
outcome: { type: 'string', default: 'DONE' }
}
},
ui: { structural: false, visibleWhen: [], enabledWhen: [] }
}];
component.ensureBlockConfiguration()['llmDescriptor'] = { provider: 'p', model: 'm' };
const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType<typeof vi.fn>;
open.mockResolvedValue(null);
await component.openOptionalGroupEditor('llmDescriptor.parameters');
const byKey = new Map<string, any>(
open.mock.calls.at(-1)?.[0].fields.map((field: any) => [field.key, field])
);
expect(byKey.get('mode').defaultsWhenEmpty).toBe(false);
expect(byKey.get('note').defaultsWhenEmpty).toBe(true);
expect(byKey.get('temperature').defaultsWhenEmpty).toBe(true);
// A checkbox has no empty state: false is a value.
expect(byKey.get('verbose').defaultsWhenEmpty).toBe(false);
expect(byKey.get('outcome')).toMatchObject({ defaultsWhenEmpty: true, defaultValue: 'DONE' });
});
it('writes only what was filled in', async () => {
const component = withOptionalGroup();
const open = TestBed.inject(NodeSettingsDialogService).open as ReturnType<typeof vi.fn>;

View File

@ -12,7 +12,8 @@ import {
NodeSettingsValues,
NodeSettingsDialogInput,
NodeSettingOption,
NodeSettingsDialogService
NodeSettingsDialogService,
validateFieldValue
} from '@services/dialogs/node-settings-dialog';
import { EditorStateHolder } from '@stores/flow-editor';
import { FieldRetriever } from '@services/retriever/field-retriever';
@ -174,12 +175,19 @@ export class GenericNodeComponent implements OnDestroy {
localEditorHasRetriever = false;
localEditorType: FieldType = 'string';
localEditorMaxLength: number | null = null;
localEditorMinLength: number | null = null;
localEditorPattern: string | null = null;
/** Empty means unset on this field, and the modal says so. Every optional field, not just numbers. */
localEditorDefaultsWhenEmpty = false;
localEditorDefaultValue: string | null = null;
localEditorMin: number | null = null;
localEditorMax: number | null = null;
localEditorWidget: 'textarea' | null = null;
localEditorRows: number | null = null;
localEditorBindableAsInput = false;
localEditorUseInput = false;
/** A retriever whose values are an incomplete list: the field takes a typed value instead. */
localEditorFreeText = false;
localEditorBindableInputName: string | null = null;
deleteConfirmOpen = false;
focusOpen = false;
@ -357,6 +365,10 @@ export class GenericNodeComponent implements OnDestroy {
this.localEditorLabel = 'Name';
this.localEditorType = 'string';
this.localEditorMaxLength = 20;
this.localEditorMinLength = null;
this.localEditorPattern = null;
this.localEditorDefaultsWhenEmpty = false;
this.localEditorDefaultValue = null;
this.localEditorMin = null;
this.localEditorMax = null;
this.localEditorValue = this.name ?? '';
@ -368,6 +380,7 @@ export class GenericNodeComponent implements OnDestroy {
this.localEditorRows = null;
this.localEditorBindableAsInput = false;
this.localEditorUseInput = false;
this.localEditorFreeText = false;
this.localEditorBindableInputName = null;
}
@ -383,7 +396,12 @@ export class GenericNodeComponent implements OnDestroy {
this.localEditorPath = definition.path;
this.localEditorLabel = definition.label;
this.localEditorType = definition.type;
this.localEditorMaxLength = null;
this.localEditorMaxLength = definition.ui.maxLength ?? null;
this.localEditorMinLength = definition.ui.minLength ?? null;
this.localEditorPattern = definition.ui.pattern ?? null;
// A boolean has no empty state: false is a value, not an absence.
this.localEditorDefaultsWhenEmpty = definition.type !== 'boolean' && !this.isPathRequired(definition.path);
this.localEditorDefaultValue = this.schemaDeclaredDefault(definition.path);
this.localEditorMin = definition.ui.minimum ?? null;
this.localEditorMax = definition.ui.maximum ?? null;
this.localEditorWidget = definition.ui.widget;
@ -432,10 +450,15 @@ export class GenericNodeComponent implements OnDestroy {
this.localEditorHasRetriever = false;
this.localEditorType = 'string';
this.localEditorMaxLength = null;
this.localEditorMinLength = null;
this.localEditorPattern = null;
this.localEditorDefaultsWhenEmpty = false;
this.localEditorDefaultValue = null;
this.localEditorWidget = null;
this.localEditorRows = null;
this.localEditorBindableAsInput = false;
this.localEditorUseInput = false;
this.localEditorFreeText = false;
this.localEditorBindableInputName = null;
}
@ -896,6 +919,20 @@ export class GenericNodeComponent implements OnDestroy {
});
}
/** Whether the schema insists on this path, conditional requirements included. */
private isPathRequired(path: string): boolean {
if (this.schemaRequirements.required.some((field) => field.path === path)) return true;
if (this.schemaRequirements.requiredObjects.some((field) => field.path === path)) return true;
return this.schemaRequirements.conditional.some((field) =>
field.path === path && this.conditionalRequiredByPath.get(path) === true);
}
private schemaDeclaredDefault(path: string): string | null {
const schema = this.resolveFieldSchema(path);
const declared = schema?.['default'];
return declared == null ? null : String(declared);
}
private isStructuralField(path: string): boolean {
return getSchemaPathUiMeta(this.blockSchema, path).structural;
}
@ -1000,8 +1037,63 @@ export class GenericNodeComponent implements OnDestroy {
return valueToDisplayString(value);
}
/**
* Why the typed value cannot be saved, or null. Same function as the settings dialog uses, so a
* bound declared once in the schema reads the same wherever the value can be typed.
*/
localEditorError(): string | null {
if (this.localEditorLoading || this.localEditorUseInput) return null;
return validateFieldValue(
{
type: this.localEditorType === 'number' || this.localEditorType === 'integer' ? 'number' : 'text',
// Requiredness is deliberately not checked here: an empty value already means "unset" for
// an optional parameter, and a missing required one is reported by the node's own warning.
min: this.localEditorMin ?? undefined,
max: this.localEditorMax ?? undefined,
step: this.localEditorType === 'integer' ? 1 : undefined,
minLength: this.localEditorMinLength ?? undefined,
maxLength: this.localEditorMaxLength ?? undefined,
pattern: this.localEditorPattern ?? undefined
},
this.localEditorValue
);
}
localEditorUsesDefault(): boolean {
return this.localEditorDefaultsWhenEmpty
&& !this.localEditorUseInput
&& this.localEditorValue.trim().length === 0;
}
localEditorDefaultHint(): string | null {
if (!this.localEditorUsesDefault()) return null;
return this.localEditorDefaultValue
? `Using the default: ${this.localEditorDefaultValue}`
: 'Using the default';
}
/**
* Whether there is a default to go back to at all. Apart from {@link canUseLocalEditorDefault},
* which says whether pressing it would do anything: the control keeps its place and greys out
* instead of appearing once a value is typed.
*/
showLocalEditorDefault(): boolean {
return this.localEditorDefaultsWhenEmpty && !this.localEditorUseInput;
}
canUseLocalEditorDefault(): boolean {
return this.showLocalEditorDefault() && !this.localEditorUsesDefault();
}
useLocalEditorDefault(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.localEditorValue = '';
}
canSaveLocalEditor(): boolean {
if (this.localEditorLoading) return false;
if (this.localEditorType !== 'boolean' && this.localEditorError() !== null) return false;
if (!this.localEditorBindableAsInput || this.localEditorUseInput) return true;
if (this.localEditorType === 'boolean') return true;
return this.localEditorValue.trim().length > 0;
@ -1044,19 +1136,27 @@ export class GenericNodeComponent implements OnDestroy {
definition.retrieverDependsOn
);
try {
this.localEditorOptions = await this.fetchRetrieverOptions(
// Each request carries its own fallback: an open field is precisely the one whose value list
// comes back empty, so a failure on either side must not decide the other.
const [options, freeText] = await Promise.all([
this.fetchRetrieverOptions(
blockType,
definition.retrieverKey,
definition.retrieverUrl,
definition.retrieverStructuredData,
context
);
} catch {
this.localEditorOptions = [];
} finally {
this.localEditorLoading = false;
}
).catch(() => [] as NodeSettingOption[]),
this.fetchRetrieverFreeText(
blockType,
definition.retrieverKey,
definition.retrieverUrl,
context
)
]);
this.localEditorOptions = options;
this.localEditorFreeText = freeText;
this.localEditorLoading = false;
}
async openFieldPreview(field: EditableFieldView, event?: Event) {
@ -1720,6 +1820,10 @@ export class GenericNodeComponent implements OnDestroy {
if (this.localEditorMin !== null) {
return `${this.localEditorMin} or more - leave empty for the default`;
}
if (this.localEditorFreeText) {
// No list to pick from, so the placeholder has to carry what the select used to imply.
return `Type the ${this.localEditorLabel.toLowerCase()}`;
}
return 'Value...';
}
@ -2063,6 +2167,25 @@ export class GenericNodeComponent implements OnDestroy {
return (values ?? []).map((value) => ({ label: value, value }));
}
/**
* Closed on any failure: a select the user cannot get past is a visible problem, where a text
* field silently accepting anything against a closed list would be a wrong value saved.
*/
private async fetchRetrieverFreeText(
blockType: string,
retrieverKey: string,
retrieverUrl: string | null,
context?: Record<string, string>
): Promise<boolean> {
try {
return await firstValueFrom(
this.fieldRetriever.isFieldOpen(blockType, retrieverKey, context, retrieverUrl)
) === true;
} catch {
return false;
}
}
private toStructuredRetrieverOptions(items: Array<{ descriptor?: { label?: string; description?: string }; data?: unknown }>) {
return items
.map((item, index) => {

View File

@ -2,6 +2,7 @@ import {
collectSchemaFlowDataFields,
isFlowDataFieldPath
} from './flow-data-schema-fields';
import { parentGroupLabel } from './node-utility';
import {
buildOrderedSchemaDisplay,
buildSchemaEditableFieldDefinitions,
@ -382,11 +383,12 @@ describe('schema-driven-fields', () => {
]);
});
it('places an optional group in the ordering, in its own slot', () => {
// The group is one item like an array is, so it must reach the template through a slot of its
// own rather than being mistaken for a field and swept into a grouped fieldset.
it('puts an optional group in the fieldset of the object that owns it', () => {
// The group reaches the template through a slot of its own - it is one control, not a field -
// but it still belongs beside its siblings: "Model parameters" floating at root level would
// not say which object's parameters it edits.
const result = buildOrderedSchemaDisplay({
definitions: [{ path: 'name' }, { path: 'llmDescriptor.parameters' }],
definitions: [{ path: 'name' }, { path: 'llmDescriptor.provider' }, { path: 'llmDescriptor.parameters' }],
fields: [
{
path: 'name',
@ -397,6 +399,16 @@ describe('schema-driven-fields', () => {
enabled: true,
type: 'string' as const,
booleanValue: false
},
{
path: 'llmDescriptor.provider',
label: 'Provider',
value: 'OpenAI',
wide: false,
expandable: false,
enabled: true,
type: 'string' as const,
booleanValue: false
}
],
richContentFields: [],
@ -407,11 +419,62 @@ describe('schema-driven-fields', () => {
resolveGroupLabel: (path) => path.startsWith('llmDescriptor.') ? 'llm' : null
});
expect(result.rootItems.map((item) => item.path)).toEqual(['name', 'llmDescriptor.parameters']);
expect(result.groups).toHaveLength(0);
const group = result.sections.find((section) => section.item?.path === 'llmDescriptor.parameters');
expect(group?.item?.optionalGroupField).toMatchObject({ label: 'Model parameters', setCount: 2 });
expect(group?.item?.field).toBeNull();
expect(result.rootItems.map((item) => item.path)).toEqual(['name']);
expect(result.groups).toHaveLength(1);
expect(result.groups[0].items.map((item) => item.path)).toEqual([
'llmDescriptor.provider',
'llmDescriptor.parameters'
]);
const item = result.groups[0].items[1];
expect(item.optionalGroupField).toMatchObject({ label: 'Model parameters', setCount: 2 });
expect(item.field).toBeNull();
});
it('keeps the parameters of two descriptors in separate fieldsets', () => {
// The reason optional groups are grouped at all: one node holding two LLMs must not show two
// identical "Model parameters" controls with nothing to tell them apart.
const result = buildOrderedSchemaDisplay({
definitions: [
{ path: 'plannerLlm.model' },
{ path: 'plannerLlm.parameters' },
{ path: 'executorLlm.model' },
{ path: 'executorLlm.parameters' }
],
fields: [
{
path: 'plannerLlm.model',
label: 'Model',
value: 'gpt-5.4',
wide: false,
expandable: false,
enabled: true,
type: 'string' as const,
booleanValue: false
},
{
path: 'executorLlm.model',
label: 'Model',
value: 'claude-opus-5',
wide: false,
expandable: false,
enabled: true,
type: 'string' as const,
booleanValue: false
}
],
optionalGroupFields: [
{ path: 'plannerLlm.parameters', label: 'Model parameters', setCount: 1, enabled: true },
{ path: 'executorLlm.parameters', label: 'Model parameters', setCount: 0, enabled: true }
],
resolveGroupLabel: (path) => parentGroupLabel(path)
});
expect(result.rootItems).toEqual([]);
expect(result.groups.map((group) => group.legend)).toEqual(['Planner Llm', 'Executor Llm']);
expect(result.groups.map((group) => group.items.map((item) => item.path))).toEqual([
['plannerLlm.model', 'plannerLlm.parameters'],
['executorLlm.model', 'executorLlm.parameters']
]);
});
it('updates and deletes nested schema values by path', () => {

View File

@ -49,6 +49,10 @@ export type SchemaFieldUiMeta = {
*/
minimum?: number;
maximum?: number;
/** Length and format bounds, read the same way and for the same reason as minimum/maximum. */
minLength?: number;
maxLength?: number;
pattern?: string;
visibleWhen: UiConditionRule[];
enabledWhen: UiConditionRule[];
group: string | null;
@ -159,6 +163,11 @@ export function toSchemaFieldUiMeta(
: undefined;
const minimum = typeof schema?.['minimum'] === 'number' ? Number(schema['minimum']) : undefined;
const maximum = typeof schema?.['maximum'] === 'number' ? Number(schema['maximum']) : undefined;
const minLength = typeof schema?.['minLength'] === 'number' ? Number(schema['minLength']) : undefined;
const maxLength = typeof schema?.['maxLength'] === 'number' ? Number(schema['maxLength']) : undefined;
const pattern = typeof schema?.['pattern'] === 'string' && schema['pattern'].length > 0
? String(schema['pattern'])
: undefined;
const visibleWhen = readEffectiveUiVisibleConditionRule(schema);
const enabledWhen = readUiConditionRule(schema?.['x-ui-enabled-when']);
const label = readUiLabel(schema?.['x-ui-label']) ?? undefined;
@ -183,6 +192,9 @@ export function toSchemaFieldUiMeta(
rows,
minimum,
maximum,
minLength,
maxLength,
pattern,
visibleWhen: [
...(inheritedUi?.visibleWhen ?? []),
...(visibleWhen ? [visibleWhen] : [])
@ -384,6 +396,13 @@ export function buildOrderedSchemaDisplay<
optionalGroupFields?: TOptionalGroup[];
resolveGroupLabel: (path: string) => string | null;
resolveLegend?: (groupLabel: string) => string;
/**
* Which items a group label applies to. Defaults to scalar fields and optional groups: an
* optional group belongs in the fieldset of the object that owns it, because a node may hold
* more than one such object and a lone "Model parameters" control at root level would not say
* whose parameters it edits. Rich content and arrays stay at root, where their own width needs
* the full card.
*/
shouldGroupItem?: (item: SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup>) => boolean;
}
): {
@ -399,7 +418,7 @@ export function buildOrderedSchemaDisplay<
const groups = new Map<string, SchemaDisplayGroup<SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup>>>();
const sections: Array<SchemaDisplaySection<SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup>>> = [];
const resolveLegend = params.resolveLegend ?? ((groupLabel: string) => groupLabel);
const shouldGroupItem = params.shouldGroupItem ?? ((item: SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup>) => item.field != null);
const shouldGroupItem = params.shouldGroupItem ?? ((item: SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup>) => item.field != null || item.optionalGroupField != null);
for (const definition of params.definitions) {
const item: SchemaDisplayItem<TField, TRichContent, TArray, TOptionalGroup> = {
@ -890,6 +909,9 @@ export async function buildSchemaObjectDialog(
const fields: NodeSettingField[] = [];
const initial: Record<string, string | boolean> = {};
// Only to tell an optional field from a required one. Requiredness deliberately does not gate
// saving here: an emptied required number still becomes 0, as it always has.
const requiredKeys = new Set(Array.isArray(objectSchema?.['required']) ? objectSchema['required'] as string[] : []);
for (const { key, schema: propertySchema } of orderedSchemaPropertyEntries(objectSchema, schemaRoot)) {
if (!propertySchema) continue;
@ -911,11 +933,14 @@ export async function buildSchemaObjectDialog(
const currentValue = value[key];
const options = await hooks.loadOptions(propertySchema, value);
const isObjectLike = propertySchema?.['type'] === 'object';
const schemaType = propertySchema?.['type'];
const isNumeric = schemaType === 'number' || schemaType === 'integer';
const fieldType =
options ? 'select' :
propertySchema?.['type'] === 'boolean' ? 'checkbox' :
propertySchema?.['x-ui-widget'] === 'textarea' || isObjectLike ? 'textarea' :
'text';
schemaType === 'boolean' ? 'checkbox' :
isNumeric ? 'number' :
propertySchema?.['x-ui-widget'] === 'textarea' || isObjectLike ? 'textarea' :
'text';
fields.push({
key,
@ -925,6 +950,18 @@ export async function buildSchemaObjectDialog(
options,
placeholder: typeof propertySchema?.['x-ui-placeholder'] === 'string' ? String(propertySchema['x-ui-placeholder']) : undefined,
tip: schemaFieldDescription(propertySchema) ?? undefined,
// The bounds the schema already declares, so the field refuses what the server would reject.
min: isNumeric ? fieldUi.minimum : undefined,
max: isNumeric ? fieldUi.maximum : undefined,
// A decimal has no required granularity, but its arrows still need somewhere sensible to go.
step: schemaType === 'integer' ? 1 : undefined,
stepIncrement: isNumeric ? (schemaType === 'integer' ? 1 : 0.1) : undefined,
minLength: isNumeric ? undefined : fieldUi.minLength,
maxLength: isNumeric ? undefined : fieldUi.maxLength,
pattern: isNumeric ? undefined : fieldUi.pattern,
// A checkbox is excluded because it has no empty state: false is a value, not an absence.
defaultsWhenEmpty: !requiredKeys.has(key) && fieldType !== 'checkbox',
defaultValue: propertySchema?.['default'] == null ? undefined : String(propertySchema['default']),
readonly: !fieldUi.enabledWhen.every((rule) =>
evaluateUiConditionRule(rule, value, (fieldPath) => resolveSchemaPath(objectSchema, fieldPath))
)

View File

@ -221,6 +221,43 @@ body.node-focus-modal-open {
resize: vertical;
}
/* Why the value cannot be saved, under the input it belongs to. */
.llm-modal-error {
font-size: 11px;
font-weight: 600;
color: #dc2626;
}
/* What an empty optional field is doing, so it does not read as unfinished. Shares its look with
.llm-modal-tip: both are background, and neither should compete with .llm-modal-error. */
.llm-modal-hint,
.llm-modal-tip {
font-size: 11px;
line-height: 1.35;
color: #64748b;
}
/* The control grows, its reset keeps a fixed place beside it. */
.llm-modal-control-row {
display: flex;
align-items: flex-start;
gap: 8px;
}
.llm-modal-control-row > :first-child {
flex: 1;
min-width: 0;
}
.llm-modal-default-btn {
flex: 0 0 auto;
}
.llm-btn:disabled {
cursor: default;
opacity: 0.5;
}
.llm-modal-actions {
display: flex;
justify-content: flex-end;