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:
parent
f5386cdb7e
commit
c298c352b3
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue