feat(flow-editor): support node capabilities
This commit is contained in:
parent
c031eb8535
commit
318531111d
|
|
@ -72,6 +72,28 @@ export type BlockInteractionContract = {
|
|||
supportsPartialResult: boolean;
|
||||
};
|
||||
|
||||
export type NodeVisualRole = 'ACTIVITY' | 'DECISION' | 'MERGE' | 'END' | 'CONTAINER';
|
||||
|
||||
export type NodeTypeCapabilities = {
|
||||
visualRole: NodeVisualRole;
|
||||
terminal: boolean;
|
||||
biasAnnotationsAllowed: boolean;
|
||||
allowsIncomingConnections: boolean;
|
||||
allowsOutgoingConnections: boolean;
|
||||
canDependOnOtherNodes: boolean;
|
||||
canHaveDependentNodes: boolean;
|
||||
};
|
||||
|
||||
export const DEFAULT_NODE_CAPABILITIES: NodeTypeCapabilities = {
|
||||
visualRole: 'ACTIVITY',
|
||||
terminal: false,
|
||||
biasAnnotationsAllowed: true,
|
||||
allowsIncomingConnections: true,
|
||||
allowsOutgoingConnections: true,
|
||||
canDependOnOtherNodes: true,
|
||||
canHaveDependentNodes: true,
|
||||
};
|
||||
|
||||
export type BlockType = {
|
||||
type: BlockTypeName;
|
||||
family: NodeFamily;
|
||||
|
|
@ -83,6 +105,7 @@ export type BlockType = {
|
|||
configurationType: string | null;
|
||||
configurationClass: string | null;
|
||||
schema: BlockTypeSchema;
|
||||
capabilities?: NodeTypeCapabilities;
|
||||
};
|
||||
|
||||
export type FlowNodeBase = {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BiasAnnotationsDescriptor, BlockType, FlowBlock } from "@models/flow";
|
||||
import { BiasAnnotationsDescriptor, BlockType, DEFAULT_NODE_CAPABILITIES, FlowBlock } from "@models/flow";
|
||||
import { BiasCapabilities } from '@models/bias-impact';
|
||||
import { Observable, of } from "rxjs";
|
||||
import { BlockDraftContext, BlocksCallServiceBase } from "./block-call.base";
|
||||
|
|
@ -18,6 +18,7 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
|
|||
"responseField": "output",
|
||||
"supportsPartialResult": false
|
||||
},
|
||||
"capabilities": DEFAULT_NODE_CAPABILITIES,
|
||||
"configurationType": "HumanInteractiveBlockConfiguration",
|
||||
"configurationClass": "it.cnr.isti.workflow.manager.blocks.configurations.HumanInteractiveBlockConfiguration",
|
||||
"schema": {
|
||||
|
|
@ -82,6 +83,7 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
|
|||
"family": "block",
|
||||
"description": "This type represents a LLM node in the workflow manager",
|
||||
"userInteractive": false,
|
||||
"capabilities": DEFAULT_NODE_CAPABILITIES,
|
||||
"configurationType": "LLMBlockConfiguration",
|
||||
"configurationClass": "it.cnr.isti.workflow.manager.blocks.configurations.LLMBlockConfiguration",
|
||||
"schema": {
|
||||
|
|
@ -142,6 +144,70 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
|
|||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "EndBlock",
|
||||
"family": "block",
|
||||
"description": "Records a terminal outcome for the selected workflow path",
|
||||
"userInteractive": false,
|
||||
"capabilities": {
|
||||
"visualRole": "END",
|
||||
"terminal": true,
|
||||
"biasAnnotationsAllowed": false,
|
||||
"allowsIncomingConnections": true,
|
||||
"allowsOutgoingConnections": false,
|
||||
"canDependOnOtherNodes": false,
|
||||
"canHaveDependentNodes": false
|
||||
},
|
||||
"configurationType": "EndBlockConfiguration",
|
||||
"configurationClass": "it.cnr.isti.workflow.manager.blocks.configurations.EndBlockConfiguration",
|
||||
"schema": {
|
||||
"$schema": "http://json-schema.org/draft-04/schema#",
|
||||
"title": "EndBlockConfiguration",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"EndBlockConfiguration"
|
||||
],
|
||||
"default": "EndBlockConfiguration"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"outcomeLabel": {
|
||||
"type": "string",
|
||||
"x-ui-placeholder": "Outcome label"
|
||||
},
|
||||
"outcomeCode": {
|
||||
"type": "string",
|
||||
"x-ui-visible-when": {
|
||||
"field": "mode",
|
||||
"equals": "__never__"
|
||||
}
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"PATH_END"
|
||||
],
|
||||
"default": "PATH_END",
|
||||
"x-ui-visible-when": {
|
||||
"field": "mode",
|
||||
"equals": "__never__"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"name",
|
||||
"outcomeLabel",
|
||||
"outcomeCode",
|
||||
"mode"
|
||||
]
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
override retrieveAllBlocksTypes(): Observable<BlockType[]> {
|
||||
|
|
@ -342,6 +408,13 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
|
|||
};
|
||||
}
|
||||
|
||||
if (typeName === "EndBlock") {
|
||||
return {
|
||||
inputs: [{ name: "input", type: "TEXT", multiple: false }],
|
||||
outputs: []
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
inputs: [{ name: "input", type: "TEXT", multiple: false }],
|
||||
outputs: [{ name: "output", type: "TEXT", multiple: false }]
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { provideHttpClient } from '@angular/common/http';
|
|||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { environment } from '@environment';
|
||||
import { DEFAULT_NODE_CAPABILITIES } from '@models/flow';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { BlocksCallService } from './blocks-call';
|
||||
|
|
@ -86,6 +87,49 @@ describe('BlocksCallService', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it('parses a terminal EndBlock descriptor capabilities and falls back to defaults when absent', async () => {
|
||||
const request = firstValueFrom(service.retrieveAllBlocksTypes());
|
||||
|
||||
httpMock.expectOne(`${environment.apiUrl}/blocks/types/catalog`).flush({
|
||||
descriptors: [
|
||||
{
|
||||
type: 'EndBlock',
|
||||
description: 'Terminal outcome',
|
||||
userInteractive: false,
|
||||
schema: { type: 'object', properties: {} },
|
||||
capabilities: {
|
||||
visualRole: 'END',
|
||||
terminal: true,
|
||||
biasAnnotationsAllowed: false,
|
||||
allowsIncomingConnections: true,
|
||||
allowsOutgoingConnections: false,
|
||||
canDependOnOtherNodes: false,
|
||||
canHaveDependentNodes: false
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'LLMBlock',
|
||||
description: 'LLM node',
|
||||
userInteractive: false,
|
||||
schema: { type: 'object', properties: {} }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const blockTypes = await request;
|
||||
|
||||
expect(blockTypes.find((type) => type.type === 'EndBlock')?.capabilities).toEqual({
|
||||
visualRole: 'END',
|
||||
terminal: true,
|
||||
biasAnnotationsAllowed: false,
|
||||
allowsIncomingConnections: true,
|
||||
allowsOutgoingConnections: false,
|
||||
canDependOnOtherNodes: false,
|
||||
canHaveDependentNodes: false
|
||||
});
|
||||
expect(blockTypes.find((type) => type.type === 'LLMBlock')?.capabilities).toEqual(DEFAULT_NODE_CAPABILITIES);
|
||||
});
|
||||
|
||||
it('rejects the legacy block catalog array format', async () => {
|
||||
const request = firstValueFrom(service.retrieveAllBlocksTypes());
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { inject } from "@angular/core";
|
|||
import { environment } from "@environment";
|
||||
import { catchError, map, Observable, of, switchMap, take, throwError } from "rxjs";
|
||||
import { BlockDraftContext, BlocksCallServiceBase } from "./block-call.base";
|
||||
import { attachSharedDefinitions, toApiPath, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from "@services/shared/flow-node-mapping";
|
||||
import { attachSharedDefinitions, toApiPath, toNodeCapabilities, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from "@services/shared/flow-node-mapping";
|
||||
|
||||
export class BlocksCallService extends BlocksCallServiceBase {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
|
@ -155,7 +155,8 @@ export class BlocksCallService extends BlocksCallServiceBase {
|
|||
exampleBlockEndpoint: toApiPath(value["exampleBlockEndpoint"]),
|
||||
configurationType: toNullableString(value["configurationType"]),
|
||||
configurationClass: toNullableString(value["configurationClass"]),
|
||||
schema
|
||||
schema,
|
||||
capabilities: toNodeCapabilities(value["capabilities"])
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { BlockType, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow";
|
||||
import { BlockType, DEFAULT_NODE_CAPABILITIES, FlowContainer, FlowData, FlowSubflowValidationResult } from "@models/flow";
|
||||
import { BiasCapabilities } from "@models/bias-impact";
|
||||
import { Observable, of } from "rxjs";
|
||||
import { ContainersCallServiceBase } from "./container-call.base";
|
||||
|
|
@ -10,6 +10,7 @@ export class ContainersCallServiceFake extends ContainersCallServiceBase {
|
|||
family: "container",
|
||||
description: "Container node with an embedded validated subflow",
|
||||
userInteractive: false,
|
||||
capabilities: { ...DEFAULT_NODE_CAPABILITIES, visualRole: "CONTAINER" },
|
||||
configurationType: "GenericContainerConfiguration",
|
||||
configurationClass: "it.cnr.isti.workflow.manager.blocks.configurations.GenericContainerConfiguration",
|
||||
schema: {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { provideHttpClient } from '@angular/common/http';
|
|||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { environment } from '@environment';
|
||||
import { DEFAULT_NODE_CAPABILITIES } from '@models/flow';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
import { ContainersCallService } from './containers-call';
|
||||
|
|
@ -81,6 +82,25 @@ describe('ContainersCallService', () => {
|
|||
}));
|
||||
});
|
||||
|
||||
it('falls back to DEFAULT_NODE_CAPABILITIES when a descriptor has no capabilities field', async () => {
|
||||
const request = firstValueFrom(service.retrieveAllContainerTypes());
|
||||
|
||||
httpMock.expectOne(`${environment.apiUrl}/containers/types/catalog`).flush({
|
||||
descriptors: [
|
||||
{
|
||||
type: 'GenericContainer',
|
||||
description: 'Container node',
|
||||
userInteractive: false,
|
||||
schema: { type: 'object', properties: {} }
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const containerTypes = await request;
|
||||
|
||||
expect(containerTypes[0].capabilities).toEqual(DEFAULT_NODE_CAPABILITIES);
|
||||
});
|
||||
|
||||
it('uses the example endpoint exposed by the catalog when creating an empty container', async () => {
|
||||
const typesRequest = firstValueFrom(service.retrieveAllContainerTypes());
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import {
|
|||
import { BiasCapabilities } from "@models/bias-impact";
|
||||
import { map, Observable, of } from "rxjs";
|
||||
import { ContainersCallServiceBase } from "./container-call.base";
|
||||
import { attachSharedDefinitions, toApiPath, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from "@services/shared/flow-node-mapping";
|
||||
import { attachSharedDefinitions, toApiPath, toNodeCapabilities, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from "@services/shared/flow-node-mapping";
|
||||
|
||||
export class ContainersCallService extends ContainersCallServiceBase {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
|
@ -128,7 +128,8 @@ export class ContainersCallService extends ContainersCallServiceBase {
|
|||
exampleBlockEndpoint: toApiPath(value["exampleBlockEndpoint"] ?? value["exampleContainerEndpoint"]),
|
||||
configurationType: toNullableString(value["configurationType"]),
|
||||
configurationClass: toNullableString(value["configurationClass"]),
|
||||
schema
|
||||
schema,
|
||||
capabilities: toNodeCapabilities(value["capabilities"])
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { attachSharedDefinitions, toApiPath, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from './flow-node-mapping';
|
||||
import { DEFAULT_NODE_CAPABILITIES } from '@models/flow';
|
||||
import { attachSharedDefinitions, toApiPath, toNodeCapabilities, toNullableString, toPorts, toPosition, toRecord, toSchema, toValueKinds } from './flow-node-mapping';
|
||||
|
||||
describe('toRecord', () => {
|
||||
it('returns the object as-is', () => {
|
||||
|
|
@ -111,3 +112,38 @@ describe('toPorts', () => {
|
|||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toNodeCapabilities', () => {
|
||||
it('falls back to DEFAULT_NODE_CAPABILITIES when raw is missing/not an object', () => {
|
||||
expect(toNodeCapabilities(null)).toEqual(DEFAULT_NODE_CAPABILITIES);
|
||||
expect(toNodeCapabilities(undefined)).toEqual(DEFAULT_NODE_CAPABILITIES);
|
||||
expect(toNodeCapabilities('x')).toEqual(DEFAULT_NODE_CAPABILITIES);
|
||||
});
|
||||
|
||||
it('reads a fully specified capabilities object, e.g. a terminal EndBlock', () => {
|
||||
expect(toNodeCapabilities({
|
||||
visualRole: 'END',
|
||||
terminal: true,
|
||||
biasAnnotationsAllowed: false,
|
||||
allowsIncomingConnections: true,
|
||||
allowsOutgoingConnections: false,
|
||||
canDependOnOtherNodes: false,
|
||||
canHaveDependentNodes: false
|
||||
})).toEqual({
|
||||
visualRole: 'END',
|
||||
terminal: true,
|
||||
biasAnnotationsAllowed: false,
|
||||
allowsIncomingConnections: true,
|
||||
allowsOutgoingConnections: false,
|
||||
canDependOnOtherNodes: false,
|
||||
canHaveDependentNodes: false
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back per-field for missing booleans and an unknown visualRole', () => {
|
||||
expect(toNodeCapabilities({ visualRole: 'NOT_A_ROLE', terminal: true })).toEqual({
|
||||
...DEFAULT_NODE_CAPABILITIES,
|
||||
terminal: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { environment } from '@environment';
|
||||
import { DEFAULT_NODE_CAPABILITIES, NodeTypeCapabilities, NodeVisualRole } from '@models/flow';
|
||||
|
||||
/**
|
||||
* Response-mapping helpers shared by `blocks-call.ts` and `containers-call.ts`
|
||||
|
|
@ -87,3 +88,29 @@ export function toPorts(
|
|||
};
|
||||
});
|
||||
}
|
||||
|
||||
const NODE_VISUAL_ROLES: NodeVisualRole[] = ['ACTIVITY', 'DECISION', 'MERGE', 'END', 'CONTAINER'];
|
||||
|
||||
function toVisualRole(value: unknown): NodeVisualRole {
|
||||
const candidate = typeof value === 'string' ? value.toUpperCase() : '';
|
||||
return (NODE_VISUAL_ROLES as string[]).includes(candidate)
|
||||
? candidate as NodeVisualRole
|
||||
: DEFAULT_NODE_CAPABILITIES.visualRole;
|
||||
}
|
||||
|
||||
function toBooleanCapability(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
export function toNodeCapabilities(raw: unknown): NodeTypeCapabilities {
|
||||
const value = toRecord(raw);
|
||||
return {
|
||||
visualRole: toVisualRole(value['visualRole']),
|
||||
terminal: toBooleanCapability(value['terminal'], DEFAULT_NODE_CAPABILITIES.terminal),
|
||||
biasAnnotationsAllowed: toBooleanCapability(value['biasAnnotationsAllowed'], DEFAULT_NODE_CAPABILITIES.biasAnnotationsAllowed),
|
||||
allowsIncomingConnections: toBooleanCapability(value['allowsIncomingConnections'], DEFAULT_NODE_CAPABILITIES.allowsIncomingConnections),
|
||||
allowsOutgoingConnections: toBooleanCapability(value['allowsOutgoingConnections'], DEFAULT_NODE_CAPABILITIES.allowsOutgoingConnections),
|
||||
canDependOnOtherNodes: toBooleanCapability(value['canDependOnOtherNodes'], DEFAULT_NODE_CAPABILITIES.canDependOnOtherNodes),
|
||||
canHaveDependentNodes: toBooleanCapability(value['canHaveDependentNodes'], DEFAULT_NODE_CAPABILITIES.canHaveDependentNodes)
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,18 @@
|
|||
:host { display: block; }
|
||||
.bias-section { margin: 12px; padding: 12px; border: 1px solid #d8dee9; border-radius: 10px; background: #fff; color: #263244; }
|
||||
.bias-section { margin: 12px; color: #263244; }
|
||||
.bias-open-trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #d8dee9;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
.bias-header, .bias-actions, .bias-modal-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.bias-title { font-size: 13px; font-weight: 700; }
|
||||
.bias-counter { color: #64748b; font-size: 11px; }
|
||||
|
|
|
|||
|
|
@ -1,48 +1,63 @@
|
|||
@if (descriptor; as descriptor) {
|
||||
<section class="bias-section" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
|
||||
<div class="bias-header">
|
||||
<div>
|
||||
<div class="bias-title">Bias annotations</div>
|
||||
<div class="bias-counter">{{ annotations.length }}{{ descriptor.maxItems !== null ? ' / ' + descriptor.maxItems : '' }}</div>
|
||||
</div>
|
||||
@if (!readonly) {
|
||||
<button type="button" class="bias-add" [disabled]="!canAdd" (click)="add($event)">Add bias annotation</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (listError(); as error) { <div class="bias-error">{{ error }}</div> }
|
||||
@if (!annotations.length) {
|
||||
<div class="bias-empty">No bias annotations.</div>
|
||||
} @else {
|
||||
<div class="bias-list">
|
||||
@for (annotation of annotations; track annotation.id ?? $index; let index = $index) {
|
||||
<article class="bias-card">
|
||||
<div class="bias-badges">
|
||||
<span class="bias-badge category">{{ optionLabel('category', annotation.category) }}</span>
|
||||
<span class="bias-badge severity">{{ optionLabel('severity', annotation.severity) }}</span>
|
||||
<span class="bias-badge status">{{ optionLabel('status', annotation.status) }}</span>
|
||||
<span class="bias-badge source">{{ optionLabel('source', annotation.source) }}</span>
|
||||
@if (probeExecutable(annotation)) { <span class="bias-badge probe">Executable probe</span> }
|
||||
</div>
|
||||
<div class="bias-issue">{{ annotation.issue }}</div>
|
||||
@if (serverError(index); as error) { <div class="bias-error">{{ error }}</div> }
|
||||
@for (field of fields; track field.key) {
|
||||
@if (serverError(index, field.key); as error) { <div class="bias-error">{{ field.label }}: {{ error }}</div> }
|
||||
}
|
||||
@if (!readonly) {
|
||||
<div class="bias-actions">
|
||||
<button type="button" (click)="edit(index, $event)">Edit</button>
|
||||
<button type="button" class="danger" (click)="remove(index, $event)">Remove</button>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button type="button" class="bias-open-trigger" (click)="openList($event)">
|
||||
<span class="bias-title">Bias annotations</span>
|
||||
<span class="bias-counter">{{ annotations.length }}{{ countSuffix }}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
@if (listOpen) {
|
||||
<dialog #biasListDialog class="bias-modal-backdrop" (pointerdown)="$event.stopPropagation()" (wheel)="$event.stopPropagation()" (click)="onListDialogClick($event)" (cancel)="closeList($event)">
|
||||
<div class="bias-modal" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
|
||||
<div class="bias-header">
|
||||
<div>
|
||||
<div class="bias-title">Bias annotations</div>
|
||||
<div class="bias-counter">{{ annotations.length }}{{ countSuffix }}</div>
|
||||
</div>
|
||||
@if (!readonly) {
|
||||
<button type="button" class="bias-add" [disabled]="!canAdd" (click)="add($event)">Add bias annotation</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (listError(); as error) { <div class="bias-error">{{ error }}</div> }
|
||||
@if (!annotations.length) {
|
||||
<div class="bias-empty">No bias annotations.</div>
|
||||
} @else {
|
||||
<div class="bias-list">
|
||||
@for (annotation of annotations; track annotation.id ?? $index; let index = $index) {
|
||||
<article class="bias-card">
|
||||
<div class="bias-badges">
|
||||
<span class="bias-badge category">{{ optionLabel('category', annotation.category) }}</span>
|
||||
<span class="bias-badge severity">{{ optionLabel('severity', annotation.severity) }}</span>
|
||||
<span class="bias-badge status">{{ optionLabel('status', annotation.status) }}</span>
|
||||
<span class="bias-badge source">{{ optionLabel('source', annotation.source) }}</span>
|
||||
@if (probeExecutable(annotation)) { <span class="bias-badge probe">Executable probe</span> }
|
||||
</div>
|
||||
<div class="bias-issue">{{ annotation.issue }}</div>
|
||||
@if (serverError(index); as error) { <div class="bias-error">{{ error }}</div> }
|
||||
@for (field of fields; track field.key) {
|
||||
@if (serverError(index, field.key); as error) { <div class="bias-error">{{ field.label }}: {{ error }}</div> }
|
||||
}
|
||||
@if (!readonly) {
|
||||
<div class="bias-actions">
|
||||
<button type="button" (click)="edit(index, $event)">Edit</button>
|
||||
<button type="button" class="danger" (click)="remove(index, $event)">Remove</button>
|
||||
</div>
|
||||
}
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="bias-modal-actions">
|
||||
<button type="button" (click)="closeList($event)">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
}
|
||||
|
||||
@if (editorOpen) {
|
||||
<dialog #biasModalDialog class="bias-modal-backdrop" (pointerdown)="$event.stopPropagation()" (click)="onDialogClick($event)" (cancel)="close($event)">
|
||||
<dialog #biasModalDialog class="bias-modal-backdrop" (pointerdown)="$event.stopPropagation()" (wheel)="$event.stopPropagation()" (click)="onDialogClick($event)" (cancel)="close($event)">
|
||||
<form class="bias-modal" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()" (submit)="save($event)">
|
||||
<h3>{{ editingIndex === null ? 'Add' : 'Edit' }} bias annotation</h3>
|
||||
<div class="bias-form">
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ describe('BiasAnnotationsComponent', () => {
|
|||
});
|
||||
|
||||
it('renders API options and applies defaults without generating an id', () => {
|
||||
(fixture.nativeElement.querySelector('.bias-open-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.bias-add') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
expect(component.draft).toEqual({ status: 'NEW' });
|
||||
|
|
@ -100,6 +102,7 @@ describe('BiasAnnotationsComponent', () => {
|
|||
fixture.componentRef.setInput('readonly', true);
|
||||
component.remove(0);
|
||||
expect(emitted).toHaveBeenCalledTimes(1);
|
||||
(fixture.nativeElement.querySelector('.bias-open-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Add bias annotation');
|
||||
});
|
||||
|
|
@ -111,10 +114,13 @@ describe('BiasAnnotationsComponent', () => {
|
|||
});
|
||||
|
||||
it('renders the editor as a native <dialog> so it escapes the node canvas transform, and closing it via the backdrop click works', () => {
|
||||
(fixture.nativeElement.querySelector('.bias-open-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.bias-add') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const dialog = fixture.nativeElement.querySelector('dialog.bias-modal-backdrop') as HTMLDialogElement;
|
||||
const dialogs = fixture.nativeElement.querySelectorAll('dialog.bias-modal-backdrop');
|
||||
const dialog = dialogs[dialogs.length - 1] as HTMLDialogElement;
|
||||
expect(dialog).not.toBeNull();
|
||||
|
||||
component.onDialogClick({ target: dialog, stopPropagation: vi.fn() } as unknown as MouseEvent);
|
||||
|
|
@ -122,14 +128,33 @@ describe('BiasAnnotationsComponent', () => {
|
|||
});
|
||||
|
||||
it('does not close when clicking inside the dialog content', () => {
|
||||
(fixture.nativeElement.querySelector('.bias-open-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
(fixture.nativeElement.querySelector('.bias-add') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const form = fixture.nativeElement.querySelector('.bias-modal') as HTMLElement;
|
||||
const forms = fixture.nativeElement.querySelectorAll('.bias-modal');
|
||||
const form = forms[forms.length - 1] as HTMLElement;
|
||||
component.onDialogClick({ target: form, stopPropagation: vi.fn() } as unknown as MouseEvent);
|
||||
expect(component.editorOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('opens the annotations list in its own dialog, separate from the add/edit dialog', () => {
|
||||
fixture.componentRef.setInput('annotations', [{ issue: 'one' }]);
|
||||
expect(fixture.nativeElement.querySelector('dialog.bias-modal-backdrop')).toBeNull();
|
||||
|
||||
(fixture.nativeElement.querySelector('.bias-open-trigger') as HTMLButtonElement).click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.listOpen).toBe(true);
|
||||
const dialog = fixture.nativeElement.querySelector('dialog.bias-modal-backdrop') as HTMLDialogElement;
|
||||
expect(dialog).not.toBeNull();
|
||||
expect(dialog.textContent).toContain('one');
|
||||
|
||||
component.onListDialogClick({ target: dialog, stopPropagation: vi.fn() } as unknown as MouseEvent);
|
||||
expect(component.listOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('maps typed mock-output probe errors to nested fields', () => {
|
||||
validationErrors.set([{
|
||||
code: 'BIAS_PROBE_MOCK_OUTPUT_TYPE_MISMATCH',
|
||||
|
|
|
|||
|
|
@ -50,23 +50,34 @@ export class BiasAnnotationsComponent {
|
|||
@Input() readonly = false;
|
||||
@Output() annotationsChange = new EventEmitter<BiasAnnotation[]>();
|
||||
|
||||
listOpen = false;
|
||||
editorOpen = false;
|
||||
editingIndex: number | null = null;
|
||||
draft: BiasAnnotation = {};
|
||||
clientErrors: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* The editor renders inside a node card, which rete.js positions with a CSS
|
||||
* Both dialogs render inside a node card, which rete.js positions with a CSS
|
||||
* `transform` for pan/zoom. A `transform` on any ancestor turns it into the
|
||||
* containing block for `position: fixed` descendants, so a plain fixed-position
|
||||
* backdrop would be confined to the node's box instead of covering the page.
|
||||
* A native `<dialog>` opened via `showModal()` is promoted to the browser's
|
||||
* top layer, which sits above the whole document regardless of ancestor
|
||||
* transforms — no manual DOM reparenting needed.
|
||||
* transforms — no manual DOM reparenting needed. The list dialog and the
|
||||
* add/edit dialog are independent `<dialog>` elements, so opening the editor
|
||||
* from within the list simply layers a second top-layer dialog on top.
|
||||
*/
|
||||
private readonly listDialog = viewChild<ElementRef<HTMLDialogElement>>('biasListDialog');
|
||||
private readonly modalDialog = viewChild<ElementRef<HTMLDialogElement>>('biasModalDialog');
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const dialog = this.listDialog()?.nativeElement;
|
||||
if (dialog && typeof dialog.showModal === 'function' && !dialog.open) {
|
||||
dialog.showModal();
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const dialog = this.modalDialog()?.nativeElement;
|
||||
if (dialog && typeof dialog.showModal === 'function' && !dialog.open) {
|
||||
|
|
@ -75,12 +86,33 @@ export class BiasAnnotationsComponent {
|
|||
});
|
||||
}
|
||||
|
||||
onListDialogClick(event: MouseEvent) {
|
||||
if (event.target === this.listDialog()?.nativeElement) {
|
||||
this.closeList(event);
|
||||
}
|
||||
}
|
||||
|
||||
onDialogClick(event: MouseEvent) {
|
||||
if (event.target === this.modalDialog()?.nativeElement) {
|
||||
this.close(event);
|
||||
}
|
||||
}
|
||||
|
||||
get countSuffix(): string {
|
||||
const max = this.descriptor?.maxItems;
|
||||
return max != null ? ` / ${max}` : '';
|
||||
}
|
||||
|
||||
openList(event?: Event) {
|
||||
event?.stopPropagation();
|
||||
this.listOpen = true;
|
||||
}
|
||||
|
||||
closeList(event?: Event) {
|
||||
event?.stopPropagation();
|
||||
this.listOpen = false;
|
||||
}
|
||||
|
||||
get descriptor(): BiasAnnotationsDescriptor | null {
|
||||
const descriptorSignal = (this.blocks as BlocksService & {
|
||||
biasAnnotationsDescriptor?: () => BiasAnnotationsDescriptor | null
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@
|
|||
overflow: hidden;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
font-size: 14px;
|
||||
line-height: 1.3;
|
||||
font-weight: 700;
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@
|
|||
padding: 0 !important;
|
||||
margin-bottom: 10px;
|
||||
overflow: visible !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.flows-list-list-item:last-child {
|
||||
|
|
@ -72,4 +73,5 @@
|
|||
:host ::ng-deep .flows-list-list-item .mdc-list-item__content,
|
||||
:host ::ng-deep .flows-list-list-item .mat-mdc-list-item-unscoped-content {
|
||||
overflow: visible !important;
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -439,10 +439,12 @@
|
|||
</div>
|
||||
}
|
||||
|
||||
@if (biasAnnotationsAllowed) {
|
||||
<app-bias-annotations
|
||||
[blockId]="blockId ?? ''"
|
||||
[block]="biasContainer"
|
||||
[annotations]="biasAnnotations"
|
||||
[readonly]="isReadonly"
|
||||
(annotationsChange)="updateBiasAnnotations($event)" />
|
||||
}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { DEFAULT_NODE_CAPABILITIES } from '@models/flow';
|
||||
import { of } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
|
|
@ -222,6 +223,27 @@ describe('ContainerNodeComponent', () => {
|
|||
expect(component.biasAnnotationBadge).toBeNull();
|
||||
});
|
||||
|
||||
it('allows bias annotations by default when no descriptor capabilities are known', () => {
|
||||
component.data = { data: { id: 'container-1', specificConfiguration: {}, inputs: [], outputs: [] } };
|
||||
expect(component.biasAnnotationsAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the bias badge when biasAnnotationsAllowed is false', () => {
|
||||
component.data = {
|
||||
data: {
|
||||
id: 'container-1', specificConfiguration: {}, inputs: [], outputs: [],
|
||||
biasAnnotations: [{ id: 'a1', severity: 'HIGH' }]
|
||||
}
|
||||
};
|
||||
(component as any).containerDescriptor = {
|
||||
type: 'RestrictedContainer',
|
||||
capabilities: { ...DEFAULT_NODE_CAPABILITIES, biasAnnotationsAllowed: false }
|
||||
};
|
||||
|
||||
expect(component.biasAnnotationsAllowed).toBe(false);
|
||||
expect(component.biasAnnotationBadge).toBeNull();
|
||||
});
|
||||
|
||||
it('computes the bias annotation badge from the container annotations and the severity catalog', () => {
|
||||
const blocks = TestBed.inject(BlocksService) as any;
|
||||
blocks.biasAnnotationsDescriptor.mockReturnValue({
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
|
|||
import {
|
||||
BiasAnnotation,
|
||||
BiasAnnotationsDescriptor,
|
||||
BlockType,
|
||||
currentFlowPortValueKind,
|
||||
flowValueKindLabel,
|
||||
FlowContainer,
|
||||
|
|
@ -106,6 +107,7 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
private blocksService = inject(BlocksService);
|
||||
private settingsDialog = inject(NodeSettingsDialogService);
|
||||
private containerSchema: Record<string, any> | null = null;
|
||||
private containerDescriptor: BlockType | null = null;
|
||||
private schemaRequirements: SchemaRequirements = { required: [], requiredObjects: [], conditional: [] };
|
||||
private containerFieldDefinitions: ContainerFieldDefinition[] = [];
|
||||
private containerFlowFieldDefinitions: SchemaFlowDataFieldDefinition[] = [];
|
||||
|
|
@ -383,7 +385,12 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
return Array.isArray(value) ? value as BiasAnnotation[] : [];
|
||||
}
|
||||
|
||||
get biasAnnotationsAllowed(): boolean {
|
||||
return this.containerDescriptor?.capabilities?.biasAnnotationsAllowed !== false;
|
||||
}
|
||||
|
||||
get biasAnnotationBadge(): { count: number; hasExecutableProbe: boolean; maxSeverityLabel: string | null } | null {
|
||||
if (!this.biasAnnotationsAllowed) return null;
|
||||
const annotations = this.biasAnnotations;
|
||||
if (!annotations.length) return null;
|
||||
return {
|
||||
|
|
@ -922,6 +929,7 @@ export class ContainerNodeComponent implements OnDestroy {
|
|||
this.schemaReady = false;
|
||||
try {
|
||||
const containerType = this.containersService.peekContainerType(this.typeName) ?? await this.containersService.getContainerType(this.typeName);
|
||||
this.containerDescriptor = containerType ?? null;
|
||||
this.containerSchema = (containerType?.schema ?? null) as Record<string, any> | null;
|
||||
this.schemaRequirements = extractSchemaRequirements(this.containerSchema);
|
||||
this.containerFlowFieldDefinitions = collectSchemaFlowDataFields(this.containerSchema);
|
||||
|
|
|
|||
|
|
@ -532,12 +532,14 @@
|
|||
}
|
||||
</div>
|
||||
|
||||
@if (biasAnnotationsAllowed) {
|
||||
<app-bias-annotations
|
||||
[blockId]="blockId ?? ''"
|
||||
[block]="biasBlock"
|
||||
[annotations]="biasAnnotations"
|
||||
[readonly]="isReadonly"
|
||||
(annotationsChange)="updateBiasAnnotations($event)" />
|
||||
}
|
||||
|
||||
@if (localEditorOpen) {
|
||||
<dialog #simpleEditorDialog class="llm-modal-backdrop" (pointerdown)="$event.stopPropagation()" (click)="onSimpleEditorDialogClick($event)" (cancel)="closeSimpleParamEditor($event)">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { DEFAULT_NODE_CAPABILITIES } from '@models/flow';
|
||||
import { BlocksService } from '@services/blocks/blocks';
|
||||
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
|
||||
import { FieldRetriever } from '@services/retriever/field-retriever';
|
||||
|
|
@ -118,6 +119,33 @@ describe('GenericNodeComponent', () => {
|
|||
expect(outputLabels).toEqual(['noAssessment', 'excluded', 'continued']);
|
||||
});
|
||||
|
||||
it('keeps a flexible port kind-selectable after narrowing it once', () => {
|
||||
fixture.componentRef.setInput('data', {
|
||||
...component.data,
|
||||
outputs: {
|
||||
output: { socket: { name: 'ANY' } }
|
||||
},
|
||||
data: {
|
||||
...component.data.data,
|
||||
outputs: [{ name: 'output', type: 'ANY', multiple: false }]
|
||||
}
|
||||
});
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.canTogglePortMultiplicity('output', 'output')).toBe(true);
|
||||
expect(component.portSelectableKindOptions('output', 'output').map((option) => option.label)).toEqual(
|
||||
expect.arrayContaining(['TEXT', 'FILE', 'JSON'])
|
||||
);
|
||||
|
||||
component.onPortKindChange('output', 'output', 'TEXT::single');
|
||||
|
||||
expect(component.portCurrentKindLabel('output', 'output')).toBe('TEXT');
|
||||
expect(component.canTogglePortMultiplicity('output', 'output')).toBe(true);
|
||||
expect(component.portSelectableKindOptions('output', 'output').map((option) => option.label)).toEqual(
|
||||
expect.arrayContaining(['TEXT', 'FILE', 'JSON'])
|
||||
);
|
||||
});
|
||||
|
||||
it('restores and syncs persisted expanded-mode state', () => {
|
||||
const nodeData = component.data.data as Record<string, unknown>;
|
||||
nodeData['__focusOpen'] = true;
|
||||
|
|
@ -136,6 +164,30 @@ describe('GenericNodeComponent', () => {
|
|||
expect(component.biasAnnotationBadge).toBeNull();
|
||||
});
|
||||
|
||||
it('allows bias annotations by default when no descriptor capabilities are known', () => {
|
||||
expect(component.biasAnnotationsAllowed).toBe(true);
|
||||
});
|
||||
|
||||
it('hides the bias badge and the annotations panel when biasAnnotationsAllowed is false', async () => {
|
||||
const blocks = TestBed.inject(BlocksService) as any;
|
||||
blocks.peekBlockType.mockReturnValue({
|
||||
type: 'EndBlock',
|
||||
capabilities: { ...DEFAULT_NODE_CAPABILITIES, biasAnnotationsAllowed: false }
|
||||
});
|
||||
component.data.data = {
|
||||
...component.data.data,
|
||||
typeName: 'EndBlock',
|
||||
biasAnnotations: [{ id: 'a1', severity: 'HIGH' }]
|
||||
};
|
||||
|
||||
await (component as any).loadSchemaContext();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.biasAnnotationsAllowed).toBe(false);
|
||||
expect(component.biasAnnotationBadge).toBeNull();
|
||||
expect((fixture.nativeElement as HTMLElement).querySelector('app-bias-annotations')).toBeNull();
|
||||
});
|
||||
|
||||
it('has no lane badge when the block has no laneId', () => {
|
||||
expect(component.laneBadge).toBeNull();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -554,7 +554,7 @@ export class GenericNodeComponent implements OnDestroy {
|
|||
|
||||
canTogglePortMultiplicity(kind: 'input' | 'output', key: string): boolean {
|
||||
const port = this.resolvePorts(kind).find((candidate) => candidate.name === key);
|
||||
return !!port && this.portSelectableKinds(port).length > 1;
|
||||
return !!port && this.portSelectableKinds(kind, key, port).length > 1;
|
||||
}
|
||||
|
||||
portCurrentKindLabel(kind: 'input' | 'output', key: string): string {
|
||||
|
|
@ -567,17 +567,18 @@ export class GenericNodeComponent implements OnDestroy {
|
|||
if (!port) return '';
|
||||
|
||||
const current = currentFlowPortValueKind(port);
|
||||
const exact = this.portSelectableKinds(port).find((kindOption) =>
|
||||
const selectableKinds = this.portSelectableKinds(kind, key, port);
|
||||
const exact = selectableKinds.find((kindOption) =>
|
||||
kindOption.type === current.type && kindOption.multiple === current.multiple
|
||||
);
|
||||
const selected = exact ?? this.portSelectableKinds(port)[0];
|
||||
const selected = exact ?? selectableKinds[0];
|
||||
return selected ? this.flowValueKindValue(selected) : '';
|
||||
}
|
||||
|
||||
portSelectableKindOptions(kind: 'input' | 'output', key: string): Array<{ value: string; label: string }> {
|
||||
const port = this.resolvePorts(kind).find((candidate) => candidate.name === key);
|
||||
if (!port) return [];
|
||||
return this.portSelectableKinds(port).map((kindOption) => ({
|
||||
return this.portSelectableKinds(kind, key, port).map((kindOption) => ({
|
||||
value: this.flowValueKindValue(kindOption),
|
||||
label: flowValueKindLabel(kindOption)
|
||||
}));
|
||||
|
|
@ -593,7 +594,7 @@ export class GenericNodeComponent implements OnDestroy {
|
|||
if (index < 0) return;
|
||||
|
||||
const port = ports[index];
|
||||
const nextKind = this.portSelectableKinds(port).find(
|
||||
const nextKind = this.portSelectableKinds(kind, key, port).find(
|
||||
(kindOption) => this.flowValueKindValue(kindOption) === nextValue
|
||||
);
|
||||
if (!nextKind) return;
|
||||
|
|
@ -665,7 +666,12 @@ export class GenericNodeComponent implements OnDestroy {
|
|||
return lane ? { name: lane.name, color: lane.color ?? null } : null;
|
||||
}
|
||||
|
||||
get biasAnnotationsAllowed(): boolean {
|
||||
return this.blockDescriptor?.capabilities?.biasAnnotationsAllowed !== false;
|
||||
}
|
||||
|
||||
get biasAnnotationBadge(): { count: number; hasExecutableProbe: boolean; maxSeverityLabel: string | null } | null {
|
||||
if (!this.biasAnnotationsAllowed) return null;
|
||||
const annotations = this.biasAnnotations;
|
||||
if (!annotations.length) return null;
|
||||
return {
|
||||
|
|
@ -896,19 +902,32 @@ export class GenericNodeComponent implements OnDestroy {
|
|||
return normalizeFlowPortValueKinds(port);
|
||||
}
|
||||
|
||||
private portSelectableKinds(port: FlowPort): FlowValueKind[] {
|
||||
/**
|
||||
* Once a port is discovered to be kind-flexible (declared as ANY), that fact is
|
||||
* remembered here by port key. Picking a concrete kind narrows `port.type`/`multiple`
|
||||
* for connection-compatibility purposes, which would otherwise make the port look
|
||||
* like a fixed single-kind port on the next read and permanently hide the selector.
|
||||
*/
|
||||
private flexiblePortKeys = new Set<string>();
|
||||
|
||||
private portSelectableKinds(kindSide: 'input' | 'output', key: string, port: FlowPort): FlowValueKind[] {
|
||||
const expanded = new Map<string, FlowValueKind>();
|
||||
const portKey = `${kindSide}:${key}`;
|
||||
const valueKinds = this.portValueKinds(port);
|
||||
const isFlexible = valueKinds.some((kind) => String(kind.type ?? 'ANY').toUpperCase() === 'ANY')
|
||||
|| this.flexiblePortKeys.has(portKey);
|
||||
|
||||
for (const kind of this.portValueKinds(port)) {
|
||||
const type = String(kind.type ?? 'ANY').toUpperCase();
|
||||
if (type === 'ANY') {
|
||||
for (const concreteType of ['TEXT', 'FILE', 'JSON']) {
|
||||
const concreteKind = { type: concreteType, multiple: Boolean(kind.multiple) };
|
||||
expanded.set(this.flowValueKindValue(concreteKind), concreteKind);
|
||||
}
|
||||
continue;
|
||||
if (isFlexible) {
|
||||
this.flexiblePortKeys.add(portKey);
|
||||
const multiple = Boolean(valueKinds[0]?.multiple ?? port.multiple);
|
||||
for (const concreteType of ['TEXT', 'FILE', 'JSON']) {
|
||||
const concreteKind = { type: concreteType, multiple };
|
||||
expanded.set(this.flowValueKindValue(concreteKind), concreteKind);
|
||||
}
|
||||
return Array.from(expanded.values());
|
||||
}
|
||||
|
||||
for (const kind of valueKinds) {
|
||||
expanded.set(this.flowValueKindValue(kind), kind);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
import { DEFAULT_NODE_CAPABILITIES } from '@models/flow';
|
||||
import { HFNode } from '@models/nodes';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { resolveNodeCapabilities, ReteRuntimeContext } from './rete-editor';
|
||||
|
||||
function makeNode(typeName: string, nodeFamily: 'block' | 'container'): HFNode {
|
||||
return { data: { typeName, nodeFamily } } as unknown as HFNode;
|
||||
}
|
||||
|
||||
function makeRuntime(overrides: Partial<ReteRuntimeContext> = {}): ReteRuntimeContext {
|
||||
return {
|
||||
blocksService: { peekBlockType: vi.fn().mockReturnValue(null) } as any,
|
||||
containersService: { peekContainerType: vi.fn().mockReturnValue(null) } as any,
|
||||
flowState: {} as any,
|
||||
readonly: false,
|
||||
globalInputs: [],
|
||||
lanes: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('resolveNodeCapabilities', () => {
|
||||
it('falls back to DEFAULT_NODE_CAPABILITIES when there is no runtime, node or typeName', () => {
|
||||
expect(resolveNodeCapabilities(undefined, makeNode('LLMBlock', 'block'))).toEqual(DEFAULT_NODE_CAPABILITIES);
|
||||
expect(resolveNodeCapabilities(makeRuntime(), undefined)).toEqual(DEFAULT_NODE_CAPABILITIES);
|
||||
expect(resolveNodeCapabilities(makeRuntime(), makeNode('', 'block'))).toEqual(DEFAULT_NODE_CAPABILITIES);
|
||||
});
|
||||
|
||||
it('falls back to DEFAULT_NODE_CAPABILITIES when the descriptor has none', () => {
|
||||
const runtime = makeRuntime();
|
||||
expect(resolveNodeCapabilities(runtime, makeNode('LLMBlock', 'block'))).toEqual(DEFAULT_NODE_CAPABILITIES);
|
||||
});
|
||||
|
||||
it('resolves capabilities for a block node via blocksService.peekBlockType', () => {
|
||||
const endBlockCapabilities = {
|
||||
visualRole: 'END' as const,
|
||||
terminal: true,
|
||||
biasAnnotationsAllowed: false,
|
||||
allowsIncomingConnections: true,
|
||||
allowsOutgoingConnections: false,
|
||||
canDependOnOtherNodes: false,
|
||||
canHaveDependentNodes: false
|
||||
};
|
||||
const peekBlockType = vi.fn().mockReturnValue({ type: 'EndBlock', capabilities: endBlockCapabilities });
|
||||
const runtime = makeRuntime({ blocksService: { peekBlockType } as any });
|
||||
|
||||
expect(resolveNodeCapabilities(runtime, makeNode('EndBlock', 'block'))).toEqual(endBlockCapabilities);
|
||||
expect(peekBlockType).toHaveBeenCalledWith('EndBlock');
|
||||
});
|
||||
|
||||
it('resolves capabilities for a container node via containersService.peekContainerType, not blocksService', () => {
|
||||
const peekBlockType = vi.fn().mockReturnValue(null);
|
||||
const peekContainerType = vi.fn().mockReturnValue({
|
||||
type: 'GenericContainer',
|
||||
capabilities: { ...DEFAULT_NODE_CAPABILITIES, visualRole: 'CONTAINER' as const }
|
||||
});
|
||||
const runtime = makeRuntime({
|
||||
blocksService: { peekBlockType } as any,
|
||||
containersService: { peekContainerType } as any
|
||||
});
|
||||
|
||||
expect(resolveNodeCapabilities(runtime, makeNode('GenericContainer', 'container')))
|
||||
.toEqual({ ...DEFAULT_NODE_CAPABILITIES, visualRole: 'CONTAINER' });
|
||||
expect(peekContainerType).toHaveBeenCalledWith('GenericContainer');
|
||||
expect(peekBlockType).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -9,6 +9,7 @@ import { AngularPlugin, Presets, AngularArea2D } from "rete-angular-plugin/21";
|
|||
import { HFNode, HFSchemes } from "@models/nodes";
|
||||
import {
|
||||
areFlowValueKindsCompatible,
|
||||
DEFAULT_NODE_CAPABILITIES,
|
||||
FlowBlock,
|
||||
FlowData,
|
||||
FlowGlobalInput,
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
FLOW_DEPENDENCY_PORT_KEY,
|
||||
FLOW_DEPENDENCY_SOCKET_TYPE,
|
||||
FlowNode,
|
||||
NodeTypeCapabilities,
|
||||
normalizeFlowPortValueKinds
|
||||
} from "@models/flow";
|
||||
import { BlocksService } from "@services/blocks/blocks";
|
||||
|
|
@ -42,7 +44,7 @@ export type ReteEditorInstance = {
|
|||
|
||||
type GraphConnectionKind = "data" | "dependency";
|
||||
|
||||
type ReteRuntimeContext = {
|
||||
export type ReteRuntimeContext = {
|
||||
blocksService: BlocksService;
|
||||
containersService: ContainersService;
|
||||
flowState: EditorStateHolder;
|
||||
|
|
@ -104,14 +106,20 @@ export async function createEditor(
|
|||
editor.addPipe((context) => {
|
||||
if (context.type !== "connectioncreate") return context;
|
||||
|
||||
const sourceNode = editor.getNode(context.data.source) as HFNode | undefined;
|
||||
const targetNode = editor.getNode(context.data.target) as HFNode | undefined;
|
||||
const sourceCapabilities = resolveNodeCapabilities(runtime, sourceNode);
|
||||
const targetCapabilities = resolveNodeCapabilities(runtime, targetNode);
|
||||
|
||||
const connectionKind = getGraphConnectionKind(context.data.sourceOutput, context.data.targetInput);
|
||||
if (connectionKind === "dependency") {
|
||||
if (context.data.source === context.data.target) return;
|
||||
if (!sourceCapabilities.canHaveDependentNodes || !targetCapabilities.canDependOnOtherNodes) return;
|
||||
return context;
|
||||
}
|
||||
|
||||
const sourceNode = editor.getNode(context.data.source) as HFNode | undefined;
|
||||
const targetNode = editor.getNode(context.data.target) as HFNode | undefined;
|
||||
if (!sourceCapabilities.allowsOutgoingConnections || !targetCapabilities.allowsIncomingConnections) return;
|
||||
|
||||
const sourcePort = resolveNodePort(sourceNode, "output", context.data.sourceOutput);
|
||||
const targetPort = resolveNodePort(targetNode, "input", context.data.targetInput);
|
||||
|
||||
|
|
@ -543,8 +551,13 @@ export async function addBlockToEditor(
|
|||
__containerAssigning: false
|
||||
};
|
||||
|
||||
node.addOutput(FLOW_DEPENDANT_PORT_KEY, new ClassicPreset.Output(getSocket(editor, FLOW_DEPENDENCY_SOCKET_TYPE)));
|
||||
node.addInput(FLOW_DEPENDENCY_PORT_KEY, new ClassicPreset.Input(getSocket(editor, FLOW_DEPENDENCY_SOCKET_TYPE), undefined, true));
|
||||
const capabilities = resolveNodeCapabilities(resolvedRuntime, node);
|
||||
if (capabilities.canHaveDependentNodes) {
|
||||
node.addOutput(FLOW_DEPENDANT_PORT_KEY, new ClassicPreset.Output(getSocket(editor, FLOW_DEPENDENCY_SOCKET_TYPE)));
|
||||
}
|
||||
if (capabilities.canDependOnOtherNodes) {
|
||||
node.addInput(FLOW_DEPENDENCY_PORT_KEY, new ClassicPreset.Input(getSocket(editor, FLOW_DEPENDENCY_SOCKET_TYPE), undefined, true));
|
||||
}
|
||||
|
||||
for (const output of block.outputs ?? []) {
|
||||
node.addOutput(output.name, new ClassicPreset.Output(getSocket(editor, output.type ?? "ANY")));
|
||||
|
|
@ -624,6 +637,17 @@ function getSocket(editor: NodeEditor<HFSchemes>, type: string) {
|
|||
return map.get(type)!;
|
||||
}
|
||||
|
||||
export function resolveNodeCapabilities(runtime: ReteRuntimeContext | undefined, node: HFNode | undefined): NodeTypeCapabilities {
|
||||
const typeName = node?.data?.typeName;
|
||||
if (!runtime || typeof typeName !== "string" || !typeName) return DEFAULT_NODE_CAPABILITIES;
|
||||
|
||||
const descriptor = node?.data?.nodeFamily === "container"
|
||||
? runtime.containersService.peekContainerType(typeName)
|
||||
: runtime.blocksService.peekBlockType(typeName);
|
||||
|
||||
return descriptor?.capabilities ?? DEFAULT_NODE_CAPABILITIES;
|
||||
}
|
||||
|
||||
function resolveNodePort(node: HFNode | undefined, kind: "input" | "output", portName: string) {
|
||||
const ports = node?.data?.[kind === "input" ? "inputs" : "outputs"];
|
||||
if (!Array.isArray(ports)) return null;
|
||||
|
|
|
|||
Loading…
Reference in New Issue