Let a configurable field take a global input, and show its own tip

The small parameter editor offered two sources for a configurable-as-input
field: a static value, or a workflow input. A global input was reachable only
by typing ${{global.<name>}} by hand into the value box - and on a provider
whose model list is closed there is no value box to type into, which left the
one flow-level way of deciding such a field unreachable from the editor.

There is now a third source that lists the flow's declared globals and writes
the placeholder for you, with a plain sentence when the flow declares none
rather than an empty select.

The modal also never rendered a field's description, so every tip written on
one of these fields was invisible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-08 10:53:01 +02:00
parent f5386cdb7e
commit c298c352b3
3 changed files with 291 additions and 7 deletions

View File

@ -581,11 +581,14 @@
<div class="llm-modal-field">
<label>Source</label>
<select
[ngModel]="localEditorUseInput ? 'input' : 'static'"
[ngModel]="localEditorSourceMode()"
(pointerdown)="$event.stopPropagation()"
(ngModelChange)="onLocalEditorSourceModeChange($event)">
<option value="static">Static value</option>
<option value="input">Workflow input</option>
@if (canTakeGlobalInput()) {
<option value="global">From global inputs</option>
}
</select>
@if (localEditorUseInput) {
<div class="llm-modal-tip">
@ -594,6 +597,29 @@
}
</div>
}
<!--
Picking the global writes ${{global.<name>}} into the field, so nothing has to be typed -
which is the point: on a provider with a closed model list there is no text box at all.
-->
@if (localEditorFromGlobal) {
<div class="llm-modal-field">
<label>Global input</label>
<select
[ngModel]="localEditorGlobalName"
[disabled]="!localEditorGlobalInputs.length"
(pointerdown)="$event.stopPropagation()"
(ngModelChange)="onLocalEditorGlobalInputChange($event)">
<option value="">{{ localEditorGlobalInputs.length ? 'Select a global input...' : 'No global inputs available' }}</option>
@for (globalInput of localEditorGlobalInputs; track globalInput.name) {
<option [value]="globalInput.name">{{ globalInputLabel(globalInput) }}</option>
}
</select>
@if (!localEditorGlobalInputs.length) {
<div class="llm-modal-tip">This flow declares no global inputs yet. Add one to the flow to use it here.</div>
}
</div>
}
<div class="llm-modal-field">
<label>{{ localEditorLabel }}</label>
<div class="llm-modal-control-row">
@ -660,6 +686,10 @@
} @else if (localEditorDefaultHint(); as hint) {
<small class="llm-modal-hint">{{ hint }}</small>
}
<!-- The field's own description. The modal never rendered one, so every tip was invisible. -->
@if (localEditorTip) {
<small class="llm-modal-tip">{{ localEditorTip }}</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

@ -505,6 +505,185 @@ describe('GenericNodeComponent', () => {
});
});
describe('taking a field from a global input', () => {
/**
* The third Source choice. It exists because on a provider with a closed model list the field
* is a <select>: there is no text box to type ${{global.x}} into, so the placeholder has to be
* built by picking from a list.
*/
function bindableFieldDefinition() {
return {
path: 'llmDescriptor.model',
label: 'Model',
type: 'string' as const,
enumOptions: [],
nodeOptionsSource: null,
retrieverKey: null,
retrieverBlockType: null,
retrieverUrl: null,
retrieverStructuredData: false,
retrieverDependsOn: [],
ui: {
bindableAsInput: true,
inputName: 'model',
acceptVariableAsPlaceholder: true,
visibleWhen: [],
enabledWhen: []
}
};
}
function withGlobals(globals: Array<Record<string, unknown>>) {
const editorState = TestBed.inject(EditorStateHolder) as any;
// A whole FlowData, not just the globals: saving clones the active flow to mark it dirty.
editorState.activeFlowData.mockReturnValue({
blocks: [],
containers: [],
connections: [],
globalInputs: globals
});
}
function openModelEditor(component: any, currentValue?: string) {
const config = component.ensureBlockConfiguration();
config['llmDescriptor'] = { provider: 'InternalOllama', ...(currentValue === undefined ? {} : { model: currentValue }) };
component.editableFieldDefinitions = [bindableFieldDefinition()];
// What the backend emits for a @ConfigurableAsInput field: structural, because adding or
// removing its port changes the node's shape and only the server can do that.
component.blockSchema = {
type: 'object',
properties: {
llmDescriptor: {
type: 'object',
properties: {
model: {
type: 'string',
'x-ui-structural': true,
'x-ui-bindable-as-input': true,
'x-ui-input-name': 'model',
'x-ui-accept-variable-as-placeholder': true
}
}
}
}
};
return component.openParameterEditor('llmDescriptor.model');
}
it('offers the choice wherever the field can be configured as an input', () => {
// Not gated on anything narrower: a port replaces the whole value, so such a field is by
// construction one value rather than prose with references inside it.
const component = fixture.componentInstance as any;
component.localEditorBindableAsInput = true;
component.localEditorPath = 'llmDescriptor.model';
expect(component.canTakeGlobalInput()).toBe(true);
component.localEditorPath = 'name';
expect(component.canTakeGlobalInput()).toBe(false);
});
it('writes the reference for you when a global is picked', async () => {
const component = fixture.componentInstance as any;
withGlobals([{ name: 'modelName', type: 'TEXT', multiple: false }]);
await openModelEditor(component);
component.onLocalEditorSourceModeChange('global');
expect(component.localEditorSourceMode()).toBe('global');
expect(component.localEditorValue).toBe('');
component.onLocalEditorGlobalInputChange('modelName');
expect(component.localEditorValue).toBe('${{global.modelName}}');
expect(component.canSaveLocalEditor()).toBe(true);
});
it('saves the reference and asks the server to rebuild the node', async () => {
// The field is structural, and a written value means the server must not expose a port.
const component = fixture.componentInstance as any;
withGlobals([{ name: 'modelName', type: 'TEXT', multiple: false }]);
await openModelEditor(component);
component.onLocalEditorSourceModeChange('global');
component.onLocalEditorGlobalInputChange('modelName');
const recreate = vi.spyOn(component, 'markBlockForServerRecreate');
component.saveSimpleParamEditor();
expect(component.ensureBlockConfiguration()['llmDescriptor'].model).toBe('${{global.modelName}}');
expect(recreate).toHaveBeenCalled();
});
it('reopens on the mode it was left in, with the global preselected', async () => {
const component = fixture.componentInstance as any;
withGlobals([{ name: 'modelName', type: 'TEXT', multiple: false }]);
await openModelEditor(component, '${{global.modelName}}');
expect(component.localEditorSourceMode()).toBe('global');
expect(component.localEditorGlobalName).toBe('modelName');
});
it('opens as a static value when the reference is embedded in other text', async () => {
// That is a template someone wrote by hand; the picker can only express a whole value.
const component = fixture.componentInstance as any;
withGlobals([{ name: 'modelName', type: 'TEXT', multiple: false }]);
await openModelEditor(component, 'prefix-${{global.modelName}}');
expect(component.localEditorSourceMode()).toBe('static');
expect(component.localEditorGlobalName).toBe('');
});
it('says why the list is empty instead of hiding the choice', async () => {
const component = fixture.componentInstance as any;
withGlobals([]);
await openModelEditor(component);
component.onLocalEditorSourceModeChange('global');
expect(component.canTakeGlobalInput()).toBe(true);
expect(component.localEditorGlobalInputs).toEqual([]);
// Nothing picked, so nothing to save either.
expect(component.canSaveLocalEditor()).toBe(false);
});
it('does not leave a reference behind when the mode is switched away', async () => {
const component = fixture.componentInstance as any;
withGlobals([{ name: 'modelName', type: 'TEXT', multiple: false }]);
await openModelEditor(component, '${{global.modelName}}');
component.onLocalEditorSourceModeChange('static');
// A ${{...}} lingering here would be saved as a literal model name.
expect(component.localEditorValue).toBe('');
expect(component.localEditorGlobalName).toBe('');
});
it('renders the field description, which the modal never used to show', async () => {
// The other half of why none of this was discoverable: the modal held no tip at all, so a
// @UiDescription explaining that the field takes a placeholder was invisible.
const component = fixture.componentInstance as any;
const definition = bindableFieldDefinition();
definition.ui = { ...definition.ui, tip: 'Leave empty to take it from a node input.' } as any;
component.editableFieldDefinitions = [definition];
component.ensureBlockConfiguration()['llmDescriptor'] = { provider: 'InternalOllama' };
await component.openParameterEditor('llmDescriptor.model');
// The component now carries it, which it never did - the template renders it from here.
expect(component.localEditorTip).toBe('Leave empty to take it from a node input.');
});
it('labels a global with its type and multiplicity', () => {
const component = fixture.componentInstance as any;
expect(component.globalInputLabel({ name: 'modelName', type: 'TEXT', multiple: false }))
.toBe('modelName (TEXT)');
expect(component.globalInputLabel({ name: 'cvs', type: 'TEXT', multiple: true }))
.toBe('cvs (TEXT[])');
});
});
it('should create', () => {
expect(component).toBeTruthy();
});

