Draw a resource as its own kind of node, not a block

A storage was a block with a visual role that said "not a step", which
left resource-shaped exceptions scattered through the editor, the node
components, the palette and the execution view.

The flow now carries resources beside its blocks, and the canvas draws
them with a component of their own: no ports, no dependencies, one
handle for the steps that use it. What a step links is read from the
schema flag x-resource-ref, so the editor no longer knows the word
"storage" anywhere - a new kind of resource needs no code here.

The configuration form the two kinds of node share is now a component of
its own (app-node-parameters), so a resource is edited exactly as a
block while it is drawn differently.

Resource types come from /resources/types, and share the palette with
the blocks under the Resource kind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-29 08:24:18 +02:00
parent ee2696814c
commit 067cd2f287
36 changed files with 1219 additions and 585 deletions

View File

@ -31,6 +31,8 @@ export type Flow = {
export type FlowData = {
blocks: FlowBlock[];
containers: FlowContainer[];
/** What the flow's steps use without being steps: a storage. Never among its nodes. */
resources?: FlowResource[];
connections: FlowBlockConnection[];
dependencies: FlowNodeDependency[];
globalInputs?: FlowGlobalInput[];
@ -67,7 +69,7 @@ export type FlowSubflowValidationResult = {
};
export type BlockTypeName = "HumanInteractionBlock" | "LLMBlock" | "SourceBlock" | string;
export type NodeFamily = 'block' | 'container';
export type NodeFamily = 'block' | 'container' | 'resource';
export type BlockTypeSchema = Record<string, unknown> | null;
@ -86,8 +88,7 @@ export type BlockInteractionContract = {
supportsPartialResult: boolean;
};
/** RESOURCE: not a step - a storage the steps on its edges read and write. */
export type NodeVisualRole = 'ACTIVITY' | 'DECISION' | 'MERGE' | 'END' | 'CONTAINER' | 'RESOURCE';
export type NodeVisualRole = 'ACTIVITY' | 'DECISION' | 'MERGE' | 'END' | 'CONTAINER';
export type NodeTypeCapabilities = {
visualRole: NodeVisualRole;
@ -105,6 +106,18 @@ export type NodeTypeCapabilities = {
routesExclusively?: boolean;
};
/** A resource is not a step: nothing connects to it, and it neither takes nor gives dependencies. */
export const RESOURCE_NODE_CAPABILITIES: NodeTypeCapabilities = {
visualRole: 'ACTIVITY',
terminal: false,
biasAnnotationsAllowed: false,
allowsIncomingConnections: false,
allowsOutgoingConnections: false,
canDependOnOtherNodes: false,
canHaveDependentNodes: false,
routesExclusively: false,
};
export const DEFAULT_NODE_CAPABILITIES: NodeTypeCapabilities = {
visualRole: 'ACTIVITY',
terminal: false,
@ -212,6 +225,32 @@ export type FlowContainer = FlowNodeBase & {
export type FlowNode = FlowBlock | FlowContainer;
/**
* A resource of the flow: something its steps use which is not a step - a storage.
*
* <p>It has no ports, no connections and no dependencies. A step names it in a configuration field
* the schema marks with x-resource-ref, and the editor draws that as a link.
*/
export type FlowResource = {
id: string;
name: string;
position?: { x: number, y: number };
specificConfiguration: FlowBlockConfiguration;
typeName: string;
nodeFamily?: 'resource';
laneId?: string | null;
};
/** What the editor is told about a kind of resource, as BlockType says about a kind of block. */
export type ResourceType = {
type: string;
description: string;
exampleEndpoint?: string | null;
configurationType: string | null;
configurationClass: string | null;
schema: BlockTypeSchema;
};
export type FlowValueKind = {
type: string;
multiple: boolean;
@ -278,14 +317,28 @@ export type GroupedFlowValidation = {
export const FLOW_DEPENDANT_PORT_KEY = '__dependant';
export const FLOW_DEPENDENCY_PORT_KEY = '__dependency';
export const FLOW_DEPENDENCY_SOCKET_TYPE = '__FLOW_DEPENDENCY__';
/** A Storage node's handle, and a StorageOperation's: the link between them is not a connection. */
export const FLOW_STORAGE_PORT_KEY = '__storage';
export const FLOW_STORAGE_LINK_PORT_KEY = '__storageNode';
export const FLOW_STORAGE_SOCKET_TYPE = '__FLOW_STORAGE__';
/** A resource's handle, and the handle of a step linked to one: that link is not a connection. */
export const FLOW_RESOURCE_PORT_KEY = '__resource';
export const FLOW_RESOURCE_LINK_PORT_KEY = '__resourceRef';
export const FLOW_RESOURCE_SOCKET_TYPE = '__FLOW_RESOURCE__';
/** A block that works on a Storage node, linked to it rather than connected. */
export function isStorageOperationNode(node: { typeName?: string; specificConfiguration?: Record<string, unknown> | null }): boolean {
return node.typeName === 'StorageOperation' || node.specificConfiguration?.['type'] === 'StorageOperationBlockConfiguration';
/** The schema flag that says a field holds the id of a resource, and of which type. */
export const RESOURCE_REF_SCHEMA_KEY = 'x-resource-ref';
/**
* The field a block links a resource through, from its schema: its name and the type of resource
* it takes. A block has at most one today; the first is the one the canvas draws.
*/
export function resourceRefField(schema: BlockTypeSchema | undefined | null): { field: string; resourceType: string } | null {
const properties = (schema as Record<string, unknown> | null | undefined)?.['properties'];
if (!properties || typeof properties !== 'object') return null;
for (const [field, raw] of Object.entries(properties as Record<string, unknown>)) {
const resourceType = (raw as Record<string, unknown> | null)?.[RESOURCE_REF_SCHEMA_KEY];
if (typeof resourceType === 'string' && resourceType) {
return { field, resourceType };
}
}
return null;
}
export function normalizeFlowValidationErrors(raw: unknown): FlowValidationError[] {

View File

@ -12,6 +12,7 @@ import { CommonModule } from '@angular/common';
import { FlowsService } from '@services/flows/flows';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { ResourcesService } from '@services/resources/resources';
import { ListState } from '@stores/list-state';
import { finalize } from 'rxjs';
import { MatMenuModule } from '@angular/material/menu';
@ -55,6 +56,7 @@ export class EditorSidebar {
flowService = inject(FlowsService);
blocksService = inject(BlocksService);
containersService = inject(ContainersService);
resourcesService = inject(ResourcesService);
confirm = inject(ConfirmDialogService);
notifications = inject(NotificationService);
@ -209,7 +211,8 @@ export class EditorSidebar {
try {
await Promise.all([
this.blocksService.getAllBlocksTypes(),
this.containersService.getAllContainerTypes()
this.containersService.getAllContainerTypes(),
this.resourcesService.getAllResourceTypes()
]);
} catch (error) {
console.error('Could not load the type catalogs before importing', error);
@ -218,7 +221,8 @@ export class EditorSidebar {
return [
...names.blocks.filter((name) => !this.blocksService.peekBlockType(resolveLegacyTypeName(name))),
...names.containers.filter((name) => !this.containersService.peekContainerType(resolveLegacyTypeName(name)))
...names.containers.filter((name) => !this.containersService.peekContainerType(resolveLegacyTypeName(name))),
...names.resources.filter((name) => !this.resourcesService.peekResourceType(name))
];
}

View File

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { Flow, FlowBlock, FlowContainer, FlowData, FlowGlobalInput, FlowLane, FlowStatus, FlowVisibility, normalizeFlowValidationErrors } from '@models/flow';
import { FlowResource, Flow, FlowBlock, FlowContainer, FlowData, FlowGlobalInput, FlowLane, FlowStatus, FlowVisibility, normalizeFlowValidationErrors } from '@models/flow';
function parseDate(value: unknown, fallback: Date): Date {
if (typeof value !== 'string' || !value) return fallback;
@ -47,6 +47,7 @@ export function flowFromApi(raw: unknown): Flow {
data: {
blocks: normalizeNodes(data.blocks, 'block') as FlowBlock[],
containers: normalizeNodes(data.containers, 'container') as FlowContainer[],
resources: normalizeNodes(data.resources, 'resource') as unknown as FlowResource[],
connections: Array.isArray(data.connections) ? data.connections : [],
dependencies: Array.isArray(data.dependencies) ? data.dependencies : [],
globalInputs: normalizeGlobalInputs(data.globalInputs),
@ -68,6 +69,7 @@ export function toFlowCreateRequest(name: string, description?: string, flow?: F
flow: flow ?? {
blocks: [],
containers: [],
resources: [],
connections: [],
dependencies: [],
globalInputs: [],
@ -106,7 +108,7 @@ function normalizeLanes(raw: unknown): FlowLane[] {
.sort((a, b) => a.order - b.order);
}
function normalizeNodes(raw: unknown, nodeFamily: 'block' | 'container'): Array<FlowBlock | FlowContainer> {
function normalizeNodes(raw: unknown, nodeFamily: 'block' | 'container' | 'resource'): Array<FlowBlock | FlowContainer> {
if (!Array.isArray(raw)) return [];
return raw

View File

@ -16,6 +16,7 @@ function makeFlowData(overrides: Partial<FlowData> = {}): FlowData {
return {
blocks: [],
containers: [],
resources: [],
connections: [],
dependencies: [],
globalInputs: [],
@ -131,7 +132,7 @@ describe('flow transfer', () => {
expect(parsed.ok).toBe(true);
if (!parsed.ok) return;
expect(parsed.data).toEqual({
blocks: [], containers: [], connections: [], dependencies: [], globalInputs: [], lanes: []
blocks: [], containers: [], resources: [], connections: [], dependencies: [], globalInputs: [], lanes: []
});
});
@ -205,7 +206,8 @@ describe('flow transfer', () => {
expect(collectNodeTypeNames(data)).toEqual({
blocks: ['LLMBlock', 'EndBlock'],
containers: ['IteratorContainer']
containers: ['IteratorContainer'],
resources: []
});
});
});

View File

@ -125,13 +125,15 @@ export function parseFlowImport(text: string, fallbackName: string): FlowImportP
* know is just as fatal in there, so the walk goes all the way down - reusing the app's own
* definition of where a subflow lives rather than growing a second one.
*/
export function collectNodeTypeNames(data: FlowData): { blocks: string[]; containers: string[] } {
export function collectNodeTypeNames(data: FlowData): { blocks: string[]; containers: string[]; resources: string[] } {
const blocks = new Set<string>();
const containers = new Set<string>();
const resources = new Set<string>();
const collect = (graph: FlowData) => {
for (const name of typeNamesOf(graph.blocks)) blocks.add(name);
for (const name of typeNamesOf(graph.containers)) containers.add(name);
for (const name of typeNamesOf(graph.resources ?? [])) resources.add(name);
};
collect(data);
@ -140,7 +142,7 @@ export function collectNodeTypeNames(data: FlowData): { blocks: string[]; contai
if (nested) collect(nested);
}
return { blocks: [...blocks], containers: [...containers] };
return { blocks: [...blocks], containers: [...containers], resources: [...resources] };
}
/**
@ -179,6 +181,7 @@ function toFlowData(value: unknown): FlowData | null {
return {
blocks: record['blocks'] as FlowData['blocks'],
containers: asArray(record['containers']) as FlowData['containers'],
resources: asArray(record['resources']) as FlowData['resources'],
connections: asArray(record['connections']) as FlowData['connections'],
dependencies: asArray(record['dependencies']) as FlowData['dependencies'],
globalInputs: asArray(record['globalInputs']) as FlowData['globalInputs'],

View File

@ -36,6 +36,7 @@ export class FlowsCallService extends FlowsCallServiceBase {
data: {
blocks: [],
containers: [],
resources: [],
connections: [],
dependencies: [],
globalInputs: []

View File

@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { FlowResource, ResourceType } from '@models/flow';
import { Observable } from 'rxjs';
export abstract class ResourcesCallServiceBase {
abstract retrieveAllResourceTypes(): Observable<ResourceType[]>;
abstract createEmptyResource(resourceType: string): Observable<FlowResource>;
}

View File

@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { FlowResource, ResourceType } from '@models/flow';
import { Observable, of } from 'rxjs';
import { ResourcesCallServiceBase } from './resource-call.base';
export const FAKE_STORAGE_RESOURCE_TYPE: ResourceType = {
type: 'Storage',
description: 'A storage the flow keeps data in. It is not a step.',
exampleEndpoint: '/resources/types/Storage/example',
configurationType: 'StorageResourceConfiguration',
configurationClass: 'it.cnr.isti.workflow.manager.storage.StorageResourceConfiguration',
schema: {
type: 'object',
properties: {
name: { type: 'string', 'x-ui-order': 10 },
storageType: { type: 'string', 'x-ui-order': 20, 'x-ui-label': 'Storage type' },
source: { type: 'string', enum: ['CATALOG', 'PERSONAL'], default: 'CATALOG', 'x-ui-order': 30 }
}
}
};
export class ResourcesCallServiceFake extends ResourcesCallServiceBase {
override retrieveAllResourceTypes(): Observable<ResourceType[]> {
return of([FAKE_STORAGE_RESOURCE_TYPE]);
}
override createEmptyResource(resourceType: string): Observable<FlowResource> {
return of({
id: crypto.randomUUID(),
name: resourceType,
specificConfiguration: { type: 'StorageResourceConfiguration', name: resourceType } as FlowResource['specificConfiguration'],
typeName: resourceType,
nodeFamily: 'resource' as const
});
}
}

View File

@ -0,0 +1,59 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { runInInjectionContext, Injector } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { environment } from '@environment';
import { firstValueFrom } from 'rxjs';
import { ResourcesCallService } from './resources-call';
describe('ResourcesCallService', () => {
let service: ResourcesCallService;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
http = TestBed.inject(HttpTestingController);
service = runInInjectionContext(TestBed.inject(Injector), () => new ResourcesCallService());
});
afterEach(() => http.verify());
it('reads the catalog, giving every resource type the shared schema definitions', async () => {
const types = firstValueFrom(service.retrieveAllResourceTypes());
http.expectOne(`${environment.apiUrl}/resources/types/configurations/catalog`).flush({
sharedDefinitions: { Shape: { type: 'string' } },
descriptors: [{
type: 'Storage',
description: 'A storage the flow keeps data in.',
exampleEndpoint: '/resources/types/Storage/example',
configurationType: 'StorageResourceConfiguration',
configurationClass: 'it.cnr.isti.StorageResourceConfiguration',
schema: { type: 'object', properties: { name: { type: 'string' } } }
}]
});
const [storage] = await types;
expect(storage.type).toBe('Storage');
expect(storage.configurationType).toBe('StorageResourceConfiguration');
expect((storage.schema as any)['sharedDefinitions']).toEqual({ Shape: { type: 'string' } });
});
it('asks the server for an empty resource, and marks it as one', async () => {
const resource = firstValueFrom(service.createEmptyResource('Storage'));
http.expectOne(`${environment.apiUrl}/resources/types/Storage/example`).flush({
id: 'r1',
name: 'Storage',
typeName: 'Storage',
specificConfiguration: { type: 'StorageResourceConfiguration', name: 'Storage' }
});
expect(await resource).toEqual(expect.objectContaining({ id: 'r1', typeName: 'Storage', nodeFamily: 'resource' }));
});
});

View File

@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { environment } from '@environment';
import { FlowResource, ResourceType } from '@models/flow';
import { attachSharedDefinitions, toApiPath, toNullableString, toPosition, toRecord, toSchema } from '@services/shared/flow-node-mapping';
import { map, Observable } from 'rxjs';
import { ResourcesCallServiceBase } from './resource-call.base';
export class ResourcesCallService extends ResourcesCallServiceBase {
private readonly http = inject(HttpClient);
override retrieveAllResourceTypes(): Observable<ResourceType[]> {
return this.http
.get<unknown>(`${environment.apiUrl}/resources/types/configurations/catalog`)
.pipe(map((raw) => this.parseCatalogResponse(raw)));
}
override createEmptyResource(resourceType: string): Observable<FlowResource> {
return this.http
.get<unknown>(`${environment.apiUrl}/resources/types/${encodeURIComponent(resourceType)}/example`)
.pipe(map((raw) => this.resourceFromApi(raw, resourceType)));
}
private parseCatalogResponse(raw: unknown): ResourceType[] {
const value = toRecord(raw);
const descriptors = value['descriptors'];
if (!Array.isArray(descriptors)) {
throw new Error('Invalid resource catalog response: expected a descriptors array');
}
const sharedDefinitions = toSchema(value['sharedDefinitions']);
return descriptors.map((descriptor) => this.resourceTypeFromApi(descriptor, sharedDefinitions));
}
private resourceTypeFromApi(raw: unknown, sharedDefinitions: Record<string, unknown> | null): ResourceType {
const value = toRecord(raw);
return {
type: String(value['type'] ?? ''),
description: String(value['description'] ?? ''),
exampleEndpoint: toApiPath(value['exampleEndpoint']),
configurationType: toNullableString(value['configurationType']),
configurationClass: toNullableString(value['configurationClass']),
schema: attachSharedDefinitions(toSchema(value['schema']), sharedDefinitions)
};
}
private resourceFromApi(raw: unknown, fallbackType: string): FlowResource {
const value = toRecord(raw);
const specificConfiguration = toRecord(value['specificConfiguration']);
return {
id: String(value['id'] ?? crypto.randomUUID()),
name: String(value['name'] ?? specificConfiguration['name'] ?? fallbackType),
position: toPosition(value['position']),
specificConfiguration: specificConfiguration as FlowResource['specificConfiguration'],
typeName: String(value['typeName'] ?? fallbackType),
nodeFamily: 'resource'
};
}
}

View File

@ -0,0 +1,41 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { Injectable, Signal } from '@angular/core';
import { environment } from '@environment';
import { FlowResource, ResourceType } from '@models/flow';
import { CatalogStore } from '@services/shared/catalog-store';
import { Observable } from 'rxjs';
import { ResourcesCallServiceBase } from './resource-call.base';
/** The resource types the server has, beside its block types. */
@Injectable({ providedIn: 'root' })
export class ResourcesService extends CatalogStore<ResourceType> {
private readonly call: ResourcesCallServiceBase = new environment.resourcesCallService();
protected readonly loadErrorLabel = 'Retrieve resource types failed';
readonly resourceTypes = this.types;
readonly catalogLoading = this.loading;
protected fetchAll(): Observable<ResourceType[]> {
return this.call.retrieveAllResourceTypes();
}
hasLoadedResourceTypes(): boolean {
return this.hasLoadedTypes();
}
getAllResourceTypes(): Promise<Signal<ResourceType[]>> {
return this.getAllTypes();
}
peekResourceType(type: string): ResourceType | null {
return this.peekType((resourceType) => resourceType.type === type);
}
createEmptyResource(resourceType: string): Observable<FlowResource> {
return this.call.createEmptyResource(resourceType);
}
}

View File

@ -147,7 +147,7 @@ describe('toNodeCapabilities', () => {
it('reads that a type routes exclusively, which is what lets it guard a loop', () => {
expect(toNodeCapabilities({ visualRole: 'DECISION', routesExclusively: true }).routesExclusively).toBe(true);
expect(toNodeCapabilities({ visualRole: 'RESOURCE' }).visualRole).toBe('RESOURCE');
expect(toNodeCapabilities({ visualRole: 'RESOURCE' }).visualRole).toBe('ACTIVITY');
expect(toNodeCapabilities({ visualRole: 'BRANCH_REJOIN' }).visualRole).toBe('MERGE');
});

View File

@ -93,7 +93,7 @@ export function toPorts(
});
}
const NODE_VISUAL_ROLES: NodeVisualRole[] = ['ACTIVITY', 'DECISION', 'MERGE', 'END', 'CONTAINER', 'RESOURCE'];
const NODE_VISUAL_ROLES: NodeVisualRole[] = ['ACTIVITY', 'DECISION', 'MERGE', 'END', 'CONTAINER'];
function toVisualRole(value: unknown): NodeVisualRole {
const raw = typeof value === 'string' ? value.toUpperCase() : '';

View File

@ -6,6 +6,7 @@ import { DEFAULT_NODE_CAPABILITIES } from '@models/flow';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { BlocksService } from '@services/blocks/blocks';
import { ResourcesService } from '@services/resources/resources';
import { ListState } from '@stores/list-state';
import { vi } from 'vitest';
@ -86,22 +87,24 @@ describe('BlocksList', () => {
it('says what each block is from the role its type declares, not only whether a person is in it', () => {
const base = { ...DEFAULT_NODE_CAPABILITIES };
expect(blockKind({ userInteractive: false, capabilities: { ...base, visualRole: 'RESOURCE' } }).label).toBe('Resource');
expect(blockKind({ userInteractive: false, capabilities: { ...base, visualRole: 'END', terminal: true } }).label).toBe('End of a path');
expect(blockKind({ userInteractive: false, capabilities: { ...base, visualRole: 'DECISION', routesExclusively: true } }).label).toBe('Automated decision');
expect(blockKind({ userInteractive: true, capabilities: { ...base, visualRole: 'DECISION', routesExclusively: true } }).label).toBe('Human decision');
expect(blockKind({ userInteractive: true, capabilities: base }).label).toBe('Human step');
expect(blockKind({ userInteractive: false, capabilities: { ...base, visualRole: 'MERGE' } }).label).toBe('Branches rejoin');
expect(blockKind({ userInteractive: false, capabilities: base }).label).toBe('Automated step');
expect(blockKind({ userInteractive: false }).label).toBe('Automated step');
expect(blockKind({ userInteractive: false, family: 'resource' }).label).toBe('Resource');
expect(blockKind({ userInteractive: false, family: 'block', capabilities: { ...base, visualRole: 'END', terminal: true } }).label).toBe('End of a path');
expect(blockKind({ userInteractive: false, family: 'block', capabilities: { ...base, visualRole: 'DECISION', routesExclusively: true } }).label).toBe('Automated decision');
expect(blockKind({ userInteractive: true, family: 'block', capabilities: { ...base, visualRole: 'DECISION', routesExclusively: true } }).label).toBe('Human decision');
expect(blockKind({ userInteractive: true, family: 'block', capabilities: base }).label).toBe('Human step');
expect(blockKind({ userInteractive: false, family: 'block', capabilities: { ...base, visualRole: 'MERGE' } }).label).toBe('Branches rejoin');
expect(blockKind({ userInteractive: false, family: 'block', capabilities: base }).label).toBe('Automated step');
expect(blockKind({ userInteractive: false, family: 'block' }).label).toBe('Automated step');
});
it('narrows the palette to the kinds picked, and shows everything when none is', () => {
const palette = [...BLOCK_TYPES, {
...BLOCK_TYPES[0], type: 'Storage', description: 'A storage the steps read and write.',
capabilities: { ...DEFAULT_NODE_CAPABILITIES, visualRole: 'RESOURCE' as const }
}];
(TestBed.inject(BlocksService) as any).blockTypes.set(palette);
it('narrows the palette to the kinds picked, and shows everything when none is', async () => {
(TestBed.inject(BlocksService) as any).blockTypes.set([...BLOCK_TYPES]);
// The resource types come from their own catalogue, and share the palette with the blocks.
await TestBed.inject(ResourcesService).getAllResourceTypes();
component.ngOnInit();
await Promise.resolve();
fixture.detectChanges();
const kinds = component.availableKinds().map((kind) => kind.label);
expect(kinds).toEqual(['Automated step', 'Resource']);
@ -114,6 +117,6 @@ describe('BlocksList', () => {
expect(component.filteredBlocks().map((block) => block.type)).toEqual(['LLMBlock', 'Storage']);
component.clearKinds();
expect(component.filteredBlocks().length).toBe(palette.length);
expect(component.filteredBlocks().length).toBe(BLOCK_TYPES.length + 1);
});
});

View File

@ -5,6 +5,7 @@
import { ChangeDetectionStrategy, Component, computed, inject, model, signal, Signal, WritableSignal } from '@angular/core';
import { BlockType } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { ResourcesService } from '@services/resources/resources';
import { ListStateViewHolder, OrderViewState } from '@utilities/list-state-holder';
import { MatCardModule } from '@angular/material/card';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -26,10 +27,10 @@ export type BlockKind = { icon: string; label: string; modifier: string };
* From the role its type declares, not only whether a person is involved: a storage is not a step
* at all, an end closes a path, a decision picks one branch - none of them is "an automated step".
*/
export function blockKind(block: Pick<BlockType, 'userInteractive' | 'capabilities'>): BlockKind {
export function blockKind(block: Pick<BlockType, 'userInteractive' | 'capabilities' | 'family'>): BlockKind {
const capabilities = block.capabilities;
const decides = capabilities?.visualRole === 'DECISION' || capabilities?.routesExclusively === true;
if (capabilities?.visualRole === 'RESOURCE') return { icon: 'storage', label: 'Resource', modifier: 'resource' };
if (block.family === 'resource') return { icon: 'storage', label: 'Resource', modifier: 'resource' };
if (block.userInteractive) {
return decides
? { icon: 'touch_app', label: 'Human decision', modifier: 'interactive' }
@ -56,6 +57,10 @@ export class BlocksList extends ListStateViewHolder<BlockType> {
readonly selectedKinds = signal<ReadonlySet<string>>(new Set());
private blocksService = inject(BlocksService);
private resourcesService = inject(ResourcesService);
/** The resource types, shown in the same palette: they are dropped on the canvas the same way. */
private readonly resourceTypes = signal<BlockType[]>([]);
loading: WritableSignal<boolean> = signal(true);
@ -87,6 +92,7 @@ export class BlocksList extends ListStateViewHolder<BlockType> {
}
ngOnInit() {
this.loadResourceTypes();
const existingState = this.view;
if (existingState.list) {
this.blockTypes = existingState.list;
@ -111,9 +117,27 @@ export class BlocksList extends ListStateViewHolder<BlockType> {
});
}
private loadResourceTypes() {
this.resourcesService.getAllResourceTypes()
.then((types) => this.resourceTypes.set(types().map((type) => ({
type: type.type,
family: 'resource' as const,
description: type.description,
userInteractive: false,
configurationType: type.configurationType,
configurationClass: type.configurationClass,
schema: type.schema
}))))
.catch((err) => console.error('Error loading resource types', err));
}
/** Blocks and resources together: both are dragged onto the canvas from here. */
private readonly paletteTypes = computed<BlockType[]>(() =>
[...(this.blockTypes ? this.blockTypes() ?? [] : []), ...this.resourceTypes()]);
/** The kinds of block the palette has, in a fixed order, each once: what can be filtered by. */
readonly availableKinds = computed<BlockKind[]>(() => {
const blocks = this.blockTypes ? this.blockTypes() : [];
const blocks = this.paletteTypes();
const byLabel = new Map<string, BlockKind>();
for (const block of blocks ?? []) {
const kind = blockKind(block);
@ -124,8 +148,7 @@ export class BlocksList extends ListStateViewHolder<BlockType> {
});
filteredBlocks = computed(() => {
const blocks = this.blockTypes ? this.blockTypes() : [];
if (!blocks) return [];
const blocks = this.paletteTypes();
const term = this.searchTerm().toLowerCase();
const kinds = this.selectedKinds();

View File

@ -8,8 +8,8 @@ import {
DEFAULT_LOOP_MAX_ITERATIONS,
FLOW_DEPENDANT_PORT_KEY,
FLOW_DEPENDENCY_PORT_KEY,
FLOW_STORAGE_LINK_PORT_KEY,
FLOW_STORAGE_PORT_KEY,
FLOW_RESOURCE_LINK_PORT_KEY,
FLOW_RESOURCE_PORT_KEY,
MAX_LOOP_MAX_ITERATIONS
} from '@models/flow';
import type { LoopAwareConnection } from '@utilities/rete-editor';
@ -201,9 +201,9 @@ export class CustomConnectionComponent {
@Input() end!: { x: number; y: number };
@Input() path = '';
/** A storage operation's link to the Storage node it works on: not a connection, orders nothing. */
get isStorageLink(): boolean {
return this.data?.sourceOutput === FLOW_STORAGE_PORT_KEY && this.data?.targetInput === FLOW_STORAGE_LINK_PORT_KEY;
/** A step's link to the resource it uses: not a connection, and it orders nothing. */
get isResourceLink(): boolean {
return this.data?.sourceOutput === FLOW_RESOURCE_PORT_KEY && this.data?.targetInput === FLOW_RESOURCE_LINK_PORT_KEY;
}
get isDependencyConnection(): boolean {
@ -285,19 +285,19 @@ export class CustomConnectionComponent {
if (this.isSelected) return '#f97316';
if (this.isBiasRoutingChange) return '#b45309';
if (this.isLoopBack) return '#7c3aed';
if (this.isStorageLink) return '#0f766e';
if (this.isResourceLink) return '#0f766e';
return this.isDependencyConnection ? '#7c8ba1' : '#4682b4';
}
get strokeWidth(): number {
if (this.isSelected) return this.isDependencyConnection || this.isStorageLink ? 3.25 : 6;
if (this.isSelected) return this.isDependencyConnection || this.isResourceLink ? 3.25 : 6;
if (this.isBiasRoutingChange) return 6;
return this.isDependencyConnection || this.isStorageLink ? 2.25 : 5;
return this.isDependencyConnection || this.isResourceLink ? 2.25 : 5;
}
get strokeDasharray(): string | null {
if (this.isLoopBack) return '12 7';
if (this.isStorageLink) return '2 6';
if (this.isResourceLink) return '2 6';
return this.isDependencyConnection ? '6 6' : null;
}

View File

@ -13,7 +13,7 @@ export class CustomSocket {
@Input() emit!: any;
@Input() rendered!: any;
private side: "input" | "output" = "input";
private kind: "data" | "dependency" | "storage" = "data";
private kind: "data" | "dependency" | "resource" = "data";
@HostBinding("title") get title() {
return this.data.name;
@ -34,7 +34,7 @@ export class CustomSocket {
@HostBinding("style.background")
get bg() {
if (this.kind === "storage") {
if (this.kind === "resource") {
return "linear-gradient(145deg, #2dd4bf 0%, #0f766e 100%)";
}
if (this.kind === "dependency") {
@ -49,7 +49,7 @@ export class CustomSocket {
@HostBinding("style.boxShadow")
get sh() {
if (this.kind === "storage") {
if (this.kind === "resource") {
return "0 2px 6px rgba(15,118,110,0.45), 0 0 0 1px rgba(15,118,110,0.45)";
}
if (this.kind === "dependency") {
@ -65,9 +65,9 @@ export class CustomSocket {
return side === "output" ? "output" : "input";
}
private resolveSocketKind(): "data" | "dependency" | "storage" {
private resolveSocketKind(): "data" | "dependency" | "resource" {
const kind = this.data?.__hfKind;
return kind === "dependency" || kind === "storage" ? kind : "data";
return kind === "dependency" || kind === "resource" ? kind : "data";
}
ngOnChanges(): void {

View File

@ -989,7 +989,7 @@
font-size: 12px;
}
/* A storage node: something the steps read and write, not a step - drawn apart from them. */
/* A resource: something the flow's steps use, not a step - drawn apart from them. */
.llm-node--resource {
border: 2px dashed #0f766e;
background: linear-gradient(180deg, #f8fffd 0%, #ecfdf5 100%);
@ -1000,7 +1000,7 @@
border-bottom-color: #99f6e4;
}
.llm-pill-storage {
.llm-pill-resource {
border-color: #5eead4;
background: #f0fdfa;
color: #0f766e;

View File

@ -11,7 +11,6 @@
[class.llm-node-error]="hasUpdateBlockError()"
[class.llm-node-delete-pending]="deleteConfirmOpen"
[class.llm-node--human]="isHumanNode()"
[class.llm-node--resource]="isResourceNode()"
[class.llm-node--focus]="focusOpen"
(pointerdown)="focusOpen && $event.stopPropagation()"
(click)="focusOpen && $event.stopPropagation()">
@ -53,12 +52,6 @@
}
</div>
<div class="llm-node-id">{{ nodeIdLabel }}</div>
@if (isResourceNode()) {
<div class="llm-lane-chip" title="Not a step: outputs connected to its targets are written there, its views are read by the steps they feed">
<i class="bi bi-database"></i>
<span>Resource</span>
</div>
}
@if (laneBadge; as lane) {
<div class="llm-lane-chip" [style.border-color]="lane.color || '#94a3b8'">
<i class="bi bi-diagram-3"></i>
@ -217,11 +210,11 @@
</div>
</div>
@if (storageLinkOutput || storageLinkInput) {
<div class="llm-body llm-body-dependency llm-body-storage">
@if (resourceLinkInput) {
<div class="llm-body llm-body-dependency llm-body-resource">
<div class="llm-column">
<div class="llm-column-title">Storage</div>
@if (storageLinkInput; as storageLinkInput) {
<div class="llm-column-title">Resource</div>
@if (resourceLinkInput; as resourceLinkInput) {
<div class="llm-row llm-row-input">
<div
refComponent
@ -229,39 +222,18 @@
[data]="{
type: 'socket',
side: 'input',
key: storageLinkInput.key,
key: resourceLinkInput.key,
nodeId: data.id,
__readonly: isReadonly,
payload: storageLinkInput.socket
payload: resourceLinkInput.socket
}"
[emit]="emit">
</div>
<span class="llm-pill llm-pill-input llm-pill-storage">Works on</span>
<span class="llm-pill llm-pill-input llm-pill-resource">Works on</span>
</div>
}
</div>
<div class="llm-column">
<div class="llm-column-title llm-column-title-right">&nbsp;</div>
@if (storageLinkOutput; as storageLinkOutput) {
<div class="llm-row llm-row-output">
<span class="llm-pill llm-pill-output llm-pill-storage">Used by</span>
<div
refComponent
class="llm-socket llm-socket-right"
[data]="{
type: 'socket',
side: 'output',
key: storageLinkOutput.key,
nodeId: data.id,
__readonly: isReadonly,
payload: storageLinkOutput.socket
}"
[emit]="emit">
</div>
</div>
}
</div>
</div>
}
@ -313,243 +285,7 @@
</div>
}
<div class="llm-params llm-params-shell">
@if (!schemaReady) {
<div class="llm-skeleton-overlay" aria-hidden="true">
<div class="llm-skeleton-stack">
<div class="llm-skeleton-line llm-skeleton-line-title"></div>
<div class="llm-skeleton-line llm-skeleton-line-short"></div>
<div class="llm-skeleton-grid">
<div class="llm-skeleton-pill"></div>
<div class="llm-skeleton-pill"></div>
<div class="llm-skeleton-pill llm-skeleton-pill-wide"></div>
</div>
</div>
</div>
}
@for (section of parameterDisplaySections; track section.key) {
@if (section.group; as group) {
<fieldset class="llm-param-fieldset">
<legend class="llm-param-legend">{{ group.legend }}</legend>
<div class="llm-param-grid">
@for (item of group.items; track item.path) {
@if (item.field; as field) {
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide" [class.llm-param-chip-boolean]="field.type === 'boolean'" [class.llm-param-chip-disabled]="!field.enabled">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
@if (field.type === 'boolean') {
<button
type="button"
class="llm-bool-toggle"
[class.llm-bool-toggle-on]="field.booleanValue"
[disabled]="isReadonly || !field.enabled"
[attr.aria-pressed]="field.booleanValue"
[attr.title]="'Toggle ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="toggleBooleanParameter(field.path, $event)">
<span class="llm-bool-toggle-thumb"></span>
</button>
} @else if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
[disabled]="!field.enabled"
[attr.title]="'Edit ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (field.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openFieldPreview(field, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
@if (field.type !== 'boolean') {
<span class="llm-param-value" [class.llm-param-value-clamped]="field.expandable">{{ field.value }}</span>
}
</div>
} @else {
@if (item.richContentField; as contentField) {
<div class="llm-param-block llm-param-chip llm-param-chip-wide">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ contentField.label }}</div>
@if (!isReadonly) {
<button type="button" class="llm-edit-btn" [attr.title]="'Edit ' + contentField.label.toLowerCase()" (pointerdown)="$event.stopPropagation()" (click)="openMainContentEditor(contentField.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (contentField.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openMainContentPreview(contentField, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<div class="llm-param-text" [class.llm-param-text-clamped]="contentField.expandable">
@if (!contentField.parts.length) {
-
} @else {
@for (part of contentField.parts; track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ formatDynamicInputToken(part.text) }}</span>
} @else {
<span>{{ part.text }}</span>
}
}
}
</div>
</div>
}
@if (item.optionalGroupField; as optionalGroup) {
<button
type="button"
class="llm-param-chip llm-param-chip-wide llm-optional-group"
[class.llm-param-chip-disabled]="!optionalGroup.enabled"
[disabled]="isReadonly || !optionalGroup.enabled"
[attr.title]="'Edit ' + optionalGroup.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="openOptionalGroupEditor(optionalGroup.path, $event)">
<span class="llm-param-key">{{ optionalGroup.label }}</span>
@if (optionalGroup.setCount > 0) {
<span class="llm-optional-group-count">{{ optionalGroup.setCount }} set</span>
}
<i class="bi bi-sliders llm-optional-group-icon"></i>
</button>
}
@if (item.arrayField; as arrayField) {
<app-node-list-field
class="llm-array-block llm-param-chip llm-param-chip-wide"
[label]="arrayField.label"
[items]="arrayField.items"
[readonly]="isReadonly"
(add)="addArrayItem(arrayField.path)"
(edit)="editArrayItem(arrayField.path, $event)"
(remove)="removeArrayItem(arrayField.path, $event)"
(view)="viewItemDefinition($any($event))" />
}
}
}
</div>
</fieldset>
} @else if (section.item; as item) {
<div class="llm-param-grid">
@if (item.field; as field) {
<div class="llm-param-chip llm-param-chip-root" [class.llm-param-chip-wide]="field.wide" [class.llm-param-chip-boolean]="field.type === 'boolean'" [class.llm-param-chip-disabled]="!field.enabled">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
@if (field.type === 'boolean') {
<button
type="button"
class="llm-bool-toggle"
[class.llm-bool-toggle-on]="field.booleanValue"
[disabled]="isReadonly || !field.enabled"
[attr.aria-pressed]="field.booleanValue"
[attr.title]="'Toggle ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="toggleBooleanParameter(field.path, $event)">
<span class="llm-bool-toggle-thumb"></span>
</button>
} @else if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
[disabled]="!field.enabled"
[attr.title]="'Edit ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (field.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openFieldPreview(field, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
@if (field.type !== 'boolean') {
<span class="llm-param-value" [class.llm-param-value-clamped]="field.expandable">{{ field.value }}</span>
}
</div>
} @else {
@if (item.richContentField; as contentField) {
<div class="llm-param-block llm-param-chip llm-param-chip-wide">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ contentField.label }}</div>
@if (!isReadonly) {
<button type="button" class="llm-edit-btn" [attr.title]="'Edit ' + contentField.label.toLowerCase()" (pointerdown)="$event.stopPropagation()" (click)="openMainContentEditor(contentField.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (contentField.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openMainContentPreview(contentField, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<div class="llm-param-text" [class.llm-param-text-clamped]="contentField.expandable">
@if (!contentField.parts.length) {
-
} @else {
@for (part of contentField.parts; track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ formatDynamicInputToken(part.text) }}</span>
} @else {
<span>{{ part.text }}</span>
}
}
}
</div>
</div>
}
@if (item.optionalGroupField; as optionalGroup) {
<button
type="button"
class="llm-param-chip llm-param-chip-wide llm-optional-group"
[class.llm-param-chip-disabled]="!optionalGroup.enabled"
[disabled]="isReadonly || !optionalGroup.enabled"
[attr.title]="'Edit ' + optionalGroup.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="openOptionalGroupEditor(optionalGroup.path, $event)">
<span class="llm-param-key">{{ optionalGroup.label }}</span>
@if (optionalGroup.setCount > 0) {
<span class="llm-optional-group-count">{{ optionalGroup.setCount }} set</span>
}
<i class="bi bi-sliders llm-optional-group-icon"></i>
</button>
}
@if (item.arrayField; as arrayField) {
<app-node-list-field
class="llm-array-block llm-param-chip llm-param-chip-wide"
[label]="arrayField.label"
[items]="arrayField.items"
[readonly]="isReadonly"
(add)="addArrayItem(arrayField.path)"
(edit)="editArrayItem(arrayField.path, $event)"
(remove)="removeArrayItem(arrayField.path, $event)"
(view)="viewItemDefinition($any($event))" />
}
}
</div>
}
}
</div>
<app-node-parameters [host]="this" />
@if (biasAnnotationsAllowed) {
<app-bias-annotations
@ -560,144 +296,5 @@
(annotationsChange)="updateBiasAnnotations($event)" />
}
@if (localEditorOpen) {
<dialog #simpleEditorDialog class="llm-modal-backdrop" (pointerdown)="$event.stopPropagation()" (click)="onSimpleEditorDialogClick($event)" (cancel)="closeSimpleParamEditor($event)">
<div class="llm-modal" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<div class="llm-modal-title">Edit {{ localEditorLabel }}</div>
@if (localEditorBindableAsInput && localEditorPath !== 'name') {
<div class="llm-modal-field">
<label>Source</label>
<select
[ngModel]="localEditorSourceMode()"
(pointerdown)="$event.stopPropagation()"
(ngModelChange)="onLocalEditorSourceModeChange($event)">
<option value="static">Static value</option>
<option value="input">Workflow input</option>
@if (canTakeGlobalInput()) {
<!--
Kept in place and disabled when the flow declares none, rather than hidden: the same
reasoning as the Use default button below, where an option that appears and disappears
is one nobody discovers. Selecting it used to lead to a mode that could not be completed.
-->
<option value="global" [disabled]="!localEditorGlobalInputs.length">
From global inputs{{ localEditorGlobalInputs.length ? '' : ' (none declared)' }}
</option>
}
</select>
@if (localEditorUseInput) {
<div class="llm-modal-tip">
This field will be provided by node input{{ localEditorBindableInputName ? (' "' + localEditorBindableInputName + '"') : '' }}.
</div>
}
</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>
}
<!--
Hidden in global mode: the value there is the ${{global.x}} the picker above writes, so a
second control for the same field could only contradict it - a model picker reading
"Select model..." next to a source that says the model comes from a global input.
-->
@if (!localEditorFromGlobal) {
<div class="llm-modal-field">
<label>{{ localEditorLabel }}</label>
<div class="llm-modal-control-row">
@if (localEditorUseInput && localEditorPath !== 'name') {
<input
type="text"
[ngModel]="localEditorBindableInputName ? ('Provided by input ' + localEditorBindableInputName) : 'Provided by workflow input'"
readonly
(pointerdown)="$event.stopPropagation()" />
} @else if (localEditorLoading) {
<input type="text" [ngModel]="localEditorValue" placeholder="Loading..." disabled (pointerdown)="$event.stopPropagation()" />
} @else if (localEditorType === 'boolean') {
<button
type="button"
class="node-toggle"
[class.node-toggle-on]="localEditorValue === 'true'"
[attr.aria-pressed]="localEditorValue === 'true'"
(pointerdown)="$event.stopPropagation()"
(click)="localEditorValue = localEditorValue === 'true' ? 'false' : 'true'">
<span class="node-toggle-track">
<span class="node-toggle-thumb"></span>
</span>
<span class="node-toggle-label">{{ localEditorValue === 'true' ? 'true' : 'false' }}</span>
</button>
} @else if (localEditorWidget === 'textarea') {
<textarea
[rows]="localEditorRows ?? 6"
[(ngModel)]="localEditorValue"
[attr.maxlength]="localEditorMaxLength"
[placeholder]="localEditorMaxLength ? 'Max ' + localEditorMaxLength + ' characters' : 'Value...'"
(pointerdown)="$event.stopPropagation()"></textarea>
} @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) {
<option [value]="option.value">{{ option.label }}</option>
}
</select>
} @else {
<input
[type]="localEditorType === 'number' || localEditorType === 'integer' ? 'number' : 'text'"
[(ngModel)]="localEditorValue"
[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>
}
<!-- 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>
<button type="button" class="llm-btn llm-btn-primary" [disabled]="!canSaveLocalEditor()" (pointerdown)="$event.stopPropagation()" (click)="saveSimpleParamEditor($event)">Save</button>
</div>
</div>
</dialog>
}
</div>

View File

@ -8,8 +8,9 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, ElementR
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { environment } from '@environment';
import { BiasAnnotation, BiasAnnotationsDescriptor, BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowData, FlowGlobalInput, FlowPort, FlowValueKind, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, FLOW_STORAGE_LINK_PORT_KEY, FLOW_STORAGE_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, FLOW_RESOURCE_LINK_PORT_KEY, FLOW_RESOURCE_PORT_KEY, isProbeExecutable, normalizeFlowPortValueKinds } from '@models/flow';
import { BiasAnnotationsComponent } from '../../bias-annotations/bias-annotations';
import { NodeParametersComponent } from '../node-parameters/node-parameters';
import { NodeFocusModalController } from '../node-focus-modal-controller';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
@ -73,7 +74,6 @@ import {
primitiveItemType,
type PrimitiveItemType
} from '../schema-driven-fields';
import { NodeListFieldComponent } from '../node-list-field/node-list-field';
type FieldType = SchemaFieldType;
@ -152,7 +152,7 @@ type RenderedSocketPort = {
@Component({
selector: 'app-generic-node',
imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule, BiasAnnotationsComponent, NodeListFieldComponent],
imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule, BiasAnnotationsComponent, NodeParametersComponent],
templateUrl: './generic-node.html',
styleUrl: './generic-node.css',
host: {
@ -245,7 +245,7 @@ export class GenericNodeComponent implements OnDestroy {
get outputs(): RenderedSocketPort[] {
return Object.entries(this.data?.outputs ?? {})
.filter(([key]) => key !== FLOW_DEPENDANT_PORT_KEY && key !== FLOW_STORAGE_PORT_KEY)
.filter(([key]) => key !== FLOW_DEPENDANT_PORT_KEY && key !== FLOW_RESOURCE_PORT_KEY)
.map(([key, output]) => ({
key,
socket: (output as any).socket as ClassicPreset.Socket
@ -254,23 +254,23 @@ export class GenericNodeComponent implements OnDestroy {
get inputs(): RenderedSocketPort[] {
return Object.entries(this.data?.inputs ?? {})
.filter(([key]) => key !== FLOW_DEPENDENCY_PORT_KEY && key !== FLOW_STORAGE_LINK_PORT_KEY)
.filter(([key]) => key !== FLOW_DEPENDENCY_PORT_KEY && key !== FLOW_RESOURCE_LINK_PORT_KEY)
.map(([key, input]) => ({
key,
socket: (input as any).socket as ClassicPreset.Socket
}));
}
/** A Storage node's handle, which storage operations are linked to. */
get storageLinkOutput(): { key: string; socket: ClassicPreset.Socket } | null {
const output = this.data?.outputs?.[FLOW_STORAGE_PORT_KEY];
return output ? { key: FLOW_STORAGE_PORT_KEY, socket: (output as any).socket as ClassicPreset.Socket } : null;
/** A resource's handle, which the steps that use it are linked to. */
get resourceLinkOutput(): { key: string; socket: ClassicPreset.Socket } | null {
const output = this.data?.outputs?.[FLOW_RESOURCE_PORT_KEY];
return output ? { key: FLOW_RESOURCE_PORT_KEY, socket: (output as any).socket as ClassicPreset.Socket } : null;
}
/** A storage operation's handle, linked to the Storage node it works on. */
get storageLinkInput(): { key: string; socket: ClassicPreset.Socket } | null {
const input = this.data?.inputs?.[FLOW_STORAGE_LINK_PORT_KEY];
return input ? { key: FLOW_STORAGE_LINK_PORT_KEY, socket: (input as any).socket as ClassicPreset.Socket } : null;
/** The handle of a step that uses a resource, linked to the resource it works on. */
get resourceLinkInput(): { key: string; socket: ClassicPreset.Socket } | null {
const input = this.data?.inputs?.[FLOW_RESOURCE_LINK_PORT_KEY];
return input ? { key: FLOW_RESOURCE_LINK_PORT_KEY, socket: (input as any).socket as ClassicPreset.Socket } : null;
}
get dependantOutput(): RenderedSocketPort | null {
@ -296,14 +296,6 @@ export class GenericNodeComponent implements OnDestroy {
private conditionalRequiredByPath = new Map<string, boolean>();
private refreshingConditionalRequirements = false;
/**
* This template renders inside a node card, which rete.js positions with a
* CSS `transform` for pan/zoom, so a plain fixed-position backdrop would be
* confined to the node's box. A native `<dialog>` shown via `showModal()`
* escapes that via the browser's top layer, same fix as bias-annotations.
*/
private readonly simpleEditorDialog = viewChild<ElementRef<HTMLDialogElement>>('simpleEditorDialog');
constructor() {
effect(() => {
const descriptorSignal = (this.blocksService as BlocksService & {
@ -315,18 +307,6 @@ export class GenericNodeComponent implements OnDestroy {
}
});
effect(() => {
const dialog = this.simpleEditorDialog()?.nativeElement;
if (dialog && typeof dialog.showModal === 'function' && !dialog.open) {
dialog.showModal();
}
});
}
onSimpleEditorDialogClick(event: MouseEvent) {
if (event.target === this.simpleEditorDialog()?.nativeElement) {
this.closeSimpleParamEditor(event);
}
}
ngOnInit() {
@ -626,9 +606,9 @@ export class GenericNodeComponent implements OnDestroy {
return isHumanInteractiveNode(this.blockDescriptor?.interactionContract);
}
/** A storage node: not a step, but what the steps connected to it read and write. */
/** A resource: not a step, but something the flow's steps use. */
isResourceNode(): boolean {
return this.blockDescriptor?.capabilities?.visualRole === 'RESOURCE';
return (this.data?.data as Record<string, unknown> | undefined)?.['nodeFamily'] === 'resource';
}
isConditionalNode(): boolean {

View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
* SPDX-License-Identifier: AGPL-3.0-or-later
* Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
*/
/*
* The node styles, whole: these fields were part of the generic node's template before they were
* a component of their own, and they are meant to look exactly as they did. Scoped per component
* by Angular, so importing the same sheet in both is not a conflict - only one source for it.
*/
@import '../generic-node/generic-node.css';

View File

@ -0,0 +1,383 @@
<!--
SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
SPDX-License-Identifier: AGPL-3.0-or-later
Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
-->
<div class="llm-params llm-params-shell">
@if (!host.schemaReady) {
<div class="llm-skeleton-overlay" aria-hidden="true">
<div class="llm-skeleton-stack">
<div class="llm-skeleton-line llm-skeleton-line-title"></div>
<div class="llm-skeleton-line llm-skeleton-line-short"></div>
<div class="llm-skeleton-grid">
<div class="llm-skeleton-pill"></div>
<div class="llm-skeleton-pill"></div>
<div class="llm-skeleton-pill llm-skeleton-pill-wide"></div>
</div>
</div>
</div>
}
@for (section of host.parameterDisplaySections; track section.key) {
@if (section.group; as group) {
<fieldset class="llm-param-fieldset">
<legend class="llm-param-legend">{{ group.legend }}</legend>
<div class="llm-param-grid">
@for (item of group.items; track item.path) {
@if (item.field; as field) {
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide" [class.llm-param-chip-boolean]="field.type === 'boolean'" [class.llm-param-chip-disabled]="!field.enabled">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
@if (field.type === 'boolean') {
<button
type="button"
class="llm-bool-toggle"
[class.llm-bool-toggle-on]="field.booleanValue"
[disabled]="host.isReadonly || !field.enabled"
[attr.aria-pressed]="field.booleanValue"
[attr.title]="'Toggle ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="host.toggleBooleanParameter(field.path, $event)">
<span class="llm-bool-toggle-thumb"></span>
</button>
} @else if (!host.isReadonly) {
<button
type="button"
class="llm-edit-btn"
[disabled]="!field.enabled"
[attr.title]="'Edit ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="host.openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (field.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="host.openFieldPreview(field, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
@if (field.type !== 'boolean') {
<span class="llm-param-value" [class.llm-param-value-clamped]="field.expandable">{{ field.value }}</span>
}
</div>
} @else {
@if (item.richContentField; as contentField) {
<div class="llm-param-block llm-param-chip llm-param-chip-wide">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ contentField.label }}</div>
@if (!host.isReadonly) {
<button type="button" class="llm-edit-btn" [attr.title]="'Edit ' + contentField.label.toLowerCase()" (pointerdown)="$event.stopPropagation()" (click)="host.openMainContentEditor(contentField.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (contentField.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="host.openMainContentPreview(contentField, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<div class="llm-param-text" [class.llm-param-text-clamped]="contentField.expandable">
@if (!contentField.parts.length) {
-
} @else {
@for (part of contentField.parts; track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ host.formatDynamicInputToken(part.text) }}</span>
} @else {
<span>{{ part.text }}</span>
}
}
}
</div>
</div>
}
@if (item.optionalGroupField; as optionalGroup) {
<button
type="button"
class="llm-param-chip llm-param-chip-wide llm-optional-group"
[class.llm-param-chip-disabled]="!optionalGroup.enabled"
[disabled]="host.isReadonly || !optionalGroup.enabled"
[attr.title]="'Edit ' + optionalGroup.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="host.openOptionalGroupEditor(optionalGroup.path, $event)">
<span class="llm-param-key">{{ optionalGroup.label }}</span>
@if (optionalGroup.setCount > 0) {
<span class="llm-optional-group-count">{{ optionalGroup.setCount }} set</span>
}
<i class="bi bi-sliders llm-optional-group-icon"></i>
</button>
}
@if (item.arrayField; as arrayField) {
<app-node-list-field
class="llm-array-block llm-param-chip llm-param-chip-wide"
[label]="arrayField.label"
[items]="arrayField.items"
[readonly]="host.isReadonly"
(add)="host.addArrayItem(arrayField.path)"
(edit)="host.editArrayItem(arrayField.path, $event)"
(remove)="host.removeArrayItem(arrayField.path, $event)"
(view)="host.viewItemDefinition($any($event))" />
}
}
}
</div>
</fieldset>
} @else if (section.item; as item) {
<div class="llm-param-grid">
@if (item.field; as field) {
<div class="llm-param-chip llm-param-chip-root" [class.llm-param-chip-wide]="field.wide" [class.llm-param-chip-boolean]="field.type === 'boolean'" [class.llm-param-chip-disabled]="!field.enabled">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
@if (field.type === 'boolean') {
<button
type="button"
class="llm-bool-toggle"
[class.llm-bool-toggle-on]="field.booleanValue"
[disabled]="host.isReadonly || !field.enabled"
[attr.aria-pressed]="field.booleanValue"
[attr.title]="'Toggle ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="host.toggleBooleanParameter(field.path, $event)">
<span class="llm-bool-toggle-thumb"></span>
</button>
} @else if (!host.isReadonly) {
<button
type="button"
class="llm-edit-btn"
[disabled]="!field.enabled"
[attr.title]="'Edit ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="host.openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (field.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="host.openFieldPreview(field, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
@if (field.type !== 'boolean') {
<span class="llm-param-value" [class.llm-param-value-clamped]="field.expandable">{{ field.value }}</span>
}
</div>
} @else {
@if (item.richContentField; as contentField) {
<div class="llm-param-block llm-param-chip llm-param-chip-wide">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ contentField.label }}</div>
@if (!host.isReadonly) {
<button type="button" class="llm-edit-btn" [attr.title]="'Edit ' + contentField.label.toLowerCase()" (pointerdown)="$event.stopPropagation()" (click)="host.openMainContentEditor(contentField.path, $event)">
<i class="bi bi-pen"></i>
</button>
} @else if (contentField.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="host.openMainContentPreview(contentField, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<div class="llm-param-text" [class.llm-param-text-clamped]="contentField.expandable">
@if (!contentField.parts.length) {
-
} @else {
@for (part of contentField.parts; track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ host.formatDynamicInputToken(part.text) }}</span>
} @else {
<span>{{ part.text }}</span>
}
}
}
</div>
</div>
}
@if (item.optionalGroupField; as optionalGroup) {
<button
type="button"
class="llm-param-chip llm-param-chip-wide llm-optional-group"
[class.llm-param-chip-disabled]="!optionalGroup.enabled"
[disabled]="host.isReadonly || !optionalGroup.enabled"
[attr.title]="'Edit ' + optionalGroup.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="host.openOptionalGroupEditor(optionalGroup.path, $event)">
<span class="llm-param-key">{{ optionalGroup.label }}</span>
@if (optionalGroup.setCount > 0) {
<span class="llm-optional-group-count">{{ optionalGroup.setCount }} set</span>
}
<i class="bi bi-sliders llm-optional-group-icon"></i>
</button>
}
@if (item.arrayField; as arrayField) {
<app-node-list-field
class="llm-array-block llm-param-chip llm-param-chip-wide"
[label]="arrayField.label"
[items]="arrayField.items"
[readonly]="host.isReadonly"
(add)="host.addArrayItem(arrayField.path)"
(edit)="host.editArrayItem(arrayField.path, $event)"
(remove)="host.removeArrayItem(arrayField.path, $event)"
(view)="host.viewItemDefinition($any($event))" />
}
}
</div>
}
}
</div>
@if (host.localEditorOpen) {
<dialog #simpleEditorDialog class="llm-modal-backdrop" (pointerdown)="$event.stopPropagation()" (click)="onSimpleEditorDialogClick($event)" (cancel)="host.closeSimpleParamEditor($event)">
<div class="llm-modal" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<div class="llm-modal-title">Edit {{ host.localEditorLabel }}</div>
@if (host.localEditorBindableAsInput && host.localEditorPath !== 'name') {
<div class="llm-modal-field">
<label>Source</label>
<select
[ngModel]="host.localEditorSourceMode()"
(pointerdown)="$event.stopPropagation()"
(ngModelChange)="host.onLocalEditorSourceModeChange($event)">
<option value="static">Static value</option>
<option value="input">Workflow input</option>
@if (host.canTakeGlobalInput()) {
<!--
Kept in place and disabled when the flow declares none, rather than hidden: the same
reasoning as the Use default button below, where an option that appears and disappears
is one nobody discovers. Selecting it used to lead to a mode that could not be completed.
-->
<option value="global" [disabled]="!host.localEditorGlobalInputs.length">
From global inputs{{ host.localEditorGlobalInputs.length ? '' : ' (none declared)' }}
</option>
}
</select>
@if (host.localEditorUseInput) {
<div class="llm-modal-tip">
This field will be provided by node input{{ host.localEditorBindableInputName ? (' "' + host.localEditorBindableInputName + '"') : '' }}.
</div>
}
</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 (host.localEditorFromGlobal) {
<div class="llm-modal-field">
<label>Global input</label>
<select
[ngModel]="host.localEditorGlobalName"
[disabled]="!host.localEditorGlobalInputs.length"
(pointerdown)="$event.stopPropagation()"
(ngModelChange)="host.onLocalEditorGlobalInputChange($event)">
<option value="">{{ host.localEditorGlobalInputs.length ? 'Select a global input...' : 'No global inputs available' }}</option>
@for (globalInput of host.localEditorGlobalInputs; track globalInput.name) {
<option [value]="globalInput.name">{{ host.globalInputLabel(globalInput) }}</option>
}
</select>
@if (!host.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>
}
<!--
Hidden in global mode: the value there is the ${{global.x}} the picker above writes, so a
second control for the same field could only contradict it - a model picker reading
"Select model..." next to a source that says the model comes from a global input.
-->
@if (!host.localEditorFromGlobal) {
<div class="llm-modal-field">
<label>{{ host.localEditorLabel }}</label>
<div class="llm-modal-control-row">
@if (host.localEditorUseInput && host.localEditorPath !== 'name') {
<input
type="text"
[ngModel]="host.localEditorBindableInputName ? ('Provided by input ' + host.localEditorBindableInputName) : 'Provided by workflow input'"
readonly
(pointerdown)="$event.stopPropagation()" />
} @else if (host.localEditorLoading) {
<input type="text" [ngModel]="host.localEditorValue" placeholder="Loading..." disabled (pointerdown)="$event.stopPropagation()" />
} @else if (host.localEditorType === 'boolean') {
<button
type="button"
class="node-toggle"
[class.node-toggle-on]="host.localEditorValue === 'true'"
[attr.aria-pressed]="host.localEditorValue === 'true'"
(pointerdown)="$event.stopPropagation()"
(click)="host.localEditorValue = host.localEditorValue === 'true' ? 'false' : 'true'">
<span class="node-toggle-track">
<span class="node-toggle-thumb"></span>
</span>
<span class="node-toggle-label">{{ host.localEditorValue === 'true' ? 'true' : 'false' }}</span>
</button>
} @else if (host.localEditorWidget === 'textarea') {
<textarea
[rows]="host.localEditorRows ?? 6"
[(ngModel)]="host.localEditorValue"
[attr.maxlength]="host.localEditorMaxLength"
[placeholder]="host.localEditorMaxLength ? 'Max ' + host.localEditorMaxLength + ' characters' : 'Value...'"
(pointerdown)="$event.stopPropagation()"></textarea>
} @else if (host.localEditorHasRetriever && !host.localEditorFreeText) {
<select [(ngModel)]="host.localEditorValue" (pointerdown)="$event.stopPropagation()">
<option value="">{{ host.localEditorOptions.length ? ('Select ' + (host.localEditorLabel | lowercase) + '...') : 'No options available' }}</option>
@for (option of host.localEditorOptions; track option.value) {
<option [value]="option.value">{{ option.label }}</option>
}
</select>
} @else {
<input
[type]="host.localEditorType === 'number' || host.localEditorType === 'integer' ? 'number' : 'text'"
[(ngModel)]="host.localEditorValue"
[attr.maxlength]="host.localEditorMaxLength"
[attr.min]="host.localEditorMin"
[attr.max]="host.localEditorMax"
[attr.step]="host.localEditorType === 'integer' ? 1 : (host.localEditorType === 'number' ? 0.1 : null)"
[placeholder]="host.editorPlaceholder()"
(pointerdown)="$event.stopPropagation()" />
}
@if (host.showLocalEditorDefault()) {
<button
type="button"
class="llm-btn llm-btn-ghost llm-modal-default-btn"
[disabled]="!host.canUseLocalEditorDefault()"
[attr.title]="host.localEditorUsesDefault() ? 'Already using the default' : 'Clear this field so the default applies'"
(pointerdown)="$event.stopPropagation()"
(click)="host.useLocalEditorDefault($event)">
Use default
</button>
}
</div>
@if (host.localEditorError(); as error) {
<small class="llm-modal-error">{{ error }}</small>
} @else if (host.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 (host.localEditorTip) {
<small class="llm-modal-tip">{{ host.localEditorTip }}</small>
}
</div>
}
<div class="llm-modal-actions">
<button type="button" class="llm-btn llm-btn-ghost" (pointerdown)="$event.stopPropagation()" (click)="host.closeSimpleParamEditor($event)">Cancel</button>
<button type="button" class="llm-btn llm-btn-primary" [disabled]="!host.canSaveLocalEditor()" (pointerdown)="$event.stopPropagation()" (click)="host.saveSimpleParamEditor($event)">Save</button>
</div>
</div>
</dialog>
}

View File

@ -0,0 +1,103 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { CommonModule } from '@angular/common';
import { Component, effect, ElementRef, Input, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { NodeListFieldComponent } from '../node-list-field/node-list-field';
/**
* What a node shows of its configuration, built from its schema: the same fields whatever kind of
* node it is, so a block and a resource are edited the same way while they are drawn differently.
*
* <p>The node itself stays the host: it holds the schema, the values and the editors, and this
* only draws them. Hence the single `host` input rather than an input and an output per field.
*/
export type NodeParametersHost = {
parameterDisplaySections: readonly any[];
schemaReady: boolean;
readonly isReadonly: boolean;
formatDynamicInputToken(text: string): string;
openParameterEditor(path: string, event: Event): void;
openFieldPreview(field: any, event: Event): void;
openMainContentEditor(path: string, event: Event): void;
openMainContentPreview(field: any, event: Event): void;
openOptionalGroupEditor(path: string, event: Event): void;
toggleBooleanParameter(path: string, event: Event): void;
addArrayItem(path: string): void;
editArrayItem(path: string, index: number): void;
removeArrayItem(path: string, index: number): void;
viewItemDefinition(value: unknown): void;
/** The one-field editor, opened from a field and rendered here as a modal. */
localEditorOpen: boolean;
localEditorLabel: string;
localEditorPath: string | null;
localEditorType: string | null;
localEditorValue: any;
localEditorOptions: readonly any[];
localEditorRows: number | null;
localEditorMin: number | null;
localEditorMax: number | null;
localEditorMaxLength: number | null;
localEditorTip: string | null;
localEditorWidget: string | null;
localEditorFreeText: boolean;
localEditorHasRetriever: boolean;
localEditorLoading: boolean;
localEditorDefaultHint(): string | null;
localEditorUseInput: boolean;
localEditorBindableAsInput: boolean;
localEditorBindableInputName: string | null;
localEditorFromGlobal: boolean;
localEditorGlobalName: any;
localEditorGlobalInputs: readonly any[];
localEditorError(): string | null;
localEditorSourceMode(): any;
localEditorUsesDefault(): boolean;
showLocalEditorDefault(): boolean;
canTakeGlobalInput(): boolean;
canSaveLocalEditor(): boolean;
canUseLocalEditorDefault(): boolean;
editorPlaceholder(): string;
globalInputLabel(globalInput: any): string;
onLocalEditorGlobalInputChange(value: any): void;
onLocalEditorSourceModeChange(value: any): void;
useLocalEditorDefault(event: Event): void;
saveSimpleParamEditor(event: Event): void;
closeSimpleParamEditor(event: Event): void;
};
@Component({
selector: 'app-node-parameters',
imports: [CommonModule, FormsModule, NodeListFieldComponent],
templateUrl: './node-parameters.html',
styleUrl: './node-parameters.css'
// Default change detection on purpose: the host holds the values and mutates them in place, so
// there is no input to change for OnPush to notice. Checked whenever the node's own view is.
})
export class NodeParametersComponent {
@Input({ required: true }) host!: NodeParametersHost;
/**
* This renders inside a node card, which rete.js positions with a CSS `transform` for pan and
* zoom, so a plain fixed-position backdrop would be confined to the node's box. A native
* `<dialog>` shown with `showModal()` escapes that through the browser's top layer.
*/
private readonly simpleEditorDialog = viewChild<ElementRef<HTMLDialogElement>>('simpleEditorDialog');
constructor() {
effect(() => {
const dialog = this.simpleEditorDialog()?.nativeElement;
if (dialog && typeof dialog.showModal === 'function' && !dialog.open) {
dialog.showModal();
}
});
}
onSimpleEditorDialogClick(event: MouseEvent) {
if (event.target === this.simpleEditorDialog()?.nativeElement) {
this.host.closeSimpleParamEditor(event);
}
}
}

View File

@ -0,0 +1,12 @@
/*
* SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
* SPDX-License-Identifier: AGPL-3.0-or-later
* Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
*/
/* The node styles, whole: a resource is drawn with the same pieces, in its own colours. */
@import '../generic-node/generic-node.css';
.llm-node--resource {
min-width: 260px;
}

View File

@ -0,0 +1,125 @@
<!--
SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
SPDX-License-Identifier: AGPL-3.0-or-later
Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
-->
@if (focusOpen) {
<div class="llm-node__focus-backdrop" (pointerdown)="$event.stopPropagation()" (click)="closeFocus($event)"></div>
}
<div class="llm-node llm-node--resource"
[class.llm-node-error]="hasUpdateBlockError()"
[class.llm-node-delete-pending]="deleteConfirmOpen"
[class.llm-node--focus]="focusOpen"
(pointerdown)="focusOpen && $event.stopPropagation()"
(click)="focusOpen && $event.stopPropagation()">
@if (isSchemaLoading) {
<div class="llm-loading-overlay">
<div class="llm-loading-chip">
<span class="llm-spinner" aria-hidden="true"></span>
<span>Loading resource...</span>
</div>
</div>
}
@if (deleteConfirmOpen) {
<div class="llm-delete-overlay"></div>
}
<div class="llm-header" [class.cursor-move]="!isReadonly">
<div class="llm-icon">
<i class="bi bi-hdd-stack"></i>
</div>
<div class="llm-header-content">
<span class="llm-title">{{ resourceTitle() }}</span>
<div class="llm-subtitle-row">
<span class="llm-subtitle">{{ name }}</span>
@if (!isReadonly) {
<button type="button" class="llm-name-edit-btn" title="Edit name" (pointerdown)="$event.stopPropagation()" (click)="openNameEditor($event)">
<i class="bi bi-pencil-square"></i>
</button>
}
</div>
<div class="llm-node-id">{{ nodeIdLabel }}</div>
<div class="llm-lane-chip" title="Not a step: it never runs. Link the steps that use it to its handle.">
<i class="bi bi-database"></i>
<span>Resource</span>
</div>
</div>
<div class="llm-header-actions">
@if (hasUpdateBlockError()) {
<div class="llm-error-alert-wrap">
<div class="llm-error-alert">
<i class="bi bi-exclamation-circle-fill"></i>
</div>
<div class="llm-error-tooltip">
<div class="llm-error-title">Resource update failed</div>
<div class="llm-error-item">{{ updateBlockErrorMessage() }}</div>
</div>
</div>
}
@if (missingRequiredParams.length) {
<div class="llm-warning-wrap">
<div class="llm-warning">
<i class="bi bi-exclamation-triangle-fill"></i>
</div>
<div class="llm-warning-tooltip">
<div class="llm-warning-title">Missing required fields</div>
@for (missing of missingRequiredParams; track missing) {
<div class="llm-warning-item">{{ missing }}</div>
}
</div>
</div>
}
@if (!isReadonly && deleteConfirmOpen) {
<div class="llm-delete-confirm" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<span class="llm-delete-confirm-text">Delete resource?</span>
<button type="button" class="llm-delete-confirm-cancel" (click)="cancelDelete($event)">Cancel</button>
<button type="button" class="llm-delete-confirm-action" (click)="confirmDelete($event)">Delete</button>
</div>
}
@if (!isReadonly) {
<button
type="button"
class="llm-clone-btn"
[matTooltip]="focusOpen ? 'Close expanded resource' : 'Expand resource'"
(pointerdown)="$event.stopPropagation()"
(click)="toggleFocus($event)">
<i class="bi" [class.bi-fullscreen]="!focusOpen" [class.bi-fullscreen-exit]="focusOpen"></i>
</button>
@if (!focusOpen) {
<button type="button" class="llm-delete-btn" matTooltip="Delete resource" (pointerdown)="$event.stopPropagation()" (click)="confirmDelete($event)">
<i class="bi bi-trash"></i>
</button>
}
}
</div>
</div>
@if (resourceLinkOutput; as resourceLinkOutput) {
<div class="llm-body llm-body-dependency llm-body-resource">
<div class="llm-column">
<div class="llm-column-title">&nbsp;</div>
</div>
<div class="llm-column">
<div class="llm-column-title llm-column-title-right">Used by</div>
<div class="llm-row llm-row-output">
<span class="llm-pill llm-pill-output llm-pill-resource">Steps</span>
<div
refComponent
class="llm-socket llm-socket-right"
[data]="{
type: 'socket',
side: 'output',
key: resourceLinkOutput.key,
nodeId: data.id,
__readonly: isReadonly,
payload: resourceLinkOutput.socket
}"
[emit]="emit">
</div>
</div>
</div>
</div>
}
<app-node-parameters [host]="this" />
</div>

View File

@ -0,0 +1,36 @@
// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii <lucio.lelii@isti.cnr.it> - ISTI-CNR
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ReteModule } from 'rete-angular-plugin/21';
import { GenericNodeComponent } from '../generic-node/generic-node';
import { NodeParametersComponent } from '../node-parameters/node-parameters';
/**
* A resource on the canvas: a storage, for now.
*
* <p>Drawn unlike a step, because it is not one - no ports, no dependencies, one handle for the
* steps that use it to link to. Its configuration is edited exactly as a block's, which is why it
* keeps the generic node's machinery and changes only what is drawn.
*/
@Component({
selector: 'app-resource-node',
imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule, NodeParametersComponent],
templateUrl: './resource-node.html',
styleUrl: './resource-node.css',
host: {
'data-testid': 'node'
},
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ResourceNodeComponent extends GenericNodeComponent {
/** The type of resource, as the palette names it: what a block's title would say. */
resourceTitle(): string {
return String(this.data?.data?.typeName ?? 'Resource');
}
}

View File

@ -842,7 +842,7 @@ button.llm-node-bias-summary:focus-visible {
border-bottom-color: #99f6e4;
}
.llm-pill-storage {
.llm-pill-resource {
border-color: #5eead4;
background: #f0fdfa;
color: #0f766e;

View File

@ -268,7 +268,7 @@
}"
[emit]="emit">
</div>
<span class="llm-pill llm-pill-input llm-pill-storage">Works on</span>
<span class="llm-pill llm-pill-input llm-pill-resource">Works on</span>
</div>
}
</div>
@ -277,7 +277,7 @@
<div class="llm-column-title llm-column-title-right">&nbsp;</div>
@if (storageLinkOutput; as storageLinkOutput) {
<div class="llm-row llm-row-output">
<span class="llm-pill llm-pill-output llm-pill-storage">Used by</span>
<span class="llm-pill llm-pill-output llm-pill-resource">Used by</span>
<div
refComponent
class="llm-socket llm-socket-right"

View File

@ -7,7 +7,7 @@ import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, HostBinding, Input, inject, viewChild } from '@angular/core';
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import { BiasAnnotation, BlockInteractionContract, BlockType, DEFAULT_NODE_CAPABILITIES, FlowData, FlowNode, FlowPort, isProbeExecutable, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, FLOW_STORAGE_LINK_PORT_KEY, FLOW_STORAGE_PORT_KEY, NodeTypeCapabilities } from '@models/flow';
import { BiasAnnotation, BlockInteractionContract, BlockType, DEFAULT_NODE_CAPABILITIES, FlowData, FlowNode, FlowPort, isProbeExecutable, FLOW_DEPENDANT_PORT_KEY, FLOW_DEPENDENCY_PORT_KEY, FLOW_RESOURCE_LINK_PORT_KEY, FLOW_RESOURCE_PORT_KEY, NodeTypeCapabilities } from '@models/flow';
import { BiasCapabilities } from '@models/bias-impact';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
@ -152,26 +152,26 @@ export class TaskStepNodeComponent {
get outputs(): { key: string; socket: ClassicPreset.Socket }[] {
return Object.entries(this.data?.outputs ?? {})
.filter(([key]) => key !== FLOW_DEPENDANT_PORT_KEY && key !== FLOW_STORAGE_PORT_KEY)
.filter(([key]) => key !== FLOW_DEPENDANT_PORT_KEY && key !== FLOW_RESOURCE_PORT_KEY)
.map(([key, output]) => ({ key, socket: (output as any).socket as ClassicPreset.Socket }));
}
get inputs(): { key: string; socket: ClassicPreset.Socket }[] {
return Object.entries(this.data?.inputs ?? {})
.filter(([key]) => key !== FLOW_DEPENDENCY_PORT_KEY && key !== FLOW_STORAGE_LINK_PORT_KEY)
.filter(([key]) => key !== FLOW_DEPENDENCY_PORT_KEY && key !== FLOW_RESOURCE_LINK_PORT_KEY)
.map(([key, input]) => ({ key, socket: (input as any).socket as ClassicPreset.Socket }));
}
/** A Storage node's handle, which storage operations are linked to. */
get storageLinkOutput(): { key: string; socket: ClassicPreset.Socket } | null {
const output = this.data?.outputs?.[FLOW_STORAGE_PORT_KEY];
return output ? { key: FLOW_STORAGE_PORT_KEY, socket: (output as any).socket as ClassicPreset.Socket } : null;
const output = this.data?.outputs?.[FLOW_RESOURCE_PORT_KEY];
return output ? { key: FLOW_RESOURCE_PORT_KEY, socket: (output as any).socket as ClassicPreset.Socket } : null;
}
/** A storage operation's handle, linked to the Storage node it works on. */
get storageLinkInput(): { key: string; socket: ClassicPreset.Socket } | null {
const input = this.data?.inputs?.[FLOW_STORAGE_LINK_PORT_KEY];
return input ? { key: FLOW_STORAGE_LINK_PORT_KEY, socket: (input as any).socket as ClassicPreset.Socket } : null;
const input = this.data?.inputs?.[FLOW_RESOURCE_LINK_PORT_KEY];
return input ? { key: FLOW_RESOURCE_LINK_PORT_KEY, socket: (input as any).socket as ClassicPreset.Socket } : null;
}
get dependantOutput(): { key: string; socket: ClassicPreset.Socket } | null {
@ -521,9 +521,9 @@ export class TaskStepNodeComponent {
?? DEFAULT_NODE_CAPABILITIES;
}
/** A storage node: it has no step and no status of its own, only the steps on its edges. */
/** A resource: it is not a step, so it has no status of its own - only the steps using it do. */
isResourceNode(): boolean {
return this.typeCapabilities().visualRole === 'RESOURCE' || this.blockConfiguration?.['__resourceNode'] === true;
return this.blockConfiguration?.['__resourceNode'] === true;
}
isBiasCapable(): boolean {
@ -531,6 +531,7 @@ export class TaskStepNodeComponent {
}
visualRoleLabel(): string {
if (this.isResourceNode()) return 'Resource';
const role = this.typeCapabilities().visualRole.toLowerCase();
return role.charAt(0).toUpperCase() + role.slice(1);
}

View File

@ -7,6 +7,7 @@ import { BlockType, FlowData, FlowLane, FlowNode } from '@models/flow';
import { Drag } from 'rete-area-plugin';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { ResourcesService } from '@services/resources/resources';
import { BLOCK_TYPE_DRAG_MIME } from '@shared/blocks-list/block-drag';
import { GraphSelectionService } from '@services/graph-selection/graph-selection';
import { EditorStateHolder } from '@stores/flow-editor';
@ -34,6 +35,7 @@ export class ReteEditor implements OnChanges, OnDestroy {
private flowState: EditorStateHolder,
private blocksService: BlocksService,
private containersService: ContainersService,
private resourcesService: ResourcesService,
private graphSelection: GraphSelectionService
) {
effect(() => {
@ -154,7 +156,8 @@ export class ReteEditor implements OnChanges, OnDestroy {
if (!payload || !this.rete) return;
const blockType: BlockType = JSON.parse(payload);
if (this.flowState.isEditingSubflow() && blockType.family === 'container') return;
// A resource belongs in the top-level flow, and a container cannot hold another one.
if (this.flowState.isEditingSubflow() && blockType.family !== 'block') return;
const position = this.getDropPosition(event);
let newBlock: FlowNode;
this.creatingEmptyBlock = true;
@ -162,9 +165,11 @@ export class ReteEditor implements OnChanges, OnDestroy {
try {
newBlock = blockType.family === 'container'
? await firstValueFrom(this.containersService.createEmptyContainer(blockType.type))
: await firstValueFrom(this.blocksService.createEmptyBlock(blockType.type, {
flowId: this.flowId()
}));
: blockType.family === 'resource'
? await firstValueFrom(this.resourcesService.createEmptyResource(blockType.type)) as unknown as FlowNode
: await firstValueFrom(this.blocksService.createEmptyBlock(blockType.type, {
flowId: this.flowId()
}));
} catch (error) {
console.error('Failed to create empty block', error);
return;

View File

@ -597,13 +597,18 @@ export class TaskExecutionViewerComponent implements OnDestroy {
}
}
// A storage node never has a step, so it is drawn from the flow: its edges are the reads and
// writes of the steps at their other end, and they would otherwise point at nothing.
// A resource never has a step, so it is drawn from the flow rather than from the execution;
// the steps linked to it would otherwise be linked to nothing.
const resourceNodes: FlowNode[] = (sourceFlow?.resources ?? []).map((resource) => ({
...resource,
inputs: [],
outputs: []
} as unknown as FlowNode));
for (const sourceNode of useSourceGraphFallback
? [...(sourceFlow?.blocks ?? []), ...(sourceFlow?.containers ?? [])]
: (sourceFlow?.blocks ?? []).filter((block) => isStorageNodeBlock(block))) {
? [...(sourceFlow?.blocks ?? []), ...(sourceFlow?.containers ?? []), ...resourceNodes]
: resourceNodes) {
if (renderedNodeIds.has(sourceNode.id)) continue;
const resource = isStorageNodeBlock(sourceNode);
const resource = (sourceNode as { nodeFamily?: string }).nodeFamily === 'resource';
const connectedInputs = (sourceFlow?.connections ?? [])
.filter((connection) => connection.targetId === sourceNode.id)
@ -1912,8 +1917,4 @@ export class TaskExecutionViewerComponent implements OnDestroy {
}
/** The server's storage node: a resource of the flow, with no step of its own. */
function isStorageNodeBlock(node: FlowNode): boolean {
return (node as { typeName?: string }).typeName === 'Storage'
|| (node.specificConfiguration as { type?: string } | undefined)?.type === 'StorageNodeConfiguration';
}

View File

@ -2,13 +2,13 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { DEFAULT_NODE_CAPABILITIES } from '@models/flow';
import { DEFAULT_NODE_CAPABILITIES, RESOURCE_NODE_CAPABILITIES } from '@models/flow';
import { HFNode } from '@models/nodes';
import { vi } from 'vitest';
import { isResourceNode, resolveNodeCapabilities, ReteRuntimeContext } from './rete-editor';
function makeNode(typeName: string, nodeFamily: 'block' | 'container'): HFNode {
function makeNode(typeName: string, nodeFamily: 'block' | 'container' | 'resource'): HFNode {
return { data: { typeName, nodeFamily } } as unknown as HFNode;
}
@ -16,6 +16,7 @@ function makeRuntime(overrides: Partial<ReteRuntimeContext> = {}): ReteRuntimeCo
return {
blocksService: { peekBlockType: vi.fn().mockReturnValue(null) } as any,
containersService: { peekContainerType: vi.fn().mockReturnValue(null) } as any,
resourcesService: { peekResourceType: vi.fn().mockReturnValue(null) } as any,
flowState: {} as any,
readonly: false,
globalInputs: [],
@ -72,45 +73,55 @@ describe('resolveNodeCapabilities', () => {
});
describe('isResourceNode', () => {
it('is true for a storage node, whose edges are reads and writes rather than a path between steps', () => {
const peekBlockType = vi.fn((type: string) => type === 'Storage'
? { type, capabilities: { ...DEFAULT_NODE_CAPABILITIES, visualRole: 'RESOURCE' as const, canDependOnOtherNodes: false, canHaveDependentNodes: false } }
: { type, capabilities: DEFAULT_NODE_CAPABILITIES });
const runtime = makeRuntime({ blocksService: { peekBlockType } as any });
it('is what the node is, not what its type can do: a resource is never among the flow\'s blocks', () => {
const runtime = makeRuntime();
expect(isResourceNode(runtime, makeNode('Storage', 'block'))).toBe(true);
expect(isResourceNode(runtime, makeNode('Storage', 'resource'))).toBe(true);
expect(isResourceNode(runtime, makeNode('LLMBlock', 'block'))).toBe(false);
expect(isResourceNode(undefined, makeNode('Storage', 'block'))).toBe(false);
expect(isResourceNode(undefined, makeNode('Storage', 'resource'))).toBe(true);
});
it('gives a resource the capabilities of one: no connections, no dependencies', () => {
expect(resolveNodeCapabilities(makeRuntime(), makeNode('Storage', 'resource')))
.toEqual(RESOURCE_NODE_CAPABILITIES);
});
});
describe('the link between a Storage node and a storage operation', () => {
it('is what says which node the operation works on, and nothing when it is gone', async () => {
describe('the link between a resource and a step that uses it', () => {
it('is what fills the step\'s resource field, and empties it when the link is gone', async () => {
const { NodeEditor, ClassicPreset } = await import('rete');
const { exportGraph } = await import('./rete-editor');
const { FLOW_STORAGE_LINK_PORT_KEY, FLOW_STORAGE_PORT_KEY } = await import('@models/flow');
const { FLOW_RESOURCE_LINK_PORT_KEY, FLOW_RESOURCE_PORT_KEY } = await import('@models/flow');
const editor = new NodeEditor<any>();
const socket = new ClassicPreset.Socket('__FLOW_STORAGE__');
const socket = new ClassicPreset.Socket('__FLOW_RESOURCE__');
const storage = new ClassicPreset.Node('Storage') as any;
storage.data = { id: 'store-1', typeName: 'Storage', nodeFamily: 'block', specificConfiguration: { type: 'StorageNodeConfiguration' } };
storage.addOutput(FLOW_STORAGE_PORT_KEY, new ClassicPreset.Output(socket));
storage.data = { id: 'store-1', name: 'notes', typeName: 'Storage', nodeFamily: 'resource',
specificConfiguration: { type: 'StorageResourceConfiguration', name: 'notes' } };
storage.addOutput(FLOW_RESOURCE_PORT_KEY, new ClassicPreset.Output(socket));
const operation = new ClassicPreset.Node('StorageOperation') as any;
operation.data = { id: 'op-1', typeName: 'StorageOperation', nodeFamily: 'block',
__resourceRefField: 'storageNode',
specificConfiguration: { type: 'StorageOperationBlockConfiguration', storageNode: 'stale' } };
operation.addInput(FLOW_STORAGE_LINK_PORT_KEY, new ClassicPreset.Input(socket, undefined, true));
operation.addInput(FLOW_RESOURCE_LINK_PORT_KEY, new ClassicPreset.Input(socket, undefined, true));
await editor.addNode(storage);
await editor.addNode(operation);
const link = new ClassicPreset.Connection(storage, FLOW_STORAGE_PORT_KEY, operation, FLOW_STORAGE_LINK_PORT_KEY);
const link = new ClassicPreset.Connection(storage, FLOW_RESOURCE_PORT_KEY, operation, FLOW_RESOURCE_LINK_PORT_KEY);
await editor.addConnection(link);
let graph = exportGraph(editor);
expect((graph.blocks.find((block) => block.id === 'op-1')?.specificConfiguration as Record<string, unknown> | undefined)?.['storageNode']).toBe('store-1');
const operationOf = (g: ReturnType<typeof exportGraph>) =>
g.blocks.find((block) => block.id === 'op-1')?.specificConfiguration as Record<string, unknown> | undefined;
expect(graph.blocks.map((block) => block.id)).toEqual(['op-1']);
expect(graph.resources.map((resource) => resource.id)).toEqual(['store-1']);
expect(graph.resources[0].typeName).toBe('Storage');
expect(operationOf(graph)?.['storageNode']).toBe('store-1');
expect(graph.connections).toEqual([]);
expect(graph.dependencies).toEqual([]);
await editor.removeConnection(link.id);
graph = exportGraph(editor);
expect((graph.blocks.find((block) => block.id === 'op-1')?.specificConfiguration as Record<string, unknown> | undefined)?.['storageNode']).toBeNull();
expect(operationOf(graph)?.['storageNode']).toBeNull();
});
});

View File

@ -22,19 +22,23 @@ import {
FLOW_DEPENDANT_PORT_KEY,
FLOW_DEPENDENCY_PORT_KEY,
FLOW_DEPENDENCY_SOCKET_TYPE,
FLOW_STORAGE_LINK_PORT_KEY,
FLOW_STORAGE_PORT_KEY,
FLOW_STORAGE_SOCKET_TYPE,
isStorageOperationNode,
FLOW_RESOURCE_LINK_PORT_KEY,
FLOW_RESOURCE_PORT_KEY,
FLOW_RESOURCE_SOCKET_TYPE,
FlowNode,
FlowResource,
NodeTypeCapabilities,
normalizeFlowPortValueKinds
normalizeFlowPortValueKinds,
RESOURCE_NODE_CAPABILITIES,
resourceRefField
} from "@models/flow";
import { BlocksService } from "@services/blocks/blocks";
import { ContainersService } from "@services/containers/containers";
import { ResourcesService } from "@services/resources/resources";
import { EditorStateHolder } from "@stores/flow-editor";
import { ContainerNodeComponent } from "@shared/nodes/container-node/container-node";
import { GenericNodeComponent } from "@shared/nodes/generic-node/generic-node";
import { ResourceNodeComponent } from "@shared/nodes/resource-node/resource-node";
import { TaskStepNodeComponent } from "@shared/nodes/task-step-node/task-step-node";
import { CustomSocket } from "@shared/custom-socket/custom-socket";
import { CustomConnectionComponent } from "@shared/custom-connection/custom-connection";
@ -57,11 +61,12 @@ export type ReteEditorInstance = {
area: AreaPlugin<HFSchemes, AreaExtra>;
};
type GraphConnectionKind = "data" | "dependency" | "storage";
type GraphConnectionKind = "data" | "dependency" | "resource";
export type ReteRuntimeContext = {
blocksService: BlocksService;
containersService: ContainersService;
resourcesService: ResourcesService;
flowState: EditorStateHolder;
readonly: boolean;
globalInputs: FlowGlobalInput[];
@ -108,6 +113,7 @@ export async function createEditor(
const runtime: ReteRuntimeContext = {
blocksService: injector.get(BlocksService),
containersService: injector.get(ContainersService),
resourcesService: injector.get(ResourcesService),
flowState: injector.get(EditorStateHolder),
readonly,
globalInputs: cloneValue(flowData.globalInputs ?? []),
@ -121,7 +127,9 @@ export async function createEditor(
node(context: any) {
if (nodeView === "execution") return TaskStepNodeComponent;
const nodeFamily = context?.payload?.data?.nodeFamily;
return nodeFamily === "container" ? ContainerNodeComponent : GenericNodeComponent;
if (nodeFamily === "container") return ContainerNodeComponent;
if (nodeFamily === "resource") return ResourceNodeComponent;
return GenericNodeComponent;
},
connection() {
return CustomConnectionComponent;
@ -149,11 +157,13 @@ export async function createEditor(
const targetCapabilities = resolveNodeCapabilities(runtime, targetNode);
const connectionKind = getGraphConnectionKind(context.data.sourceOutput, context.data.targetInput);
if (connectionKind === "storage") {
// From a Storage node to a storage operation, and one node per operation: a new link
// replaces the operation's old one, the way a data input keeps to one connection.
if (connectionKind === "resource") {
// From a resource to a step that uses one, of the kind that step's field asks for. A step
// uses one resource, so a new link replaces the one it had, as a data input keeps to one.
if (!isResourceNode(runtime, sourceNode)) return;
if (!runtime.restoringConnections) await replaceStorageLink(editor, context.data.target);
const reference = resourceRefOf(runtime, targetNode);
if (!reference || reference.resourceType !== sourceNode?.data?.typeName) return;
if (!runtime.restoringConnections) await replaceResourceLink(editor, context.data.target);
return context;
}
if (connectionKind === "dependency") {
@ -249,7 +259,25 @@ export async function createEditor(
export function exportGraph(editor: NodeEditor<HFSchemes>) {
const runtime = editorRuntime.get(editor);
const nodeIdToBlockId = new Map<string, string>();
const nodes: FlowNode[] = editor.getNodes().map((node) => {
const resources: FlowResource[] = [];
/** The configuration field each node links its resource through, kept when it was added. */
const resourceRefFields = new Map<string, string>();
const nodes: FlowNode[] = editor.getNodes().filter((node) => {
const blockData = node.data as unknown as (Record<string, any> | undefined);
if (blockData?.['nodeFamily'] !== 'resource') return true;
const resourceId = String(blockData?.['id'] ?? node.id);
nodeIdToBlockId.set(node.id, resourceId);
resources.push({
id: resourceId,
name: String(blockData?.['name'] ?? node.label),
position: blockData?.['position'],
specificConfiguration: cloneValue(blockData?.['specificConfiguration'] ?? {}),
typeName: String(blockData?.['typeName'] ?? 'Storage'),
nodeFamily: 'resource',
laneId: typeof blockData?.['laneId'] === 'string' ? blockData['laneId'] : null
});
return false;
}).map((node) => {
const blockData = node.data;
const blockRecord = blockData as unknown as Record<string, unknown> | undefined;
const blockId = blockData?.id ?? node.id;
@ -258,6 +286,10 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
const inputs = cloneValue(blockData?.inputs ?? []);
const outputs = cloneValue(blockData?.outputs ?? []);
if (typeof blockRecord?.['__resourceRefField'] === 'string') {
resourceRefFields.set(blockId, String(blockRecord['__resourceRefField']));
}
const biasAnnotationsProperty = typeof blockRecord?.['__biasAnnotationsProperty'] === 'string'
? String(blockRecord['__biasAnnotationsProperty'])
: 'biasAnnotations';
@ -292,22 +324,24 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
};
});
// A storage operation says which Storage node it works on in its configuration; the link on the
// canvas is how that is set, so the configuration follows the links as they now stand.
const storageLinks = new Map(allConnections
.filter((connection) => connection.kind === 'storage')
// A step says in its configuration which resource it uses; the link on the canvas is how that is
// set, so the field follows the links as they now stand.
const resourceLinks = new Map(allConnections
.filter((connection) => connection.kind === 'resource')
.map((connection) => [connection.targetId, connection.sourceId] as const));
for (const node of nodes) {
if (!isStorageOperationNode(node as any)) continue;
const field = resourceRefFields.get(node.id);
if (!field) continue;
node.specificConfiguration = {
...(node.specificConfiguration ?? {}),
storageNode: storageLinks.get(node.id) ?? null
[field]: resourceLinks.get(node.id) ?? null
};
}
return {
blocks: nodes.filter((node): node is FlowBlock => node.nodeFamily === 'block'),
containers: nodes.filter((node) => node.nodeFamily === 'container'),
resources,
connections: allConnections
.filter((connection) => connection.kind === 'data')
.map(({ kind, ...connection }) => connection),
@ -662,11 +696,16 @@ export async function addBlockToEditor(
};
const capabilities = resolveNodeCapabilities(resolvedRuntime, node);
if (capabilities.visualRole === 'RESOURCE') {
node.addOutput(FLOW_STORAGE_PORT_KEY, new ClassicPreset.Output(getSocket(editor, FLOW_STORAGE_SOCKET_TYPE)));
const resourceReference = resourceRefOf(resolvedRuntime, node);
if (resourceReference) {
// Kept on the node, so that exporting the graph does not need the catalogue again.
(node.data as Record<string, unknown>)['__resourceRefField'] = resourceReference.field;
}
if (isStorageOperationNode(block as any)) {
node.addInput(FLOW_STORAGE_LINK_PORT_KEY, new ClassicPreset.Input(getSocket(editor, FLOW_STORAGE_SOCKET_TYPE), undefined, true));
if (isResourceNode(resolvedRuntime, node)) {
node.addOutput(FLOW_RESOURCE_PORT_KEY, new ClassicPreset.Output(getSocket(editor, FLOW_RESOURCE_SOCKET_TYPE)));
}
if (resourceReference) {
node.addInput(FLOW_RESOURCE_LINK_PORT_KEY, new ClassicPreset.Input(getSocket(editor, FLOW_RESOURCE_SOCKET_TYPE), undefined, true));
}
if (capabilities.canHaveDependentNodes) {
node.addOutput(FLOW_DEPENDANT_PORT_KEY, new ClassicPreset.Output(getSocket(editor, FLOW_DEPENDENCY_SOCKET_TYPE)));
@ -706,7 +745,13 @@ async function loadFlowData(
runtime?: ReteRuntimeContext,
isStale: () => boolean = () => false
) {
const topLevelNodes = [...(flowData.blocks ?? []), ...(flowData.containers ?? [])];
const resourceNodes: FlowNode[] = (flowData.resources ?? []).map((resource) => ({
...resource,
nodeFamily: 'resource',
inputs: [],
outputs: []
} as unknown as FlowNode));
const topLevelNodes = [...(flowData.blocks ?? []), ...(flowData.containers ?? []), ...resourceNodes];
if (!topLevelNodes.length) return;
const nodeMapping = new Map<string, any>();
@ -749,25 +794,28 @@ async function loadFlowData(
);
}
// Drawn back from what each storage operation says it works on.
// Drawn back from the field each step names its resource in.
for (const block of flowData.blocks ?? []) {
if (isStale()) return;
const storageNodeId = (block.specificConfiguration as Record<string, unknown> | undefined)?.['storageNode'];
if (!isStorageOperationNode(block as any) || typeof storageNodeId !== 'string') continue;
if (!nodeMapping.has(storageNodeId) || !nodeMapping.has(block.id)) continue;
const sourceNode = editor.getNode(nodeMapping.get(storageNodeId)) as any;
const targetNodeId = nodeMapping.get(block.id);
const field = (editor.getNode(targetNodeId)?.data as Record<string, unknown> | undefined)?.['__resourceRefField'];
if (typeof field !== 'string') continue;
const resourceId = (block.specificConfiguration as Record<string, unknown> | undefined)?.[field];
if (typeof resourceId !== 'string') continue;
if (!nodeMapping.has(resourceId) || !nodeMapping.has(block.id)) continue;
const sourceNode = editor.getNode(nodeMapping.get(resourceId)) as any;
const targetNode = editor.getNode(nodeMapping.get(block.id)) as any;
await editor.addConnection(
new ClassicPreset.Connection(sourceNode, FLOW_STORAGE_PORT_KEY, targetNode, FLOW_STORAGE_LINK_PORT_KEY)
new ClassicPreset.Connection(sourceNode, FLOW_RESOURCE_PORT_KEY, targetNode, FLOW_RESOURCE_LINK_PORT_KEY)
);
}
});
}
/** An operation works on one Storage node: linking it to another drops the link it had. */
async function replaceStorageLink(editor: NodeEditor<HFSchemes>, operationNodeId: string) {
/** A step uses one resource: linking it to another drops the link it had. */
async function replaceResourceLink(editor: NodeEditor<HFSchemes>, stepNodeId: string) {
const existing = editor.getConnections().filter((connection) =>
connection.target === operationNodeId && connection.targetInput === FLOW_STORAGE_LINK_PORT_KEY);
connection.target === stepNodeId && connection.targetInput === FLOW_RESOURCE_LINK_PORT_KEY);
for (const connection of existing) {
await editor.removeConnection(connection.id);
}
@ -814,7 +862,7 @@ function leadsBack(editor: NodeEditor<HFSchemes>, runtime: ReteRuntimeContext, c
for (const candidate of editor.getConnections()) {
if (candidate.id === ignoredConnectionId) continue;
// A link to a Storage node orders nothing: it is not a path round.
if (getGraphConnectionKind(candidate.sourceOutput, candidate.targetInput) === 'storage') continue;
if (getGraphConnectionKind(candidate.sourceOutput, candidate.targetInput) === 'resource') continue;
outgoing.set(candidate.source, [...(outgoing.get(candidate.source) ?? []), candidate.target]);
}
const seen = new Set<string>();
@ -842,8 +890,7 @@ export async function refreshLoopMarkers(
const connections = editor.getConnections() as LoopAwareConnection[];
const data = connections.filter((c) => getGraphConnectionKind(c.sourceOutput, c.targetInput) === 'data');
const backEdges = findLoopBackEdgeIds({
// Storage nodes left out, and with them their edges: they are not steps, and a write followed
// by a read of the same node is not a loop.
// Resources left out: they are not steps, and a write then a read of one is not a loop.
nodes: editor.getNodes().filter((node) => !isResourceNode(resolvedRuntime, node as HFNode)).map((node) => ({
id: node.id,
routesExclusively: resolveNodeCapabilities(resolvedRuntime, node as HFNode).routesExclusively === true
@ -909,17 +956,28 @@ function getSocket(editor: NodeEditor<HFSchemes>, type: string) {
const map = editorSockets.get(editor)!;
if (!map.has(type)) {
const socket = new ClassicPreset.Socket(type) as ClassicPreset.Socket & { __hfKind?: GraphConnectionKind };
socket.__hfKind = type === FLOW_DEPENDENCY_SOCKET_TYPE ? 'dependency' : type === FLOW_STORAGE_SOCKET_TYPE ? 'storage' : 'data';
socket.__hfKind = type === FLOW_DEPENDENCY_SOCKET_TYPE ? 'dependency' : type === FLOW_RESOURCE_SOCKET_TYPE ? 'resource' : 'data';
map.set(type, socket);
}
return map.get(type)!;
}
export function isResourceNode(runtime: ReteRuntimeContext | undefined, node: HFNode | undefined): boolean {
return resolveNodeCapabilities(runtime, node).visualRole === 'RESOURCE';
return (node?.data as unknown as Record<string, unknown> | undefined)?.['nodeFamily'] === 'resource';
}
/**
* The field this node links a resource through, from its type's schema: a step says in its
* configuration which resource it uses, and the editor draws that field as a link.
*/
function resourceRefOf(runtime: ReteRuntimeContext | undefined, node: HFNode | undefined) {
const typeName = node?.data?.typeName;
if (!runtime || isResourceNode(runtime, node) || typeof typeName !== 'string' || !typeName) return null;
return resourceRefField(runtime.blocksService.peekBlockType(typeName)?.schema);
}
export function resolveNodeCapabilities(runtime: ReteRuntimeContext | undefined, node: HFNode | undefined): NodeTypeCapabilities {
if (isResourceNode(runtime, node)) return RESOURCE_NODE_CAPABILITIES;
if (node?.data?.capabilities) return node.data.capabilities;
const typeName = node?.data?.typeName;
@ -965,6 +1023,6 @@ function cloneValue<T>(value: T): T {
function getGraphConnectionKind(sourceOutput: string, targetInput: string): GraphConnectionKind {
if (sourceOutput === FLOW_DEPENDANT_PORT_KEY && targetInput === FLOW_DEPENDENCY_PORT_KEY) return 'dependency';
if (sourceOutput === FLOW_STORAGE_PORT_KEY && targetInput === FLOW_STORAGE_LINK_PORT_KEY) return 'storage';
if (sourceOutput === FLOW_RESOURCE_PORT_KEY && targetInput === FLOW_RESOURCE_LINK_PORT_KEY) return 'resource';
return 'data';
}

View File

@ -3,6 +3,7 @@
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { StorageTypesCallServiceFake } from "@services/storage/storage-types-call.fake";
import { ResourcesCallServiceFake } from "@services/resources/resources-call.fake";
import { AdminCallFakeService } from "@services/admin/admin-call.fake";
import { AssistantCallServiceFake } from "@services/assistant/assistant-call.fake";
import { AuthorizationCallFakeService } from "@services/authorization/authorization-call.fake";
@ -34,5 +35,6 @@ export const environment = {
llmProviderCallService: LlmProviderCallServiceFake,
executionVaultCredentialsCallService: ExecutionVaultCredentialsCallServiceFake,
vaultCallService: VaultCallServiceFake,
resourcesCallService: ResourcesCallServiceFake,
storageTypesCallService: StorageTypesCallServiceFake
};

View File

@ -3,6 +3,7 @@
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { StorageTypesCallService } from "@services/storage/storage-types-call";
import { ResourcesCallService } from "@services/resources/resources-call";
import { AdminCallService } from "@services/admin/admin-call";
import { AssistantCallService } from "@services/assistant/assistant-call";
import { AuthorizationCallService } from "@services/authorization/authorization-call";
@ -34,5 +35,6 @@ export const environment = {
llmProviderCallService: LlmProviderCallService,
executionVaultCredentialsCallService: ExecutionVaultCredentialsCallService,
vaultCallService: VaultCallService,
resourcesCallService: ResourcesCallService,
storageTypesCallService: StorageTypesCallService
};

View File

@ -3,6 +3,7 @@
// Attribution term under AGPL-3.0 section 7(b): see LICENSE-ADDENDUM.
import { StorageTypesCallService } from "@services/storage/storage-types-call";
import { ResourcesCallService } from "@services/resources/resources-call";
import { AdminCallService } from "@services/admin/admin-call";
import { AssistantCallService } from "@services/assistant/assistant-call";
import { AuthorizationCallService } from "@services/authorization/authorization-call";
@ -34,5 +35,6 @@ export const environment = {
llmProviderCallService: LlmProviderCallService,
executionVaultCredentialsCallService: ExecutionVaultCredentialsCallService,
vaultCallService: VaultCallService,
resourcesCallService: ResourcesCallService,
storageTypesCallService: StorageTypesCallService
};