diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html
index 4ba1a31..2e61ea5 100644
--- a/src/app/shared/nodes/generic-node/generic-node.html
+++ b/src/app/shared/nodes/generic-node/generic-node.html
@@ -581,11 +581,14 @@
Source
Static value
Workflow input
+ @if (canTakeGlobalInput()) {
+ From global inputs
+ }
@if (localEditorUseInput) {
@@ -594,6 +597,29 @@
}
}
+
+
+ @if (localEditorFromGlobal) {
+
+
Global input
+
+ {{ localEditorGlobalInputs.length ? 'Select a global input...' : 'No global inputs available' }}
+ @for (globalInput of localEditorGlobalInputs; track globalInput.name) {
+ {{ globalInputLabel(globalInput) }}
+ }
+
+ @if (!localEditorGlobalInputs.length) {
+
This flow declares no global inputs yet. Add one to the flow to use it here.
+ }
+
+ }
{{ localEditorLabel }}
@@ -660,6 +686,10 @@
} @else if (localEditorDefaultHint(); as hint) {
{{ hint }}
}
+
+ @if (localEditorTip) {
+ {{ localEditorTip }}
+ }
Cancel
diff --git a/src/app/shared/nodes/generic-node/generic-node.spec.ts b/src/app/shared/nodes/generic-node/generic-node.spec.ts
index 6e310d6..3318cd5 100644
--- a/src/app/shared/nodes/generic-node/generic-node.spec.ts
+++ b/src/app/shared/nodes/generic-node/generic-node.spec.ts
@@ -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
: 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>) {
+ 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();
});
diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts
index f6d8eff..adb4c94 100644
--- a/src/app/shared/nodes/generic-node/generic-node.ts
+++ b/src/app/shared/nodes/generic-node/generic-node.ts
@@ -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.
+ *
+ * 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) {