View File

@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, ElementRef, HostBinding, HostListener, inject, Input, OnDestroy, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { BiasAnnotation, BiasAnnotationsDescriptor, BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowData, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, isProbeExecutable, normalizeFlowPortValueKinds } from '@models/flow';
import { BiasAnnotation, BiasAnnotationsDescriptor, BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowData, FlowGlobalInput, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, isProbeExecutable, normalizeFlowPortValueKinds } from '@models/flow';
import { BiasAnnotationsComponent } from '../../bias-annotations/bias-annotations';
import { NodeFocusModalController } from '../node-focus-modal-controller';
import { ClassicPreset } from 'rete';
@ -177,6 +177,8 @@ export class GenericNodeComponent implements OnDestroy {
localEditorMaxLength: number | null = null;
localEditorMinLength: number | null = null;
localEditorPattern: string | null = null;
/** The field's own description. The modal never rendered one, so every tip was invisible. */
localEditorTip: 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;
@ -186,6 +188,15 @@ export class GenericNodeComponent implements OnDestroy {
localEditorRows: number | null = null;
localEditorBindableAsInput = false;
localEditorUseInput = false;
/**
* The third Source choice: the value is a reference to a declared global input. Kept apart from
* localEditorUseInput rather than folded into a tri-state, so the ten places that ask "is this
* provided by a port" keep reading as they did - a global reference is a configured value, not a
* port, and the two can never both be true.
*/
localEditorFromGlobal = false;
localEditorGlobalName = '';
localEditorGlobalInputs: FlowGlobalInput[] = [];
/** A retriever whose values are an incomplete list: the field takes a typed value instead. */
localEditorFreeText = false;
localEditorBindableInputName: string | null = null;
@ -367,6 +378,7 @@ export class GenericNodeComponent implements OnDestroy {
this.localEditorMaxLength = 20;
this.localEditorMinLength = null;
this.localEditorPattern = null;
this.localEditorTip = null;
this.localEditorDefaultsWhenEmpty = false;
this.localEditorDefaultValue = null;
this.localEditorMin = null;
@ -380,6 +392,9 @@ export class GenericNodeComponent implements OnDestroy {
this.localEditorRows = null;
this.localEditorBindableAsInput = false;
this.localEditorUseInput = false;
this.localEditorFromGlobal = false;
this.localEditorGlobalName = '';
this.localEditorGlobalInputs = [];
this.localEditorFreeText = false;
this.localEditorBindableInputName = null;
}
@ -399,6 +414,7 @@ export class GenericNodeComponent implements OnDestroy {
this.localEditorMaxLength = definition.ui.maxLength ?? null;
this.localEditorMinLength = definition.ui.minLength ?? null;
this.localEditorPattern = definition.ui.pattern ?? null;
this.localEditorTip = definition.ui.tip ?? 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);
@ -410,12 +426,16 @@ export class GenericNodeComponent implements OnDestroy {
this.localEditorOptions = this.resolveSelectableOptions(definition);
this.localEditorBindableAsInput = definition.ui.bindableAsInput;
this.localEditorUseInput = this.isBindableFieldUsingInput(definition);
this.localEditorGlobalInputs = this.editorState.activeFlowData()?.globalInputs ?? [];
const referencedGlobal = this.globalReferenceName(this.getByPath(this.blockConfiguration ?? {}, definition.path));
this.localEditorFromGlobal = !!referencedGlobal;
this.localEditorGlobalName = referencedGlobal ?? '';
this.localEditorBindableInputName = definition.ui.inputName;
this.localEditorLoading = !!definition.retrieverKey && !this.localEditorUseInput;
this.localEditorLoading = !!definition.retrieverKey && !this.localEditorUseInput && !this.localEditorFromGlobal;
this.localEditorHasRetriever = this.localEditorOptions.length > 0 || !!definition.retrieverKey || !!definition.nodeOptionsSource;
this.localEditorOpen = true;
if (definition.retrieverKey && !this.localEditorUseInput) {
if (definition.retrieverKey && !this.localEditorUseInput && !this.localEditorFromGlobal) {
const missingDependencies = definition.retrieverDependsOn
.filter((dep) => {
const value = this.resolveRetrieverDependencyValue(this.blockConfiguration ?? {}, dep);
@ -452,12 +472,16 @@ export class GenericNodeComponent implements OnDestroy {
this.localEditorMaxLength = null;
this.localEditorMinLength = null;
this.localEditorPattern = null;
this.localEditorTip = null;
this.localEditorDefaultsWhenEmpty = false;
this.localEditorDefaultValue = null;
this.localEditorWidget = null;
this.localEditorRows = null;
this.localEditorBindableAsInput = false;
this.localEditorUseInput = false;
this.localEditorFromGlobal = false;
this.localEditorGlobalName = '';
this.localEditorGlobalInputs = [];
this.localEditorFreeText = false;
this.localEditorBindableInputName = null;
}
@ -1042,7 +1066,9 @@ export class GenericNodeComponent implements OnDestroy {
* 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;
// A global reference is not the final value, so there is nothing to validate against the
// field's own bounds - the value it stands for arrives at run time.
if (this.localEditorLoading || this.localEditorUseInput || this.localEditorFromGlobal) return null;
return validateFieldValue(
{
type: this.localEditorType === 'number' || this.localEditorType === 'integer' ? 'number' : 'text',
@ -1062,6 +1088,7 @@ export class GenericNodeComponent implements OnDestroy {
localEditorUsesDefault(): boolean {
return this.localEditorDefaultsWhenEmpty
&& !this.localEditorUseInput
&& !this.localEditorFromGlobal
&& this.localEditorValue.trim().length === 0;
}
@ -1078,7 +1105,7 @@ export class GenericNodeComponent implements OnDestroy {
* instead of appearing once a value is typed.
*/
showLocalEditorDefault(): boolean {
return this.localEditorDefaultsWhenEmpty && !this.localEditorUseInput;
return this.localEditorDefaultsWhenEmpty && !this.localEditorUseInput && !this.localEditorFromGlobal;
}
canUseLocalEditorDefault(): boolean {
@ -1110,9 +1137,47 @@ export class GenericNodeComponent implements OnDestroy {
socketHost.socket = new ClassicPreset.Socket(type);
}
localEditorSourceMode(): 'static' | 'input' | 'global' {
if (this.localEditorUseInput) return 'input';
return this.localEditorFromGlobal ? 'global' : 'static';
}
/**
* Whether the field can take its value from a declared global input.
*
* <p>Offered wherever the field can be configured as an input at all: a port replaces the whole
* value, so such a field is by construction a single value rather than prose with references
* embedded in it - which is what makes replacing it with one reference meaningful. Prose fields
* like a prompt have no Source selector and never reach this.
*/
canTakeGlobalInput(): boolean {
return this.localEditorBindableAsInput && this.localEditorPath !== 'name';
}
globalInputLabel(input: FlowGlobalInput): string {
return `${input.name} (${String(input.type ?? 'ANY').toUpperCase()}${input.multiple ? '[]' : ''})`;
}
onLocalEditorGlobalInputChange(name: string) {
this.localEditorGlobalName = name;
// The value the field actually stores. Written for the user rather than typed by hand, which
// is the whole point: on a provider with a closed model list there is no text box to type in.
this.localEditorValue = name ? `\${{global.${name}}}` : '';
}
async onLocalEditorSourceModeChange(nextMode: string) {
const wasFromGlobal = this.localEditorFromGlobal;
this.localEditorUseInput = nextMode === 'input';
if (this.localEditorUseInput || !this.localEditorPath) {
this.localEditorFromGlobal = nextMode === 'global';
if (this.localEditorFromGlobal || wasFromGlobal) {
// Entering the mode starts from nothing to pick, and leaving it must not leave a
// ${{...}} behind as though it had been typed as a literal.
this.localEditorGlobalName = '';
this.localEditorValue = '';
}
if (this.localEditorUseInput || this.localEditorFromGlobal || !this.localEditorPath) {
this.localEditorLoading = false;
return;
}
@ -1124,6 +1189,16 @@ export class GenericNodeComponent implements OnDestroy {
await this.loadLocalEditorOptions(definition);
}
/**
* The global input a value refers to, when the value is *exactly* that reference. A value with
* text around it is a template the user wrote by hand, and belongs in the static mode.
*/
private globalReferenceName(value: unknown): string | null {
if (typeof value !== 'string') return null;
const match = /^\$\{\{global\.([^}]+)\}\}$/.exec(value.trim());
return match ? match[1] : null;
}
private async loadLocalEditorOptions(definition: EditableFieldDefinition) {
const blockType = definition.retrieverBlockType ?? this.blockType;
if (!blockType || !definition.retrieverKey) {