diff --git a/src/app/models/flow.ts b/src/app/models/flow.ts index f63ef23..91ef700 100644 --- a/src/app/models/flow.ts +++ b/src/app/models/flow.ts @@ -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 | 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. + * + *

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 | 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 | null | undefined)?.['properties']; + if (!properties || typeof properties !== 'object') return null; + for (const [field, raw] of Object.entries(properties as Record)) { + const resourceType = (raw as Record | null)?.[RESOURCE_REF_SCHEMA_KEY]; + if (typeof resourceType === 'string' && resourceType) { + return { field, resourceType }; + } + } + return null; } export function normalizeFlowValidationErrors(raw: unknown): FlowValidationError[] { diff --git a/src/app/pages/main/editor-sidebar/editor-sidebar.ts b/src/app/pages/main/editor-sidebar/editor-sidebar.ts index 28b246d..4efa4e1 100644 --- a/src/app/pages/main/editor-sidebar/editor-sidebar.ts +++ b/src/app/pages/main/editor-sidebar/editor-sidebar.ts @@ -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)) ]; } diff --git a/src/app/services/flows/flow-mapper.ts b/src/app/services/flows/flow-mapper.ts index 6234fcd..b692f3a 100644 --- a/src/app/services/flows/flow-mapper.ts +++ b/src/app/services/flows/flow-mapper.ts @@ -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 { +function normalizeNodes(raw: unknown, nodeFamily: 'block' | 'container' | 'resource'): Array { if (!Array.isArray(raw)) return []; return raw diff --git a/src/app/services/flows/flow-transfer.spec.ts b/src/app/services/flows/flow-transfer.spec.ts index 6780c7f..7b7183e 100644 --- a/src/app/services/flows/flow-transfer.spec.ts +++ b/src/app/services/flows/flow-transfer.spec.ts @@ -16,6 +16,7 @@ function makeFlowData(overrides: Partial = {}): 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: [] }); }); }); diff --git a/src/app/services/flows/flow-transfer.ts b/src/app/services/flows/flow-transfer.ts index f654b31..b3d88e4 100644 --- a/src/app/services/flows/flow-transfer.ts +++ b/src/app/services/flows/flow-transfer.ts @@ -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(); const containers = new Set(); + const resources = new Set(); 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'], diff --git a/src/app/services/flows/flows-call.ts b/src/app/services/flows/flows-call.ts index 7bb9135..bccf878 100644 --- a/src/app/services/flows/flows-call.ts +++ b/src/app/services/flows/flows-call.ts @@ -36,6 +36,7 @@ export class FlowsCallService extends FlowsCallServiceBase { data: { blocks: [], containers: [], + resources: [], connections: [], dependencies: [], globalInputs: [] diff --git a/src/app/services/resources/resource-call.base.ts b/src/app/services/resources/resource-call.base.ts new file mode 100644 index 0000000..41f542b --- /dev/null +++ b/src/app/services/resources/resource-call.base.ts @@ -0,0 +1,12 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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; + + abstract createEmptyResource(resourceType: string): Observable; +} diff --git a/src/app/services/resources/resources-call.fake.ts b/src/app/services/resources/resources-call.fake.ts new file mode 100644 index 0000000..51cce1b --- /dev/null +++ b/src/app/services/resources/resources-call.fake.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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 { + return of([FAKE_STORAGE_RESOURCE_TYPE]); + } + + override createEmptyResource(resourceType: string): Observable { + return of({ + id: crypto.randomUUID(), + name: resourceType, + specificConfiguration: { type: 'StorageResourceConfiguration', name: resourceType } as FlowResource['specificConfiguration'], + typeName: resourceType, + nodeFamily: 'resource' as const + }); + } +} diff --git a/src/app/services/resources/resources-call.spec.ts b/src/app/services/resources/resources-call.spec.ts new file mode 100644 index 0000000..8cb2b57 --- /dev/null +++ b/src/app/services/resources/resources-call.spec.ts @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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' })); + }); +}); diff --git a/src/app/services/resources/resources-call.ts b/src/app/services/resources/resources-call.ts new file mode 100644 index 0000000..1999e78 --- /dev/null +++ b/src/app/services/resources/resources-call.ts @@ -0,0 +1,62 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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 { + return this.http + .get(`${environment.apiUrl}/resources/types/configurations/catalog`) + .pipe(map((raw) => this.parseCatalogResponse(raw))); + } + + override createEmptyResource(resourceType: string): Observable { + return this.http + .get(`${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 | 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' + }; + } +} diff --git a/src/app/services/resources/resources.ts b/src/app/services/resources/resources.ts new file mode 100644 index 0000000..ca17257 --- /dev/null +++ b/src/app/services/resources/resources.ts @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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 { + 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 { + return this.call.retrieveAllResourceTypes(); + } + + hasLoadedResourceTypes(): boolean { + return this.hasLoadedTypes(); + } + + getAllResourceTypes(): Promise> { + return this.getAllTypes(); + } + + peekResourceType(type: string): ResourceType | null { + return this.peekType((resourceType) => resourceType.type === type); + } + + createEmptyResource(resourceType: string): Observable { + return this.call.createEmptyResource(resourceType); + } +} diff --git a/src/app/services/shared/flow-node-mapping.spec.ts b/src/app/services/shared/flow-node-mapping.spec.ts index 462c7ce..8dbadf3 100644 --- a/src/app/services/shared/flow-node-mapping.spec.ts +++ b/src/app/services/shared/flow-node-mapping.spec.ts @@ -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'); }); diff --git a/src/app/services/shared/flow-node-mapping.ts b/src/app/services/shared/flow-node-mapping.ts index 062ca4e..accd131 100644 --- a/src/app/services/shared/flow-node-mapping.ts +++ b/src/app/services/shared/flow-node-mapping.ts @@ -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() : ''; diff --git a/src/app/shared/blocks-list/blocks-list.spec.ts b/src/app/shared/blocks-list/blocks-list.spec.ts index cafb5e4..a1052d9 100644 --- a/src/app/shared/blocks-list/blocks-list.spec.ts +++ b/src/app/shared/blocks-list/blocks-list.spec.ts @@ -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); }); }); diff --git a/src/app/shared/blocks-list/blocks-list.ts b/src/app/shared/blocks-list/blocks-list.ts index c5627f1..502d16d 100644 --- a/src/app/shared/blocks-list/blocks-list.ts +++ b/src/app/shared/blocks-list/blocks-list.ts @@ -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): BlockKind { +export function blockKind(block: Pick): 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 { readonly selectedKinds = signal>(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([]); loading: WritableSignal = signal(true); @@ -87,6 +92,7 @@ export class BlocksList extends ListStateViewHolder { } ngOnInit() { + this.loadResourceTypes(); const existingState = this.view; if (existingState.list) { this.blockTypes = existingState.list; @@ -111,9 +117,27 @@ export class BlocksList extends ListStateViewHolder { }); } + 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(() => + [...(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(() => { - const blocks = this.blockTypes ? this.blockTypes() : []; + const blocks = this.paletteTypes(); const byLabel = new Map(); for (const block of blocks ?? []) { const kind = blockKind(block); @@ -124,8 +148,7 @@ export class BlocksList extends ListStateViewHolder { }); filteredBlocks = computed(() => { - const blocks = this.blockTypes ? this.blockTypes() : []; - if (!blocks) return []; + const blocks = this.paletteTypes(); const term = this.searchTerm().toLowerCase(); const kinds = this.selectedKinds(); diff --git a/src/app/shared/custom-connection/custom-connection.ts b/src/app/shared/custom-connection/custom-connection.ts index 3543e81..b6a3b28 100644 --- a/src/app/shared/custom-connection/custom-connection.ts +++ b/src/app/shared/custom-connection/custom-connection.ts @@ -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; } diff --git a/src/app/shared/custom-socket/custom-socket.ts b/src/app/shared/custom-socket/custom-socket.ts index 96a58c4..1ec96ea 100644 --- a/src/app/shared/custom-socket/custom-socket.ts +++ b/src/app/shared/custom-socket/custom-socket.ts @@ -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 { diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index b4b1bd3..0956ece 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -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; diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index bb10ea3..03be2ea 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -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 @@ }

{{ nodeIdLabel }}
- @if (isResourceNode()) { -
- - Resource -
- } @if (laneBadge; as lane) {
@@ -217,11 +210,11 @@
- @if (storageLinkOutput || storageLinkInput) { -
+ @if (resourceLinkInput) { +
-
Storage
- @if (storageLinkInput; as storageLinkInput) { +
Resource
+ @if (resourceLinkInput; as resourceLinkInput) {
- Works on + Works on
}
-
-
 
- @if (storageLinkOutput; as storageLinkOutput) { -
- Used by -
-
-
- } -
} @@ -313,243 +285,7 @@
} -
- @if (!schemaReady) { - - } - @for (section of parameterDisplaySections; track section.key) { - @if (section.group; as group) { -
- {{ group.legend }} -
- @for (item of group.items; track item.path) { - @if (item.field; as field) { -
-
- {{ field.label }} - @if (field.type === 'boolean') { - - } @else if (!isReadonly) { - - } @else if (field.expandable) { - - } -
- @if (field.type !== 'boolean') { - {{ field.value }} - } -
- } @else { - @if (item.richContentField; as contentField) { -
-
-
{{ contentField.label }}
- @if (!isReadonly) { - - } @else if (contentField.expandable) { - - } -
-
- @if (!contentField.parts.length) { - - - } @else { - @for (part of contentField.parts; track $index) { - @if (part.isDynamicInput) { - {{ formatDynamicInputToken(part.text) }} - } @else { - {{ part.text }} - } - } - } -
-
- } - @if (item.optionalGroupField; as optionalGroup) { - - } - @if (item.arrayField; as arrayField) { - - } - } - } -
-
- } @else if (section.item; as item) { -
- @if (item.field; as field) { -
-
- {{ field.label }} - @if (field.type === 'boolean') { - - } @else if (!isReadonly) { - - } @else if (field.expandable) { - - } -
- @if (field.type !== 'boolean') { - {{ field.value }} - } -
- } @else { - @if (item.richContentField; as contentField) { -
-
-
{{ contentField.label }}
- @if (!isReadonly) { - - } @else if (contentField.expandable) { - - } -
-
- @if (!contentField.parts.length) { - - - } @else { - @for (part of contentField.parts; track $index) { - @if (part.isDynamicInput) { - {{ formatDynamicInputToken(part.text) }} - } @else { - {{ part.text }} - } - } - } -
-
- } - @if (item.optionalGroupField; as optionalGroup) { - - } - @if (item.arrayField; as arrayField) { - - } - } -
- } - } -
+ @if (biasAnnotationsAllowed) { } - @if (localEditorOpen) { - -
-
Edit {{ localEditorLabel }}
- @if (localEditorBindableAsInput && localEditorPath !== 'name') { -
- - - @if (localEditorUseInput) { -
- This field will be provided by node input{{ localEditorBindableInputName ? (' "' + localEditorBindableInputName + '"') : '' }}. -
- } -
- } - - - @if (localEditorFromGlobal) { -
- - - @if (!localEditorGlobalInputs.length) { -
This flow declares no global inputs yet. Add one to the flow to use it here.
- } -
- } - - @if (!localEditorFromGlobal) { -
- -
- @if (localEditorUseInput && localEditorPath !== 'name') { - - } @else if (localEditorLoading) { - - } @else if (localEditorType === 'boolean') { - - } @else if (localEditorWidget === 'textarea') { - - } @else if (localEditorHasRetriever && !localEditorFreeText) { - - } @else { - - } - @if (showLocalEditorDefault()) { - - } -
- @if (localEditorError(); as error) { - {{ error }} - } @else if (localEditorDefaultHint(); as hint) { - {{ hint }} - } - - @if (localEditorTip) { - {{ localEditorTip }} - } -
- } -
- - -
-
-
- } diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 9d91882..774bb0a 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -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(); 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 `` shown via `showModal()` - * escapes that via the browser's top layer, same fix as bias-annotations. - */ - private readonly simpleEditorDialog = viewChild>('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 | undefined)?.['nodeFamily'] === 'resource'; } isConditionalNode(): boolean { diff --git a/src/app/shared/nodes/node-parameters/node-parameters.css b/src/app/shared/nodes/node-parameters/node-parameters.css new file mode 100644 index 0000000..db7defb --- /dev/null +++ b/src/app/shared/nodes/node-parameters/node-parameters.css @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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'; diff --git a/src/app/shared/nodes/node-parameters/node-parameters.html b/src/app/shared/nodes/node-parameters/node-parameters.html new file mode 100644 index 0000000..2c18cd5 --- /dev/null +++ b/src/app/shared/nodes/node-parameters/node-parameters.html @@ -0,0 +1,383 @@ + + +
+ @if (!host.schemaReady) { + + } + @for (section of host.parameterDisplaySections; track section.key) { + @if (section.group; as group) { +
+ {{ group.legend }} +
+ @for (item of group.items; track item.path) { + @if (item.field; as field) { +
+
+ {{ field.label }} + @if (field.type === 'boolean') { + + } @else if (!host.isReadonly) { + + } @else if (field.expandable) { + + } +
+ @if (field.type !== 'boolean') { + {{ field.value }} + } +
+ } @else { + @if (item.richContentField; as contentField) { +
+
+
{{ contentField.label }}
+ @if (!host.isReadonly) { + + } @else if (contentField.expandable) { + + } +
+
+ @if (!contentField.parts.length) { + - + } @else { + @for (part of contentField.parts; track $index) { + @if (part.isDynamicInput) { + {{ host.formatDynamicInputToken(part.text) }} + } @else { + {{ part.text }} + } + } + } +
+
+ } + @if (item.optionalGroupField; as optionalGroup) { + + } + @if (item.arrayField; as arrayField) { + + } + } + } +
+
+ } @else if (section.item; as item) { +
+ @if (item.field; as field) { +
+
+ {{ field.label }} + @if (field.type === 'boolean') { + + } @else if (!host.isReadonly) { + + } @else if (field.expandable) { + + } +
+ @if (field.type !== 'boolean') { + {{ field.value }} + } +
+ } @else { + @if (item.richContentField; as contentField) { +
+
+
{{ contentField.label }}
+ @if (!host.isReadonly) { + + } @else if (contentField.expandable) { + + } +
+
+ @if (!contentField.parts.length) { + - + } @else { + @for (part of contentField.parts; track $index) { + @if (part.isDynamicInput) { + {{ host.formatDynamicInputToken(part.text) }} + } @else { + {{ part.text }} + } + } + } +
+
+ } + @if (item.optionalGroupField; as optionalGroup) { + + } + @if (item.arrayField; as arrayField) { + + } + } +
+ } + } +
+ +@if (host.localEditorOpen) { + +
+
Edit {{ host.localEditorLabel }}
+ @if (host.localEditorBindableAsInput && host.localEditorPath !== 'name') { +
+ + + @if (host.localEditorUseInput) { +
+ This field will be provided by node input{{ host.localEditorBindableInputName ? (' "' + host.localEditorBindableInputName + '"') : '' }}. +
+ } +
+ } + + + @if (host.localEditorFromGlobal) { +
+ + + @if (!host.localEditorGlobalInputs.length) { +
This flow declares no global inputs yet. Add one to the flow to use it here.
+ } +
+ } + + @if (!host.localEditorFromGlobal) { +
+ +
+ @if (host.localEditorUseInput && host.localEditorPath !== 'name') { + + } @else if (host.localEditorLoading) { + + } @else if (host.localEditorType === 'boolean') { + + } @else if (host.localEditorWidget === 'textarea') { + + } @else if (host.localEditorHasRetriever && !host.localEditorFreeText) { + + } @else { + + } + @if (host.showLocalEditorDefault()) { + + } +
+ @if (host.localEditorError(); as error) { + {{ error }} + } @else if (host.localEditorDefaultHint(); as hint) { + {{ hint }} + } + + @if (host.localEditorTip) { + {{ host.localEditorTip }} + } +
+ } +
+ + +
+
+
+} diff --git a/src/app/shared/nodes/node-parameters/node-parameters.ts b/src/app/shared/nodes/node-parameters/node-parameters.ts new file mode 100644 index 0000000..eb06b86 --- /dev/null +++ b/src/app/shared/nodes/node-parameters/node-parameters.ts @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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. + * + *

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 + * `

` shown with `showModal()` escapes that through the browser's top layer. + */ + private readonly simpleEditorDialog = viewChild>('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); + } + } +} diff --git a/src/app/shared/nodes/resource-node/resource-node.css b/src/app/shared/nodes/resource-node/resource-node.css new file mode 100644 index 0000000..de75217 --- /dev/null +++ b/src/app/shared/nodes/resource-node/resource-node.css @@ -0,0 +1,12 @@ +/* + * SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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; +} diff --git a/src/app/shared/nodes/resource-node/resource-node.html b/src/app/shared/nodes/resource-node/resource-node.html new file mode 100644 index 0000000..eb74899 --- /dev/null +++ b/src/app/shared/nodes/resource-node/resource-node.html @@ -0,0 +1,125 @@ + + +@if (focusOpen) { +
+} +
+ @if (isSchemaLoading) { +
+
+ + Loading resource... +
+
+ } + @if (deleteConfirmOpen) { +
+ } +
+
+ +
+
+ {{ resourceTitle() }} +
+ {{ name }} + @if (!isReadonly) { + + } +
+
{{ nodeIdLabel }}
+
+ + Resource +
+
+
+ @if (hasUpdateBlockError()) { +
+
+ +
+
+
Resource update failed
+
{{ updateBlockErrorMessage() }}
+
+
+ } + @if (missingRequiredParams.length) { +
+
+ +
+
+
Missing required fields
+ @for (missing of missingRequiredParams; track missing) { +
{{ missing }}
+ } +
+
+ } + @if (!isReadonly && deleteConfirmOpen) { +
+ Delete resource? + + +
+ } + @if (!isReadonly) { + + @if (!focusOpen) { + + } + } +
+
+ + @if (resourceLinkOutput; as resourceLinkOutput) { +
+
+
 
+
+
+
Used by
+
+ Steps +
+
+
+
+
+ } + + +
diff --git a/src/app/shared/nodes/resource-node/resource-node.ts b/src/app/shared/nodes/resource-node/resource-node.ts new file mode 100644 index 0000000..2cc1827 --- /dev/null +++ b/src/app/shared/nodes/resource-node/resource-node.ts @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2025-2026 Lucio Lelii - 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. + * + *

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'); + } +} diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css index 157bd86..86134ba 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.css +++ b/src/app/shared/nodes/task-step-node/task-step-node.css @@ -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; diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html index 0b4b259..9216e46 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.html +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -268,7 +268,7 @@ }" [emit]="emit"> - Works on + Works on } @@ -277,7 +277,7 @@

 
@if (storageLinkOutput; as storageLinkOutput) {
- Used by + Used by
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); } diff --git a/src/app/shared/rete-editor/rete-editor.ts b/src/app/shared/rete-editor/rete-editor.ts index fdfe25c..b540ae9 100644 --- a/src/app/shared/rete-editor/rete-editor.ts +++ b/src/app/shared/rete-editor/rete-editor.ts @@ -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; diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 8ffc1e6..44a77d9 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -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'; -} + diff --git a/src/app/utilities/rete-editor.spec.ts b/src/app/utilities/rete-editor.spec.ts index 8ffb86f..c69831d 100644 --- a/src/app/utilities/rete-editor.spec.ts +++ b/src/app/utilities/rete-editor.spec.ts @@ -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 = {}): 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(); - 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 | undefined)?.['storageNode']).toBe('store-1'); + const operationOf = (g: ReturnType) => + g.blocks.find((block) => block.id === 'op-1')?.specificConfiguration as Record | 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 | undefined)?.['storageNode']).toBeNull(); + expect(operationOf(graph)?.['storageNode']).toBeNull(); }); }); diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts index 5f71d2f..f8c690e 100644 --- a/src/app/utilities/rete-editor.ts +++ b/src/app/utilities/rete-editor.ts @@ -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; }; -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) { const runtime = editorRuntime.get(editor); const nodeIdToBlockId = new Map(); - 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(); + const nodes: FlowNode[] = editor.getNodes().filter((node) => { + const blockData = node.data as unknown as (Record | 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 | undefined; const blockId = blockData?.id ?? node.id; @@ -258,6 +286,10 @@ export function exportGraph(editor: NodeEditor) { 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) { }; }); - // 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)['__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(); @@ -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 | 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 | undefined)?.['__resourceRefField']; + if (typeof field !== 'string') continue; + const resourceId = (block.specificConfiguration as Record | 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, operationNodeId: string) { +/** A step uses one resource: linking it to another drops the link it had. */ +async function replaceResourceLink(editor: NodeEditor, 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, 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(); @@ -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, 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 | 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(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'; } diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts index 70c245c..dd0d568 100644 --- a/src/environments/environment.development.ts +++ b/src/environments/environment.development.ts @@ -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 }; diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts index 814046a..3521047 100644 --- a/src/environments/environment.staging.ts +++ b/src/environments/environment.staging.ts @@ -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 }; diff --git a/src/environments/environment.ts b/src/environments/environment.ts index c5fde04..446fc7a 100644 --- a/src/environments/environment.ts +++ b/src/environments/environment.ts @@ -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 };