feat(flow-editor): support node capabilities

This commit is contained in:
Lucio Lelii 2026-07-23 10:37:28 +02:00
parent c031eb8535
commit 318531111d
23 changed files with 583 additions and 71 deletions

View File

@ -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 = {

View File

@ -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 }]

View File

@ -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());

View File

@ -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"])
};
}

View File

@ -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: {

View File

@ -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());

View File

@ -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"])
};
}

View File

@ -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
});
});
});

View File

@ -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)
};
}

View File

@ -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; }

View File

@ -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">

View File

@ -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',

View File

@ -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

View File

@ -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;

View File

@ -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;
}

View File

@ -439,10 +439,12 @@
</div>
}
@if (biasAnnotationsAllowed) {
<app-bias-annotations
[blockId]="blockId ?? ''"
[block]="biasContainer"
[annotations]="biasAnnotations"
[readonly]="isReadonly"
(annotationsChange)="updateBiasAnnotations($event)" />
}
</div>

View File

@ -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({

View File

@ -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);

View File

@ -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)">

View File

@ -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();
});

View File

@ -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);
}

View File

@ -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();
});
});

View File

@ -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;