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:
parent
ee2696814c
commit
067cd2f287
|
|
@ -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[] {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: []
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ export class FlowsCallService extends FlowsCallServiceBase {
|
|||
data: {
|
||||
blocks: [],
|
||||
containers: [],
|
||||
resources: [],
|
||||
connections: [],
|
||||
dependencies: [],
|
||||
globalInputs: []
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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' }));
|
||||
});
|
||||
});
|
||||
|
|
@ -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'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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() : '';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"> </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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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"> </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>
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"> </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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue